Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84915fe314 | ||
|
|
5506fb9330 | ||
|
|
beebcd5851 | ||
|
|
5a1493fb4a | ||
|
|
8fb16217f7 | ||
|
|
3b1f5de101 |
@@ -1,5 +1,18 @@
|
||||
# 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`.
|
||||
|
||||
## v0.21.9 — 2026-09-29 20:56
|
||||
|
||||
- New `#loser` command (owner request): replies with which user/node has the worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide loss stats, so it groups our own tx history by destination and counts delivered vs failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and the broadcast addr is never a real destination even if a NACK lands on it). Loser = most failures, tie-break worse failure ratio. Node resolved to `longName`/`shortName`, falling back to the `@!xxxxxxxx` mention when unknown. Three reply shapes: worst delivery line (`worst delivery: <name> (@!hex) - N failed / M delivered`), a "no failed sends yet" line, and a "not enough data yet — only directed sends count (broadcasts have no acks)" line. Registered in the CommandService constructor next to `#ping`; listed in `#help`.
|
||||
- Tests: 175 → 180 — new `#loser` specs cover all three reply cases, broadcast/pending exclusion, the unknown-node `@!hex` fallback, and the failure-ratio tie-break; the `#help` spec now also asserts `#loser` is listed.
|
||||
|
||||
## v0.21.8 — 2026-09-27 07:13
|
||||
|
||||
- Fixed `npm test` exiting 1 with "Errors 2" even though all tests pass (every CI run red since v0.21.2): the two watchdog specs that force a reconnect destroy the service's socket while data is still pending in its receive buffer (the overflow spec floods 300 KB and destroys on the first ~64 KB chunk; the garbage spec destroys right after its last write), so the kernel sends RST instead of FIN and the fake bridge's accepted server-side sockets emit `read ECONNRESET`. Those test-owned sockets had no `'error'` handler, so Node threw it as an uncaught exception that vitest counts as a run-level failure. `startBridge()` now attaches a no-op `'error'` handler to every accepted socket (single registration point, covers all four spec files using the fake bridge). Test-only change — `fake-bridge.ts` is imported by specs only; no runtime diff, no deploy needed.
|
||||
|
||||
@@ -2,6 +2,7 @@ services:
|
||||
bot:
|
||||
build: .
|
||||
environment:
|
||||
TZ: Europe/Warsaw
|
||||
MESH_HOST: 192.168.1.4
|
||||
MESH_PORT: '4403'
|
||||
MESH_DB_PATH: ./data/mesh-bot.db
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mesh-bot",
|
||||
"version": "0.21.8",
|
||||
"version": "0.21.11",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
KAMIL_NUM,
|
||||
} from '../mesh/fake-bridge.js';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
import type {
|
||||
MessageRecord,
|
||||
MeshService,
|
||||
NodeSummary,
|
||||
} from '../mesh/mesh.service.js';
|
||||
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
|
||||
import { CommandService } from './command.service.js';
|
||||
|
||||
@@ -47,6 +51,7 @@ describe('CommandService.onRx', () => {
|
||||
expect(mesh.respond).toHaveBeenCalledTimes(1); // one packet — not one per line
|
||||
const text = mesh.respond.mock.calls[0][1];
|
||||
expect(text).toContain('#ping');
|
||||
expect(text).toContain('#loser');
|
||||
expect(text).toContain('#rss');
|
||||
});
|
||||
|
||||
@@ -205,6 +210,125 @@ describe('CommandService.onRx', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#loser', () => {
|
||||
const tx = (
|
||||
id: number,
|
||||
to: number,
|
||||
status: MessageRecord['status'],
|
||||
): MessageRecord => ({
|
||||
id,
|
||||
dir: 'tx',
|
||||
to,
|
||||
text: 'hi',
|
||||
channel: 3,
|
||||
at: Date.now(),
|
||||
status,
|
||||
});
|
||||
|
||||
const makeLoserMesh = (
|
||||
messages: MessageRecord[],
|
||||
nodes: NodeSummary[],
|
||||
): [CommandService, MeshService] => {
|
||||
const mesh = {
|
||||
sendText: vi.fn(),
|
||||
respond: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
messages: () => messages,
|
||||
nodes: async () => nodes,
|
||||
} as unknown as MeshService;
|
||||
return [new CommandService(mesh), mesh];
|
||||
};
|
||||
|
||||
it('reports the node with the most failures — named via longName, broadcasts and pending sends excluded', async () => {
|
||||
const messages = [
|
||||
tx(10, BROADCAST_ADDR, 'failed'), // firmware quirk: a broadcast NACK is never a real destination
|
||||
tx(9, 222, 'failed'), // Alice: 3 failed / 0 delivered → loser
|
||||
tx(8, 111, 'delivered'), // Bob: 1 failed / 2 delivered
|
||||
tx(7, 222, 'failed'),
|
||||
tx(6, 111, 'failed'),
|
||||
tx(5, 333, 'pending'), // no final status yet — ignored
|
||||
tx(4, 222, 'failed'),
|
||||
{
|
||||
id: 3,
|
||||
dir: 'rx',
|
||||
from: 999,
|
||||
text: 'hello',
|
||||
channel: 0,
|
||||
at: Date.now(),
|
||||
},
|
||||
];
|
||||
const nodes = [
|
||||
{ num: 111, shortName: 'Bob', lastHeard: null },
|
||||
{ num: 222, longName: 'Alice', lastHeard: null },
|
||||
];
|
||||
const [svc, mesh] = makeLoserMesh(messages, nodes);
|
||||
|
||||
await svc.onRx(2, '#loser', 42, 7);
|
||||
|
||||
expect(mesh.respond).toHaveBeenCalledWith(
|
||||
7,
|
||||
'worst delivery: Alice (@!000000de) - 3 failed / 0 delivered',
|
||||
);
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says no failed sends when the directed history is all delivered', async () => {
|
||||
const messages = [tx(2, 5, 'delivered'), tx(1, 6, 'delivered')];
|
||||
const [svc, mesh] = makeLoserMesh(messages, []);
|
||||
|
||||
await svc.onRx(2, '#loser', 42, 7);
|
||||
|
||||
expect(mesh.respond).toHaveBeenCalledWith(
|
||||
7,
|
||||
'no failed sends yet - everyone delivers :)',
|
||||
);
|
||||
});
|
||||
|
||||
it('explains the directed-only rule when there is no final-status data at all', async () => {
|
||||
const messages = [tx(2, BROADCAST_ADDR, 'pending'), tx(1, 5, 'pending')];
|
||||
const [svc, mesh] = makeLoserMesh(messages, []);
|
||||
|
||||
await svc.onRx(2, '#loser', 42, 7);
|
||||
|
||||
expect(mesh.respond).toHaveBeenCalledWith(
|
||||
7,
|
||||
'not enough data yet - only directed sends count (broadcasts have no acks)',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the @!hex mention when the worst node has no known name', async () => {
|
||||
const messages = [tx(1, 600, 'failed')];
|
||||
const nodes = [{ num: 5, longName: 'Someone Else', lastHeard: null }];
|
||||
const [svc, mesh] = makeLoserMesh(messages, nodes);
|
||||
|
||||
await svc.onRx(2, '#loser', 42, 7);
|
||||
|
||||
expect(mesh.respond).toHaveBeenCalledWith(
|
||||
7,
|
||||
'worst delivery: @!00000258 - 1 failed / 0 delivered',
|
||||
);
|
||||
});
|
||||
|
||||
it('tie-breaks equal failure counts by the worse failure ratio', async () => {
|
||||
const messages = [
|
||||
// node 7: 1 failed / 9 delivered (ratio 0.1) — not the loser
|
||||
tx(16, 8, 'failed'), // node 8: 1 failed / 1 delivered (ratio 0.5) → loser
|
||||
tx(15, 8, 'delivered'),
|
||||
tx(14, 7, 'failed'),
|
||||
...Array.from({ length: 9 }, (_, i) =>
|
||||
tx(i + 1, 7, 'delivered' as const),
|
||||
),
|
||||
];
|
||||
const [svc, mesh] = makeLoserMesh(messages, []);
|
||||
|
||||
await svc.onRx(2, '#loser', 42, 7);
|
||||
|
||||
expect(mesh.respond).toHaveBeenCalledWith(
|
||||
7,
|
||||
'worst delivery: @!00000008 - 1 failed / 1 delivered',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#rss prefix dispatch', () => {
|
||||
let dir: string | undefined;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
// pure formatting helper (no cycle: battery → mesh only)
|
||||
import { mention } from '../battery/battery.service.js';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
// type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
@@ -43,10 +45,12 @@ export class CommandService {
|
||||
rss?: RssService,
|
||||
) {
|
||||
this.register('ping', () => '#pong!');
|
||||
this.register('loser', () => this.loserReply());
|
||||
// single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line
|
||||
this.register('help', () =>
|
||||
[
|
||||
'#ping - alive check',
|
||||
'#loser - worst delivery record',
|
||||
'#rss help - rss subcommands',
|
||||
'#rss add <url> <name> - add feed with AI tags',
|
||||
'#rss remove <name> - drop feed',
|
||||
@@ -57,6 +61,52 @@ export class CommandService {
|
||||
if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* #loser — worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide
|
||||
* loss stats, so the only real data is our own tx history: group by destination, count delivered vs
|
||||
* failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and
|
||||
* the broadcast addr is never a real destination even if a NACK ever lands on it). Loser = most
|
||||
* failures, tie-break worse failure ratio. One reply line, ≤ ~180 chars.
|
||||
*/
|
||||
private async loserReply(): Promise<string> {
|
||||
const mesh = this.mesh;
|
||||
if (!mesh)
|
||||
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
|
||||
const stats = new Map<number, { failed: number; delivered: number }>();
|
||||
for (const m of mesh.messages()) {
|
||||
if (m.dir !== 'tx' || m.to === undefined || m.to === BROADCAST_ADDR)
|
||||
continue; // directed sends only
|
||||
if (m.status !== 'delivered' && m.status !== 'failed') continue; // pending = no final status yet
|
||||
const s = stats.get(m.to) ?? { failed: 0, delivered: 0 };
|
||||
if (m.status === 'failed') s.failed += 1;
|
||||
else s.delivered += 1;
|
||||
stats.set(m.to, s);
|
||||
}
|
||||
if (stats.size === 0)
|
||||
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
|
||||
let worst: { num: number; failed: number; total: number } | undefined;
|
||||
for (const [num, s] of stats) {
|
||||
if (s.failed === 0) continue; // zero-failure nodes can't be the loser
|
||||
const total = s.failed + s.delivered;
|
||||
// most failures first, then worse failure ratio on a tie
|
||||
if (
|
||||
!worst ||
|
||||
s.failed > worst.failed ||
|
||||
(s.failed === worst.failed &&
|
||||
s.failed / total > worst.failed / worst.total)
|
||||
) {
|
||||
worst = { num, failed: s.failed, total };
|
||||
}
|
||||
}
|
||||
if (!worst) return 'no failed sends yet - everyone delivers :)';
|
||||
const names = new Map(
|
||||
(await mesh.nodes()).map((n) => [n.num, n.longName ?? n.shortName]),
|
||||
);
|
||||
const name = names.get(worst.num);
|
||||
const who = name ? `${name} (${mention(worst.num)})` : mention(worst.num);
|
||||
return `worst delivery: ${who} - ${worst.failed} failed / ${worst.total - worst.failed} delivered`;
|
||||
}
|
||||
|
||||
/** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */
|
||||
register(name: string, fn: CommandHandler): void {
|
||||
this.commands.set(name, fn);
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> {
|
||||
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<SessionEntry[]>(`${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<void> {
|
||||
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)`,
|
||||
|
||||
Reference in New Issue
Block a user