viber-channel 0.8.39 → 0.8.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -190,7 +190,10 @@ export async function messageAgent(
190
190
  try {
191
191
  outcome = await openDm(ctx.baseUrl(), ctx.instanceToken(), trimmedPeerId);
192
192
  } catch (err) {
193
- return errorText(`message_agent failed: ${String(err)}`);
193
+ // #694: openDm already retried a network failure once. No message was posted.
194
+ return errorText(
195
+ `message_agent could not open the DM (network error, retried once; nothing was posted): ${String(err)}`,
196
+ );
194
197
  }
195
198
  if (!outcome.ok) {
196
199
  let friendly: string;
@@ -240,7 +243,8 @@ export async function messageAgent(
240
243
  return errorText(`message_agent post failed: ${String(err)}`);
241
244
  }
242
245
  if (!result.ok) {
243
- return errorText(result.detail);
246
+ // #694: name the step — the DM opened, the post is what failed.
247
+ return errorText(`message_agent: the DM is open, but the post failed: ${result.detail}`);
244
248
  }
245
249
  if (outcome.redirect) {
246
250
  // #692: say which agent was reached, and that the id used is stale.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * claude_runtime_publisher.ts — a claude agent's model, effort and context, left for
3
+ * the runner after each reply (#708 step-04).
4
+ *
5
+ * JP's rule (2026-10-17): the figures move on a round trip — a message in, the
6
+ * agent's answer out — and are pushed only if they changed. So nothing watches the
7
+ * session log: when the agent replies on the channel (`send_message`, or
8
+ * `message_agent` for an agent that only talks in DMs), the channel reads its own
9
+ * claude's log once and rewrites `<presenceDir>/<id>.runtime.json` if what the chip
10
+ * shows changed. The runner then pushes it, like a codex agent's file.
11
+ *
12
+ * The read waits `delayMs`: the assistant entry carrying the reply's tool call lands
13
+ * in the log within 200 ms of the call starting (measured, plan 708 step-01). Replies
14
+ * within that window are coalesced into one read.
15
+ */
16
+ import { readClaudeRuntime } from "./claude_session_log.js";
17
+ import { runtimeFilePath, writeRuntimeFile } from "./codex_runtime.js";
18
+ import { processStartTimeAsync } from "./process_start.js";
19
+ import { type RuntimeV1, sameRuntime } from "./runtime_info.js";
20
+
21
+ export const PUBLISH_DELAY_MS = 500;
22
+
23
+ export interface ClaudeRuntimePublisherOptions {
24
+ /** The claude process this channel serves (parent watchdog); null = none found. */
25
+ getClaudePid: () => number | null;
26
+ identityBaseUrl: string;
27
+ /** Read at publish time: the instance registers after the channel starts. */
28
+ getInstanceId: () => string;
29
+ lockDir?: string;
30
+ delayMs?: number;
31
+ probeStartTime?: (pid: number) => Promise<number | undefined>;
32
+ read?: (q: { pid: number; startTimeMs: number }) => RuntimeV1 | undefined;
33
+ write?: (path: string, instanceId: string, r: RuntimeV1) => void;
34
+ setTimer?: (fn: () => void, ms: number) => unknown;
35
+ log?: (line: string) => void;
36
+ }
37
+
38
+ export class ClaudeRuntimePublisher {
39
+ private startTime: number | undefined;
40
+ private startPid: number | null = null;
41
+ private pending = false;
42
+ private last: RuntimeV1 | undefined;
43
+ private lastInstance = "";
44
+
45
+ constructor(private readonly opts: ClaudeRuntimePublisherOptions) {}
46
+
47
+ /** The agent just replied on the channel. Never throws, never blocks the reply. */
48
+ afterReply(): void {
49
+ if (this.opts.getClaudePid() === null || this.pending) return;
50
+ this.pending = true;
51
+ const setTimer =
52
+ this.opts.setTimer ??
53
+ ((fn: () => void, ms: number) => {
54
+ const t = setTimeout(fn, ms);
55
+ (t as { unref?: () => void }).unref?.();
56
+ return t;
57
+ });
58
+ setTimer(() => {
59
+ this.pending = false;
60
+ void this.publish().catch((err) => this.log(`[runtime] publish failed: ${String(err)}\n`));
61
+ }, this.opts.delayMs ?? PUBLISH_DELAY_MS);
62
+ }
63
+
64
+ /** One read and, if what the chip shows changed, one write. Exposed for tests. */
65
+ async publish(): Promise<void> {
66
+ const pid = this.opts.getClaudePid();
67
+ const instanceId = this.opts.getInstanceId();
68
+ if (pid === null || instanceId === "") return;
69
+ if (this.startTime === undefined || this.startPid !== pid) {
70
+ this.startPid = pid;
71
+ this.startTime = await (this.opts.probeStartTime ?? ((p: number) => processStartTimeAsync(p)))(pid);
72
+ if (this.startTime === undefined) return; // retried on the next reply
73
+ }
74
+ const r = (this.opts.read ?? readClaudeRuntime)({ pid, startTimeMs: this.startTime });
75
+ // Unknown: keep the file as it is — a torn read is not a reason to clear the chip.
76
+ if (r === undefined) return;
77
+ if (instanceId === this.lastInstance && sameRuntime(this.last, r)) return;
78
+ (this.opts.write ?? writeRuntimeFile)(runtimeFilePath(this.opts.identityBaseUrl, instanceId, this.opts.lockDir), instanceId, r);
79
+ this.last = r;
80
+ this.lastInstance = instanceId;
81
+ }
82
+
83
+ private log(line: string): void {
84
+ (this.opts.log ?? ((l: string) => process.stderr.write(l)))(line);
85
+ }
86
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * claude_session_log.ts — read a Claude agent's model, effort and context from its
3
+ * session log (#708 step-02).
4
+ *
5
+ * Two files, both internal to Claude Code (undocumented, may change between
6
+ * versions — every field is optional here, and a miss yields "unknown", never 0):
7
+ *
8
+ * - `~/.claude/sessions/<pid>.json`: the live registry of a claude process. It
9
+ * names the CURRENT session (it follows `/clear` and resume), so it is re-read
10
+ * on every call, never cached. Its `procStart` (Windows FILETIME) equals the
11
+ * process start time measured by `process_start.ts` to the millisecond
12
+ * (measured, plan 708 step-01): a pid recycled by another process is refused.
13
+ * - `~/.claude/projects/<slug(cwd)>/<sessionId>.jsonl`: one entry per event. The
14
+ * last `assistant` entry carries `message.model`, `effort` and `message.usage`;
15
+ * occupation = input + cache_creation + cache_read of that LAST call (the status
16
+ * line's formula). It is written within 200 ms of a tool call starting.
17
+ *
18
+ * Known lag, by construction: after `/compact` or `/model`, the figures stay those of
19
+ * the last call until the next one is logged.
20
+ */
21
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, readdirSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { CLAUDE_CONTEXT_WINDOW, type RuntimeV1, buildRuntime, isEmptyRuntime } from "./runtime_info.js";
25
+
26
+ /** FILETIME epoch (1601-01-01) to Unix epoch, in ms. */
27
+ const FILETIME_UNIX_OFFSET_MS = 11_644_473_600_000;
28
+ /** `procStart` and the CIM probe agree to the ms today (measured); the slack absorbs a
29
+ * rounding change between versions — a recycled pid never starts within 5 ms. */
30
+ const START_TOLERANCE_MS = 5;
31
+ /** Tail windows, tried in order: an assistant entry carrying a big tool input can be large. */
32
+ export const TAIL_BYTES: readonly number[] = [256 * 1024, 4 * 1024 * 1024];
33
+
34
+ export interface LiveSession {
35
+ sessionId: string;
36
+ cwd: string;
37
+ }
38
+
39
+ /** The `/resume` bucket of a working directory: every non-alphanumeric char → '-'. */
40
+ export function projectSlug(cwd: string): string {
41
+ return cwd.replace(/[^A-Za-z0-9]/g, "-");
42
+ }
43
+
44
+ /** FILETIME string (100 ns ticks since 1601) → Unix ms, or undefined. */
45
+ export function fileTimeToMs(raw: unknown): number | undefined {
46
+ if (typeof raw !== "string" || !/^\d{1,20}$/.test(raw)) return undefined;
47
+ // Rounded like the CIM probe (`[int64]` of a fractional ms rounds), measured equal.
48
+ const ms = Number((BigInt(raw) + 5_000n) / 10_000n) - FILETIME_UNIX_OFFSET_MS;
49
+ return ms > 0 ? ms : undefined;
50
+ }
51
+
52
+ /**
53
+ * The live session of claude process `pid`, or undefined when the registry is
54
+ * missing, unreadable, or belongs to another process (pid recycled).
55
+ */
56
+ export function readLiveSession(home: string, pid: number, startTimeMs: number): LiveSession | undefined {
57
+ let d: Record<string, unknown>;
58
+ try {
59
+ d = JSON.parse(readFileSync(join(home, ".claude", "sessions", `${pid}.json`), "utf8"));
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ if (typeof d !== "object" || d === null || d.pid !== pid) return undefined;
64
+ const started = fileTimeToMs(d.procStart);
65
+ if (started === undefined || Math.abs(started - startTimeMs) > START_TOLERANCE_MS) return undefined;
66
+ if (typeof d.sessionId !== "string" || !/^[0-9a-f-]{36}$/i.test(d.sessionId)) return undefined;
67
+ if (typeof d.cwd !== "string" || d.cwd === "") return undefined;
68
+ return { sessionId: d.sessionId, cwd: d.cwd };
69
+ }
70
+
71
+ /** The session log path: the cwd's bucket first, then any bucket holding that id. */
72
+ export function resolveSessionLog(home: string, live: LiveSession): string | undefined {
73
+ const projects = join(home, ".claude", "projects");
74
+ const file = `${live.sessionId}.jsonl`;
75
+ const direct = join(projects, projectSlug(live.cwd), file);
76
+ if (existsSync(direct)) return direct;
77
+ try {
78
+ for (const bucket of readdirSync(projects)) {
79
+ const p = join(projects, bucket, file);
80
+ if (existsSync(p)) return p;
81
+ }
82
+ } catch {
83
+ // no projects directory: unknown
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function readTail(path: string, bytes: number): { text: string; whole: boolean } | undefined {
89
+ let fd: number | undefined;
90
+ try {
91
+ fd = openSync(path, "r");
92
+ const size = fstatSync(fd).size;
93
+ const len = Math.min(size, bytes);
94
+ const buf = Buffer.alloc(len);
95
+ readSync(fd, buf, 0, len, size - len);
96
+ return { text: buf.toString("utf8"), whole: len === size };
97
+ } catch {
98
+ return undefined;
99
+ } finally {
100
+ if (fd !== undefined) closeSync(fd);
101
+ }
102
+ }
103
+
104
+ export interface LastTurn {
105
+ model?: string;
106
+ effort?: string;
107
+ contextTokens?: number;
108
+ }
109
+
110
+ const count = (n: unknown): number | undefined =>
111
+ typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : undefined;
112
+
113
+ /** The last real main-thread assistant call in a chunk of log, or undefined. */
114
+ export function lastTurnOf(text: string, whole: boolean): LastTurn | undefined {
115
+ const lines = text.split("\n");
116
+ // A tail cut mid-line: the first line is a fragment. The last may be a write in progress.
117
+ const start = whole ? 0 : 1;
118
+ for (let i = lines.length - 1; i >= start; i--) {
119
+ const line = lines[i]?.trim();
120
+ if (!line || !line.includes('"assistant"')) continue;
121
+ let e: Record<string, unknown>;
122
+ try {
123
+ e = JSON.parse(line);
124
+ } catch {
125
+ continue;
126
+ }
127
+ if (e.type !== "assistant" || e.isSidechain === true) continue;
128
+ const m = e.message as Record<string, unknown> | undefined;
129
+ // An API error is logged as a "<synthetic>" assistant entry with zero usage: not a call.
130
+ if (!m || m.model === "<synthetic>") continue;
131
+ const u = m.usage as Record<string, unknown> | undefined;
132
+ const parts = u ? [count(u.input_tokens), count(u.cache_creation_input_tokens), count(u.cache_read_input_tokens)] : [];
133
+ return {
134
+ model: typeof m.model === "string" ? m.model : undefined,
135
+ effort: typeof e.effort === "string" ? e.effort : undefined,
136
+ contextTokens: parts.length === 3 && parts.every((p) => p !== undefined)
137
+ ? (parts as number[]).reduce((a, b) => a + b, 0)
138
+ : undefined,
139
+ };
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ /** The last turn of a session log, widening the tail once if the first window holds none. */
145
+ export function readLastTurn(path: string, tails: readonly number[] = TAIL_BYTES): LastTurn | undefined {
146
+ for (const bytes of tails) {
147
+ const tail = readTail(path, bytes);
148
+ if (!tail) return undefined;
149
+ const turn = lastTurnOf(tail.text, tail.whole);
150
+ if (turn || tail.whole) return turn;
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ export interface ClaudeRuntimeQuery {
156
+ pid: number;
157
+ startTimeMs: number;
158
+ home?: string;
159
+ now?: () => number;
160
+ }
161
+
162
+ /** The runtime of a claude agent, or undefined when nothing about it is known. */
163
+ export function readClaudeRuntime(q: ClaudeRuntimeQuery): RuntimeV1 | undefined {
164
+ const home = q.home ?? homedir();
165
+ const live = readLiveSession(home, q.pid, q.startTimeMs);
166
+ if (!live) return undefined;
167
+ const log = resolveSessionLog(home, live);
168
+ if (!log) return undefined;
169
+ const turn = readLastTurn(log);
170
+ return turn ? runtimeOfTurn(turn, (q.now ?? Date.now)()) : undefined;
171
+ }
172
+
173
+ /** A claude turn as a runtime, on the fixed 1 M window; undefined when nothing is known. */
174
+ export function runtimeOfTurn(turn: LastTurn, observedAt: number): RuntimeV1 | undefined {
175
+ const r = buildRuntime(
176
+ {
177
+ model: turn.model,
178
+ effort: turn.effort,
179
+ contextTokens: turn.contextTokens,
180
+ // A window alone says nothing about this agent.
181
+ contextWindow: turn.contextTokens === undefined ? undefined : CLAUDE_CONTEXT_WINDOW,
182
+ },
183
+ observedAt,
184
+ );
185
+ return isEmptyRuntime(r) ? undefined : r;
186
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * codex_runtime.ts — model, effort and context of a codex agent, left for the
3
+ * runner in `<presenceDir>/<instance_id>.runtime.json` (#708 step-03).
4
+ *
5
+ * Sources, measured on codex-cli 0.163.0 (plan 708 step-01):
6
+ * - `thread/start` / `thread/resume` answer the EFFECTIVE `model` and
7
+ * `reasoningEffort` (null = not reported) — the template's pair is only what was
8
+ * asked;
9
+ * - `thread/tokenUsage/updated` carries `tokenUsage.last.totalTokens` (occupation of
10
+ * the last call, right after a compaction too) and `tokenUsage.modelContextWindow`.
11
+ * `total` is cumulative over the thread's life and `last.inputTokens` drops to a
12
+ * false 0 right after a compaction: neither is read.
13
+ *
14
+ * The file is rewritten only when a business field changes (`observed_at` excluded),
15
+ * atomically, so the runner never reads half of it.
16
+ */
17
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { isInstanceId, presenceDir } from "./presence_record.js";
20
+ import { type RuntimeV1, buildRuntime, isEmptyRuntime, parseRuntime, sameRuntime } from "./runtime_info.js";
21
+
22
+ /** A runtime file name — never a presence record (`<id>.json`) nor a temp file. */
23
+ export const RUNTIME_FILE_RE = /^[0-9a-f]{32}\.runtime\.json$/;
24
+
25
+ export function runtimeFilePath(identityBaseUrl: string, instanceId: string, lockDir?: string): string {
26
+ if (!isInstanceId(instanceId)) {
27
+ throw new Error(`runtimeFilePath: not an instance id: ${JSON.stringify(instanceId)}`);
28
+ }
29
+ return join(presenceDir(identityBaseUrl, lockDir), `${instanceId.toLowerCase()}.runtime.json`);
30
+ }
31
+
32
+ export interface RuntimeFile extends RuntimeV1 {
33
+ instance_id: string;
34
+ }
35
+
36
+ /** Atomic write: a reader sees the old file or the new one, never half of one. */
37
+ export function writeRuntimeFile(path: string, instanceId: string, runtime: RuntimeV1): void {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ const tmp = `${path}.${process.pid}.tmp`;
40
+ const body: RuntimeFile = { ...runtime, instance_id: instanceId.toLowerCase() };
41
+ writeFileSync(tmp, JSON.stringify(body), "utf8");
42
+ try {
43
+ renameSync(tmp, path);
44
+ } catch (err) {
45
+ try {
46
+ unlinkSync(tmp);
47
+ } catch {
48
+ // the tmp may already be gone; the rename error is the one that matters
49
+ }
50
+ throw err;
51
+ }
52
+ }
53
+
54
+ /** The runtime in a file, or undefined when absent, torn or for another instance. */
55
+ export function readRuntimeFile(path: string, instanceId: string): RuntimeV1 | undefined {
56
+ let parsed: unknown;
57
+ try {
58
+ parsed = JSON.parse(readFileSync(path, "utf8"));
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ if (typeof parsed !== "object" || parsed === null) return undefined;
63
+ const { instance_id, ...rest } = parsed as Record<string, unknown>;
64
+ if (instance_id !== instanceId.toLowerCase()) return undefined;
65
+ return parseRuntime(rest);
66
+ }
67
+
68
+ const count = (n: unknown): number | undefined =>
69
+ typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : undefined;
70
+
71
+ /** Collects what codex says and writes the file when it changes. */
72
+ export class CodexRuntimeWriter {
73
+ private model?: string;
74
+ private effort?: string;
75
+ private contextTokens?: number;
76
+ private contextWindow?: number;
77
+ private last: RuntimeV1 | undefined;
78
+
79
+ constructor(
80
+ private readonly path: string,
81
+ private readonly instanceId: string,
82
+ private readonly now: () => number = Date.now,
83
+ private readonly write: (path: string, id: string, r: RuntimeV1) => void = writeRuntimeFile,
84
+ private readonly log: (line: string) => void = (l) => process.stderr.write(l),
85
+ ) {}
86
+
87
+ /** The answer to `thread/start` or `thread/resume`. */
88
+ onThread(result: unknown): void {
89
+ const r = (typeof result === "object" && result !== null ? result : {}) as Record<string, unknown>;
90
+ if (typeof r.model === "string") this.model = r.model;
91
+ // null = "not reported": keep what we knew rather than forget it.
92
+ if (typeof r.reasoningEffort === "string") this.effort = r.reasoningEffort;
93
+ this.flush();
94
+ }
95
+
96
+ /** The params of `thread/tokenUsage/updated`. */
97
+ onTokenUsage(params: unknown): void {
98
+ const usage = (params as { tokenUsage?: { last?: { totalTokens?: unknown }; modelContextWindow?: unknown } } | null)
99
+ ?.tokenUsage;
100
+ const tokens = count(usage?.last?.totalTokens);
101
+ const window = count(usage?.modelContextWindow);
102
+ // Both terms or neither: a percent from a stale numerator and a fresh window is a guess.
103
+ if (tokens === undefined || window === undefined || window === 0) return;
104
+ this.contextTokens = tokens;
105
+ this.contextWindow = window;
106
+ this.flush();
107
+ }
108
+
109
+ private flush(): void {
110
+ const next = buildRuntime(
111
+ { model: this.model, effort: this.effort, contextTokens: this.contextTokens, contextWindow: this.contextWindow },
112
+ this.now(),
113
+ );
114
+ if (isEmptyRuntime(next) || sameRuntime(this.last, next)) return;
115
+ try {
116
+ this.write(this.path, this.instanceId, next);
117
+ this.last = next;
118
+ } catch (err) {
119
+ // The file serves the web only: an agent never dies of it. Retried on the next change.
120
+ this.log(`[codex-runtime] write failed (${this.path}): ${String(err)}\n`);
121
+ }
122
+ }
123
+ }
package/lib/messages.ts CHANGED
@@ -136,13 +136,144 @@ export interface MessagePostError {
136
136
  ok: false;
137
137
  status: number;
138
138
  detail: string;
139
- retriable: boolean;
140
139
  }
141
140
 
142
141
  export type MessagePostResult = MessagePostSuccess | MessagePostError;
143
142
 
144
143
  /**
145
- * POST /api/conversations/:conversationId/messages
144
+ * `detail` of viber-api's GLOBAL notFound (`viber-api/src/index.ts`): what a server
145
+ * that predates `POST …/messages/idempotent` answers on that route (#694). The
146
+ * ONLY cue that allows falling back to the legacy route. Tied to the server's text
147
+ * by a cross test (test/messages.test.ts), so a change there breaks the build here.
148
+ */
149
+ export const SERVER_NOT_FOUND_DETAIL = "Not found";
150
+
151
+ /** Statuses an edge or a tunnel answers for a request that may never have run: resent once (#694). */
152
+ const RESEND_STATUSES: ReadonlySet<number> = new Set([502, 503, 504]);
153
+
154
+ /**
155
+ * Pause before the single resend (#694). The failed attempt reused a keep-alive
156
+ * socket a Cloudflare datacenter switch had killed; Bun drops it from its pool, so
157
+ * the resend opens a fresh connection.
158
+ */
159
+ export const RESEND_DELAY_MS = 250;
160
+
161
+ /** Test seams of postMessage; production never passes them. */
162
+ export interface PostMessageOptions {
163
+ /** Pause before the resend. Default RESEND_DELAY_MS. */
164
+ resendDelayMs?: number;
165
+ /** Client message id generator. Default crypto.randomUUID. */
166
+ newClientMessageId?: () => string;
167
+ }
168
+
169
+ /** One HTTP attempt: a response whose body was read IN FULL, or a network failure. */
170
+ type Attempt =
171
+ | { kind: "response"; status: number; statusText: string; body: string }
172
+ | { kind: "network"; error: unknown };
173
+
174
+ async function attemptPost(
175
+ fetchImpl: typeof fetch,
176
+ url: string,
177
+ conversationToken: string,
178
+ payload: Record<string, unknown>,
179
+ signal: AbortSignal | undefined,
180
+ ): Promise<Attempt> {
181
+ try {
182
+ const resp = await fetchImpl(url, {
183
+ method: "POST",
184
+ headers: {
185
+ Authorization: `Bearer ${conversationToken}`,
186
+ "Content-Type": "application/json",
187
+ ...cfAccessHeaders(),
188
+ },
189
+ body: JSON.stringify(payload),
190
+ signal,
191
+ });
192
+ // Read the body here: a socket reset can also land mid-body, and a 404 whose body
193
+ // was lost must never be mistaken for the global notFound (#694).
194
+ const body = await resp.text();
195
+ return { kind: "response", status: resp.status, statusText: resp.statusText, body };
196
+ } catch (error) {
197
+ return { kind: "network", error };
198
+ }
199
+ }
200
+
201
+ /**
202
+ * True for the global notFound body BYTE FOR BYTE — `{"detail":"Not found"}`, as
203
+ * Hono's `c.json` serializes it — and nothing else: not a reformatted variant, not
204
+ * a different detail, not a body cut by a reset (Codex review, step-02).
205
+ */
206
+ function isServerNotFound(attempt: Attempt): boolean {
207
+ return (
208
+ attempt.kind === "response" &&
209
+ attempt.status === 404 &&
210
+ attempt.body === JSON.stringify({ detail: SERVER_NOT_FOUND_DETAIL })
211
+ );
212
+ }
213
+
214
+ /** Worth one resend with the same client id: a network failure (not an abort) or 502/503/504. */
215
+ function isResendable(attempt: Attempt, signal: AbortSignal | undefined): boolean {
216
+ if (signal?.aborted) return false;
217
+ return attempt.kind === "network" || RESEND_STATUSES.has(attempt.status);
218
+ }
219
+
220
+ /**
221
+ * Map one attempt to the result the tools see.
222
+ * @throws ConversationTokenExpiredError on HTTP 401
223
+ */
224
+ function toResult(attempt: Attempt, resent: boolean): MessagePostResult {
225
+ // After a resend the first attempt may have been written: say so, never "lost".
226
+ const resendNote = resent
227
+ ? " Resent once with the same client_message_id: delivery is NOT confirmed, it may already be delivered." +
228
+ " Do not resend unless the user asks: a resend is a new message and could appear twice."
229
+ : "";
230
+ if (attempt.kind === "network") {
231
+ return { ok: false, status: 0, detail: `Network error: ${String(attempt.error)}.${resendNote}` };
232
+ }
233
+ const { status, statusText, body } = attempt;
234
+ if (status === 401) throw new ConversationTokenExpiredError();
235
+ if (status >= 500) {
236
+ return {
237
+ ok: false,
238
+ status,
239
+ detail: `Server error (HTTP ${status}): ${body || statusText}.${resendNote || " Retry the call."}`,
240
+ };
241
+ }
242
+ if (status < 200 || status >= 300) {
243
+ let detail = `HTTP ${status}`;
244
+ try {
245
+ const json = JSON.parse(body) as { detail?: unknown };
246
+ if (typeof json.detail === "string") detail = `HTTP ${status}: ${json.detail}`;
247
+ } catch {
248
+ if (body) detail = `HTTP ${status}: ${body}`;
249
+ }
250
+ return { ok: false, status, detail: `${detail}${resendNote}` };
251
+ }
252
+ // 200 or 201 — the saved message (a 200 on the idempotent route is the one an
253
+ // earlier attempt wrote).
254
+ try {
255
+ const message = JSON.parse(body) as { id: string };
256
+ return { ok: true, message_id: message.id };
257
+ } catch (err) {
258
+ return { ok: false, status, detail: `Failed to parse response JSON: ${String(err)}` };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * POST a message, at most once (#694).
264
+ *
265
+ * A Cloudflare datacenter switch kills idle keep-alive sockets; the next POST on
266
+ * one fails in ~20 ms with ECONNRESET, and Bun never resends a POST itself. So:
267
+ * 1. one client message id per logical send, sent to `…/messages/idempotent`;
268
+ * 2. a network failure (not an abort) or 502/503/504 → ONE resend, same id, after
269
+ * RESEND_DELAY_MS — the server answers the row the first attempt wrote, if any;
270
+ * 3. a server that predates the idempotent route answers its global notFound:
271
+ * ONLY on the FIRST attempt, and only on that exact, fully read body, the send
272
+ * falls back to the legacy `…/messages`, without resend (today's behaviour).
273
+ * After an ambiguous first attempt a notFound means a rollback between the two
274
+ * attempts: the first may have been written, so the legacy route would
275
+ * duplicate it — that is an error, never a fallback.
276
+ * No timer, no periodic request: a request is added only when a send fails.
146
277
  *
147
278
  * @throws ConversationTokenExpiredError on HTTP 401
148
279
  * @returns MessagePostResult — success with message_id, or error with detail
@@ -157,7 +288,7 @@ export async function postMessage(
157
288
  * Optional abort signal (#303 step-12.03): the codex bridge passes its stream's
158
289
  * signal so a reply in flight is cancelled atomically if the stream is aborted
159
290
  * (re-join / revoke / shutdown) — closing the check-then-post window that a bare
160
- * `signal.aborted` guard before the call cannot.
291
+ * `signal.aborted` guard before the call cannot. An aborted send is never resent.
161
292
  */
162
293
  signal?: AbortSignal,
163
294
  /**
@@ -167,85 +298,26 @@ export async function postMessage(
167
298
  * that cannot see the notice cannot prove the fix that exists to emit it.
168
299
  */
169
300
  fetchImpl: typeof fetch = fetch,
301
+ options: PostMessageOptions = {},
170
302
  ): Promise<MessagePostResult> {
171
303
  if (!text || text.trim().length === 0) {
172
- return {
173
- ok: false,
174
- status: 0,
175
- detail: "text must be a non-empty string",
176
- retriable: false,
177
- };
178
- }
179
-
180
- const url = `${baseUrl}/api/conversations/${conversationId}/messages`;
181
- let resp: Response;
182
- try {
183
- resp = await fetchImpl(url, {
184
- method: "POST",
185
- headers: {
186
- Authorization: `Bearer ${conversationToken}`,
187
- "Content-Type": "application/json",
188
- ...cfAccessHeaders(),
189
- },
190
- body: JSON.stringify(artifact ? { content: text, artifact } : { content: text }),
191
- signal,
192
- });
193
- } catch (err) {
194
- return {
195
- ok: false,
196
- status: 0,
197
- detail: `Network error: ${String(err)}`,
198
- retriable: true,
199
- };
200
- }
201
-
202
- if (resp.status === 401) {
203
- throw new ConversationTokenExpiredError();
304
+ return { ok: false, status: 0, detail: "text must be a non-empty string" };
204
305
  }
205
306
 
206
- if (resp.status >= 500) {
207
- let body = "";
208
- try {
209
- body = await resp.text();
210
- } catch {
211
- // ignore
212
- }
213
- return {
214
- ok: false,
215
- status: resp.status,
216
- detail: `Server error (HTTP ${resp.status}): ${body || resp.statusText}. Retry the call.`,
217
- retriable: true,
218
- };
219
- }
307
+ const messagesUrl = `${baseUrl}/api/conversations/${conversationId}/messages`;
308
+ const content: Record<string, unknown> = artifact ? { content: text, artifact } : { content: text };
309
+ const clientMessageId = (options.newClientMessageId ?? (() => crypto.randomUUID()))();
310
+ const idempotent = { ...content, client_message_id: clientMessageId };
220
311
 
221
- if (!resp.ok) {
222
- let detail = `HTTP ${resp.status}`;
223
- try {
224
- const json = (await resp.json()) as { detail?: string };
225
- if (typeof json.detail === "string") detail = `HTTP ${resp.status}: ${json.detail}`;
226
- } catch {
227
- try {
228
- const body = await resp.text();
229
- if (body) detail = `HTTP ${resp.status}: ${body}`;
230
- } catch {
231
- // ignore
232
- }
233
- }
234
- return { ok: false, status: resp.status, detail, retriable: false };
235
- }
236
-
237
- // 200 or 201 — parse the saved message
238
- let message: { id: string };
239
- try {
240
- message = (await resp.json()) as { id: string };
241
- } catch (err) {
242
- return {
243
- ok: false,
244
- status: resp.status,
245
- detail: `Failed to parse response JSON: ${String(err)}`,
246
- retriable: false,
247
- };
312
+ const first = await attemptPost(fetchImpl, `${messagesUrl}/idempotent`, conversationToken, idempotent, signal);
313
+ if (isServerNotFound(first)) {
314
+ const legacy = await attemptPost(fetchImpl, messagesUrl, conversationToken, content, signal);
315
+ return toResult(legacy, false);
248
316
  }
317
+ if (!isResendable(first, signal)) return toResult(first, false);
249
318
 
250
- return { ok: true, message_id: message.id };
319
+ await new Promise((resolve) => setTimeout(resolve, options.resendDelayMs ?? RESEND_DELAY_MS));
320
+ if (signal?.aborted) return toResult(first, false);
321
+ const second = await attemptPost(fetchImpl, `${messagesUrl}/idempotent`, conversationToken, idempotent, signal);
322
+ return toResult(second, true);
251
323
  }