From 5506fb933063b5da597f555f8650ceae6f8a2d04 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 5 Oct 2026 12:48:47 +0000 Subject: [PATCH] fix(notifier): migrate opencode notifier to v2 routes (v0.21.11) CodeNomad's per-workspace instance proxy was upgraded and now only serves opencode v2 /api/* routes; the v1 /session/... routes 403, which broke all notifications. - Discovery: GET {api}{proxyPath}/api/session (v2 {data,cursor} envelope) instead of GET {base}/session; sessions filtered by location.directory and the max time.updated entry is picked. - Send: POST {api}{proxyPath}/api/session/{sid}/prompt with a {text} body instead of POST {base}/session/{id}/message with {parts:[...]}. - v2 returns 2xx immediately, so the 15 s read-timeout-as-delivered branch is kept only as a harmless safety net. - Updated the spec to the v2 routes/body/envelope (incl. a directory-filter assertion) and kept AGENTS.md in sync. --- AGENTS.md | 2 +- CHANGELOG.md | 4 ++ package.json | 2 +- src/opencode/opencode.service.spec.ts | 72 +++++++++++++++++---------- src/opencode/opencode.service.ts | 37 +++++++++----- 5 files changed, 75 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d27eab..e13dc39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## API 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). -2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: , "replyId"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff, else 400) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet (`Data.reply_id`, field 7). Returns `{ id }`), `POST /api/respond` (body `{ "messageId": , "content": "..." }`, returns `{ id }`; reply to an earlier message by packet id — routing is automatic: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast; the reply always rides the record's channel and carries `replyId = messageId` so the recipient app renders it as a threaded quote of that packet; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from` and `to`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, returns `{ id }`; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/forwarded` (the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]`: channel in the MESH_NOTIFY_CHANNEL list (ch0: directed DMs only), not a self-echo, notifier wired; id is the packet id for real rx or the generated record id for injected, `from` omitted when there is no sender; in-memory only, reset on restart), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification: when an RX text arrives on a channel in the `MESH_NOTIFY_CHANNEL` comma-separated list (default `3`; entries trimmed, empty/invalid ones ignored), the in-process `OpencodeService` notifier (`src/opencode`, registered in `MeshModule`) pushes it to opencode — fire-and-forget, no response handling. Channel 0 is the shared default channel where other users talk: only directed DMs (both sender and recipient present) notify there, ch0 broadcasts are ignored. It resolves the mesh-bot workspace dynamically per request from the opencode API (`OPENCODE_API`, default `http://172.24.0.1:9898`; matched by `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), fallback name `mesh-bot`, status `ready` preferred) and posts to its most recently updated session (`time.updated`) — or the pinned `OPENCODE_SESSION` when set, which skips discovery. The message POST body is exactly `{"parts":[{"type":"text","text":msg}]}` with msg = `New mesh message (id )[ from node ]: "" — please react to it.` (the `from node` part omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth via `OPENCODE_USER`/`OPENCODE_PASS`; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; a read timeout on the final message POST means DELIVERED (opencode accepts immediately but holds the response until the agent turn finishes) and is treated as success without retry, while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Injected messages (`POST /api/messages`, channel 0) trigger it too when `0` is in the list — injections ride ch0 by design and are exempt from the ch0 DM-only rule. Messages from our own node are never notified (self-echo filter). +2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: , "replyId"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Optional `replyId` (integer in 1..0xffffffff, else 400) references an earlier message's packet id from `GET /api/messages` — when set, the recipient app renders the text as a threaded reply/quote of that packet (`Data.reply_id`, field 7). Returns `{ id }`), `POST /api/respond` (body `{ "messageId": , "content": "..." }`, returns `{ id }`; reply to an earlier message by packet id — routing is automatic: rx records are answered as a DM back to the original sender unless the original was a broadcast (then the reply is a broadcast), tx records go to their destination, injected messages without a sender get a broadcast; the reply always rides the record's channel and carries `replyId = messageId` so the recipient app renders it as a threaded quote of that packet; 400 on invalid messageId/content, 404 when no message has the given id (in memory or in SQLite), 503 when the bridge is down), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {
: ...}, moduleConfig: {: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from` and `to`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100 in memory (seeded from SQLite on startup) — backed by SQLite: re-upserted every MESH_POLL_MS and pruned to the newest 1000 rows, so it survives process restarts and bridge reconnects), `POST /api/messages` (body `{ "text": "...", "from"?: }`, returns `{ id }`; synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh (channel 0) so external systems can push messages to trigger communication later; NOT transmitted over RF, nothing goes to the bridge or air; appears in `GET /api/messages` like any other rx message), `GET /api/forwarded` (the last 100 rx texts that passed the notify gate and were forwarded to opencode, newest first as `{ id, from?, text, channel, at }[]`: channel in the MESH_NOTIFY_CHANNEL list (ch0: directed DMs only), not a self-echo, notifier wired; id is the packet id for real rx or the generated record id for injected, `from` omitted when there is no sender; in-memory only, reset on restart), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). Message history is persisted to SQLite: file path from `MESH_DB_PATH` (default `./data/mesh-bot.db`, parent dir auto-created), poller re-upserts every `MESH_POLL_MS` ms (default 1000) and prunes the table to the newest 1000 rows. Outbound notification: when an RX text arrives on a channel in the `MESH_NOTIFY_CHANNEL` comma-separated list (default `3`; entries trimmed, empty/invalid ones ignored), the in-process `OpencodeService` notifier (`src/opencode`, registered in `MeshModule`) pushes it to opencode — fire-and-forget, no response handling. Channel 0 is the shared default channel where other users talk: only directed DMs (both sender and recipient present) notify there, ch0 broadcasts are ignored. It resolves the mesh-bot workspace dynamically per request from the opencode API (`OPENCODE_API`, default `http://172.24.0.1:9898`; matched by `OPENCODE_WORKSPACE_PATH` (default `/home/krzyw/projects/sandbox/mesh-bot`), fallback name `mesh-bot`, status `ready` preferred) and — when no session is pinned — discovers the most recently updated one via `GET {api}{proxyPath}/api/session` (opencode v2: the response is a `{ data, cursor }` envelope of session entries whose directory lives under `location.directory` and whose `time.updated` is epoch ms; only entries with `location.directory` = the workspace path are considered, then max `time.updated` wins) — or the pinned `OPENCODE_SESSION` when set, which skips discovery. The old v1 `/session` discovery route is gone: CodeNomad's per-workspace instance proxy now only serves v2 `/api/*` (v1 `/session/…` 403s). The message is posted to `POST {api}{proxyPath}/api/session/{sid}/prompt` and the body is exactly `{"text":msg}` with msg = `New mesh message (id )[ from node ]: "" — please react to it.` (the `from node` part omitted when absent; the packet id is the rx packet's id, or the generated record id for injected messages), HTTP Basic auth via `OPENCODE_USER`/`OPENCODE_PASS`; empty credentials disable the notifier entirely (one startup log line, no-op). Every call has a 15 s timeout; v2 returns 2xx immediately (a 2xx = delivered), and a read timeout on the final prompt POST is still treated as delivered success without retry (kept as a harmless safety net), while workspace/session GET failures are retried up to 3 times with backoff (2 s / 10 s / 30 s) before being dropped. Injected messages (`POST /api/messages`, channel 0) trigger it too when `0` is in the list — injections ride ch0 by design and are exempt from the ch0 DM-only rule. Messages from our own node are never notified (self-echo filter). 3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?, replyId?)` (`to` resolves like `POST /api/send`; optional `replyId` integer in 1..0xffffffff makes the message render as a threaded reply/quote of that packet id from `GET /api/messages` or `get_messages`; errors surface as tool errors with the same messages), `respond(messageId, content)` (reply to an earlier message by packet id from `get_messages`; routing is automatic — rx → DM back to the sender unless the original was a broadcast, tx → its destination, no-sender injected records → broadcast; rides the record's channel with replyId = messageId so the recipient app renders it as a threaded quote; invalid args or an unknown id are tool errors), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `inject_message(text, from?)` (synthetic inbox injection for external triggers — records an rx entry in the history as if received on the mesh; NOT sent over RF; same validation as `POST /api/messages`), `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else (`respond` rides the record's channel). Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e15c44..f59183d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.21.11 — 2026-10-05 14:47 + +- Migrated the opencode notifier to the v2 API routes: discovery is now `GET {api}{proxyPath}/api/session` (a `{ data, cursor }` envelope; sessions filtered by `location.directory`, most-recent `time.updated` wins) and send is now `POST {api}{proxyPath}/api/session/{sid}/prompt` with a `{text}` body, instead of the v1 `GET /session` + `POST /session/{id}/message` (`{parts:[{type:"text",text}]}`) routes. CodeNomad's per-workspace instance proxy was upgraded and now only serves opencode v2 `/api/*` — every v1 `/session/…` call 403s, which was breaking all notifications. v2 returns 2xx immediately (no longer holds the response until the turn finishes), so the 15 s read-timeout-as-delivered branch is kept only as a harmless safety net. + ## v0.21.10 — 2026-09-30 05:46 - Container now runs with `TZ=Europe/Warsaw` (new line in the bot service's `environment:` block, hardcoded like MESH_HOST/MESH_PORT): scheduled jobs and log timestamps use local Polish time instead of UTC — the weather cron (`WEATHER_CRON=0 6,12,18 * * *`) previously fired at 06/12/18 UTC = 08/14/20 CEST. DST-aware via the IANA zone (CEST +2 / CET +1). Deployment-config only — no application code changed; takes effect on the next `docker compose up -d --build`. diff --git a/package.json b/package.json index 779e2c7..bd7cde6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.21.10", + "version": "0.21.11", "description": "", "author": "", "private": true, diff --git a/src/opencode/opencode.service.spec.ts b/src/opencode/opencode.service.spec.ts index 0d6d6cc..247912b 100644 --- a/src/opencode/opencode.service.spec.ts +++ b/src/opencode/opencode.service.spec.ts @@ -72,7 +72,7 @@ describe('OpencodeService.notify', () => { expect(fetchMock).toHaveBeenCalledTimes(2); const [url, init] = fetchMock.mock.calls[1]; - expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`); + expect(url).toBe(`${API}/proxy/abc/api/session/ses_pinned/prompt`); const opts = init as { method?: string; headers?: Record; @@ -83,15 +83,10 @@ describe('OpencodeService.notify', () => { expect(opts.headers?.authorization).toBe( 'Basic ' + Buffer.from('u:p').toString('base64'), ); - // body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen + // body is EXACTLY {"text": msg} — em-dash, not a hyphen — and a 2xx resolves notify as delivered expect(String(opts.body)).toBe( JSON.stringify({ - parts: [ - { - type: 'text', - text: 'New mesh message (id 7) from node 42: "hi" — please react to it.', - }, - ], + text: 'New mesh message (id 7) from node 42: "hi" — please react to it.', }), ); }); @@ -111,12 +106,7 @@ describe('OpencodeService.notify', () => { const [, init] = fetchMock.mock.calls[1]; expect(String((init as { body?: string }).body)).toBe( JSON.stringify({ - parts: [ - { - type: 'text', - text: 'New mesh message (id 7): "hi" — please react to it.', - }, - ], + text: 'New mesh message (id 7): "hi" — please react to it.', }), ); }); @@ -149,7 +139,7 @@ describe('OpencodeService.notify', () => { await svc.notify(7, 'hi'); expect(fetchMock.mock.calls[1][0]).toBe( - `${API}/proxy/abc/session/ses_pinned/message`, + `${API}/proxy/abc/api/session/ses_pinned/prompt`, ); }); @@ -182,7 +172,7 @@ describe('OpencodeService.notify', () => { await svc.notify(7, 'hi'); expect(fetchMock.mock.calls[1][0]).toBe( - `${API}/proxy/b/session/ses_pinned/message`, + `${API}/proxy/b/api/session/ses_pinned/prompt`, ); }); @@ -191,18 +181,37 @@ describe('OpencodeService.notify', () => { fetchMock .mockResolvedValueOnce(json([WORKSPACE])) .mockResolvedValueOnce( - json([ - { id: 'ses_old', time: { updated: 100 } }, - { id: 'ses_new', time: { updated: 200 } }, - ]), + // v2 envelope {data, cursor}; the /elsewhere session is more recent but a different + // directory, so the location.directory filter must exclude it + json({ + data: [ + { + id: 'ses_old', + location: { directory: WS_PATH }, + time: { updated: 100 }, + }, + { + id: 'ses_other_ws', + location: { directory: '/elsewhere' }, + time: { updated: 300 }, + }, + { + id: 'ses_new', + location: { directory: WS_PATH }, + time: { updated: 200 }, + }, + ], + cursor: { previous: null, next: null }, + }), ) .mockResolvedValueOnce(json({})); await svc.notify(7, 'hi'); - expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`); + // discovery GET hits the v2 route; the prompt POST goes to the most recently updated session of THIS directory + expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`); expect(fetchMock.mock.calls[2][0]).toBe( - `${API}/proxy/abc/session/ses_new/message`, + `${API}/proxy/abc/api/session/ses_new/prompt`, ); }); @@ -210,13 +219,22 @@ describe('OpencodeService.notify', () => { const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' }); fetchMock .mockResolvedValueOnce(json([WORKSPACE])) - .mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }])) + .mockResolvedValueOnce( + json({ + data: [ + { id: 'ses_a', location: { directory: WS_PATH } }, + { id: 'ses_b', location: { directory: WS_PATH }, time: {} }, + ], + cursor: { previous: null, next: null }, + }), + ) .mockResolvedValueOnce(json({})); await svc.notify(7, 'hi'); + expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`); expect(fetchMock.mock.calls[2][0]).toBe( - `${API}/proxy/abc/session/ses_a/message`, + `${API}/proxy/abc/api/session/ses_a/prompt`, ); }); @@ -232,11 +250,11 @@ describe('OpencodeService.notify', () => { await svc.notify(7, 'hi'); - // workspaces GET + message POST only — no /session discovery GET + // workspaces GET + prompt POST only — no /api/session discovery GET expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`); expect(fetchMock.mock.calls[1][0]).toBe( - `${API}/proxy/abc/session/ses_pinned/message`, + `${API}/proxy/abc/api/session/ses_pinned/prompt`, ); }); @@ -248,7 +266,7 @@ describe('OpencodeService.notify', () => { }); fetchMock .mockResolvedValueOnce(json([WORKSPACE])) - .mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes + .mockRejectedValueOnce(timeoutError()); // the prompt POST times out — a read timeout is still treated as delivered (safety net) const logSpy = vi.spyOn(Logger.prototype, 'log'); await expect(svc.notify('hi')).resolves.toBeUndefined(); diff --git a/src/opencode/opencode.service.ts b/src/opencode/opencode.service.ts index d040701..7b08d72 100644 --- a/src/opencode/opencode.service.ts +++ b/src/opencode/opencode.service.ts @@ -2,7 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; // ponytail: hardcoded backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer const RETRY_DELAYS_MS = [2_000, 10_000, 30_000]; -// opencode accepts a session message immediately but holds the HTTP response until the agent turn finishes +// opencode v2 returns 2xx immediately (it no longer holds the response until the agent turn finishes); +// the 15 s timeout only guards a stuck connection — a read timeout is still treated as delivered const TIMEOUT_MS = 15_000; /** Entry of GET {api}/api/workspaces (only the fields we use). */ @@ -14,9 +15,11 @@ interface WorkspaceEntry { proxyPath?: string; } -/** Entry of GET {base}/session (only the fields we use). */ +/** Entry of GET {base}/api/session (v2; only the fields we use — directory lives under location, time is epoch ms). */ interface SessionEntry { id: string; + title?: string; + location?: { directory?: string }; time?: { updated?: number }; } @@ -43,11 +46,11 @@ export class OpencodeService { } /** - * Push an rx mesh message into the opencode session as a text part. Resolves when delivered — - * including on a final-POST read timeout, which means DELIVERED (opencode holds the response until - * the agent turn finishes) and is never retried. Workspace/session resolution failures are retried - * up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped - * after N attempts`. No-op when disabled (no credentials). + * Push an rx mesh message into the opencode session (v2: POST {base}/api/session/{id}/prompt with a + * `{text}` body). Resolves when delivered — including on a final-POST read timeout, which still means + * DELIVERED (kept as a harmless safety net; v2 normally returns 2xx immediately) and is never retried. + * Workspace/session resolution failures are retried up to RETRY_DELAYS_MS.length + 1 attempts total; + * the last failure logs `opencode notify dropped after N attempts`. No-op when disabled (no credentials). */ async notify(id: number, text: string, from?: number): Promise { if (!this.enabled) return; @@ -112,12 +115,19 @@ export class OpencodeService { const base = this.api + (ws.proxyPath ?? ''); let sid = this.sessionOverride; if (!sid) { - const sessions = await this.getJson(`${base}/session`); + // v2 discovery: GET {base}/api/session returns an envelope { data, cursor } — NOT a bare array + const envelope = await this.getJson<{ data?: SessionEntry[] }>( + `${base}/api/session`, + ); + // only sessions of THIS workspace directory — the instance proxy may serve other sessions too + const sessions = (envelope.data ?? []).filter( + (s) => s.location?.directory === this.workspacePath, + ); if (sessions.length === 0) throw new Error( `no sessions in workspace ${ws.id} (${this.workspacePath})`, ); - // most recently updated session; first entry when none carry time.updated + // most recently updated session (epoch ms); first entry when none carry time.updated const updated = (s: SessionEntry): number => s.time?.updated ?? 0; sid = sessions.some((s) => updated(s)) ? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id @@ -148,16 +158,17 @@ export class OpencodeService { msg: string, ): Promise { try { - const res = await fetch(`${base}/session/${sid}/message`, { + const res = await fetch(`${base}/api/session/${sid}/prompt`, { method: 'POST', headers: { ...this.authHeaders(), 'content-type': 'application/json' }, - body: JSON.stringify({ parts: [{ type: 'text', text: msg }] }), + body: JSON.stringify({ text: msg }), signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures } catch (err) { - // a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding - // the response until the agent turn finishes; never retried + // a read timeout here means DELIVERED, not failed — kept as a harmless safety net: v2 normally + // returns 2xx immediately, but if the response is still pending past the timeout we treat the + // message as accepted and never retry if ((err as Error).name === 'TimeoutError') { this.logger.log( `opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`, -- 2.54.0