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.
@@ -164,6 +164,8 @@ export interface ParentWatchdogOptions {
164
164
 
165
165
  export interface ParentWatchdogHandle {
166
166
  stop: () => void;
167
+ /** The agent ancestor resolved at startup (#708: the runner reads its session log). */
168
+ agent: AgentIdentity | null;
167
169
  }
168
170
 
169
171
  const DEFAULT_INTERVAL_MS = 5000;
@@ -233,7 +235,7 @@ export function startParentWatchdog(
233
235
 
234
236
  if (!identity) {
235
237
  log("[viber-channel] parent watchdog disabled (no resolvable agent ancestor)\n");
236
- return { stop: () => {} };
238
+ return { stop: () => {}, agent: null };
237
239
  }
238
240
 
239
241
  const watched = identity;
@@ -272,5 +274,6 @@ export function startParentWatchdog(
272
274
  stopped = true;
273
275
  clearTimer(handle);
274
276
  },
277
+ agent: watched,
275
278
  };
276
279
  }
package/lib/peers.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { cfAccessHeaders } from "./cfAccess.js";
13
+ import { RESEND_DELAY_MS } from "./messages.js";
13
14
 
14
15
  /** A peer agent as returned by GET /api/instances/peers.
15
16
  * project_id/project_name are present only in the user-scoped listing (#307):
@@ -175,21 +176,37 @@ function instanceIdOrNull(v: unknown): string | null {
175
176
  * Returns a structured outcome rather than throwing on the expected 4xx cases
176
177
  * (peer offline / not found) so the tool can surface a clear message to the
177
178
  * model; network/parse failures throw.
179
+ *
180
+ * #694: a network failure (a keep-alive socket killed by a Cloudflare datacenter
181
+ * switch) is retried ONCE after `retryDelayMs`. Safe because the route is
182
+ * open-or-create: a second call finds the pair the first one created and pushes
183
+ * no second join (viber-api instances.test.ts, "a resend in the SAME direction").
178
184
  */
179
185
  export async function openDm(
180
186
  baseUrl: string,
181
187
  instanceToken: string,
182
188
  peerInstanceId: string,
189
+ /** Fetch seam (#694 tests). Defaults to the global `fetch`. */
190
+ fetchImpl: typeof fetch = fetch,
191
+ retryDelayMs: number = RESEND_DELAY_MS,
183
192
  ): Promise<OpenDmOutcome> {
184
- const resp = await fetch(`${baseUrl}/api/instances/dms`, {
185
- method: "POST",
186
- headers: {
187
- Authorization: `Bearer ${instanceToken}`,
188
- "Content-Type": "application/json",
189
- ...cfAccessHeaders(),
190
- },
191
- body: JSON.stringify({ peer_instance_id: peerInstanceId }),
192
- });
193
+ const send = (): Promise<Response> =>
194
+ fetchImpl(`${baseUrl}/api/instances/dms`, {
195
+ method: "POST",
196
+ headers: {
197
+ Authorization: `Bearer ${instanceToken}`,
198
+ "Content-Type": "application/json",
199
+ ...cfAccessHeaders(),
200
+ },
201
+ body: JSON.stringify({ peer_instance_id: peerInstanceId }),
202
+ });
203
+ let resp: Response;
204
+ try {
205
+ resp = await send();
206
+ } catch {
207
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
208
+ resp = await send();
209
+ }
193
210
 
194
211
  if (!resp.ok) {
195
212
  let detail = `HTTP ${resp.status}`;
@@ -47,7 +47,7 @@ export interface PresenceTrigger {
47
47
  stop: () => void;
48
48
  }
49
49
 
50
- /** The agent records of a presence folder: `<instance_id>.json`, never `*.coverage.json`. */
50
+ /** The agent records of a presence folder: `<instance_id>.json`, never `*.coverage.json` nor `*.runtime.json`. */
51
51
  export function listPresenceRecords(dir: string): Map<string, number> | null {
52
52
  let names: string[];
53
53
  try {
@@ -57,7 +57,9 @@ export function listPresenceRecords(dir: string): Map<string, number> | null {
57
57
  }
58
58
  const out = new Map<string, number>();
59
59
  for (const name of names) {
60
- if (!name.endsWith(".json") || name.endsWith(".coverage.json")) continue;
60
+ // #708: runtime files have their own trigger (runner_runtime.ts) — counted twice they would
61
+ // queue a second beat for one change.
62
+ if (!name.endsWith(".json") || name.endsWith(".coverage.json") || name.endsWith(".runtime.json")) continue;
61
63
  try {
62
64
  out.set(name, statSync(join(dir, name)).mtimeMs);
63
65
  } catch {
@@ -30,6 +30,8 @@ import {
30
30
  readPresenceRecord,
31
31
  } from "./presence_record.js";
32
32
  import { enumerateStartTimes } from "./process_start.js";
33
+ import { runtimeFilePath } from "./codex_runtime.js";
34
+ import { type RuntimeDelta, type RuntimeSource, type RuntimeTracker } from "./runner_runtime.js";
33
35
  import { announcedCapabilitiesVersion } from "./gateway_features.js";
34
36
  import { runnerIdentity } from "./runner_exec.js";
35
37
  import * as reg from "./runner_registry.js";
@@ -92,6 +94,8 @@ export interface CollectedRoster {
92
94
  notes: string[];
93
95
  /** #687: one line per ended agent OBSERVED (logged even if `capRoster` defers its report), for the runner's log (`endLine`). */
94
96
  ends: string[];
97
+ /** #708: where to read what each PRESENT agent runs on — exactly the present this beat carries. */
98
+ runtimeSources: RuntimeSource[];
95
99
  }
96
100
 
97
101
  /** The process table: pid → start time, or `undefined` when the instrument did not see. */
@@ -111,6 +115,7 @@ function emptyCollected(note?: string): CollectedRoster {
111
115
  endedFiles: new Map(),
112
116
  notes: note === undefined ? [] : [note],
113
117
  ends: [],
118
+ runtimeSources: [],
114
119
  };
115
120
  }
116
121
 
@@ -204,6 +209,7 @@ export async function collectRoster(deps: CollectDeps): Promise<CollectedRoster>
204
209
  created_at: record.created_at,
205
210
  job_name: record.job_name,
206
211
  });
212
+ out.runtimeSources.push({ instance_id: record.instance_id });
207
213
  } else {
208
214
  out.roster[c.kind].push(record.instance_id);
209
215
  out.endedFiles.set(record.instance_id, { path, content: JSON.stringify(record) });
@@ -229,6 +235,7 @@ function capRoster(c: CollectedRoster): CollectedRoster {
229
235
  // The registry must only learn what this beat actually told the server (Codex).
230
236
  const sentPresent = new Set(keptPresent.map((p) => p.instance_id));
231
237
  c.seen = c.seen.filter((s) => sentPresent.has(s.instance_id));
238
+ c.runtimeSources = c.runtimeSources.filter((s) => sentPresent.has(s.instance_id));
232
239
  c.roster = { present: keptPresent, closed: keptClosed, down: keptDown };
233
240
  c.notes.push(`roster capped at ${ROSTER_MAX} (had ${total}); the rest goes next beat`);
234
241
  return c;
@@ -349,6 +356,11 @@ export function applyAck(collected: CollectedRoster, ack: RosterAck, deps: Apply
349
356
  } catch {
350
357
  // no coverage for it: nothing to clean
351
358
  }
359
+ try {
360
+ unlinkSync(runtimeFilePath(deps.identityBaseUrl, id, deps.lockDir));
361
+ } catch {
362
+ // #708: no runtime file for it (an agent that never reported)
363
+ }
352
364
  }
353
365
  return removed;
354
366
  }
@@ -369,6 +381,8 @@ export interface RosterBeatDeps {
369
381
  headers?: () => Record<string, string>;
370
382
  /** #647: the announced capabilities version (default: the cached gateway probe). */
371
383
  capabilitiesVersion?: () => 1 | 2;
384
+ /** #708: what each present agent runs on; absent = the beat carries no `runtime`. */
385
+ runtime?: RuntimeTracker;
372
386
  }
373
387
 
374
388
  /**
@@ -444,6 +458,16 @@ async function rosterBeatOnce(auth: RosterBeatAuth, deps: RosterBeatDeps): Promi
444
458
  const { closes: pendingCloses, replaced } = pendingClosesOf(identity, deps.lockDir, collected.roster);
445
459
  collected.roster.closed.push(...pendingCloses);
446
460
 
461
+ const beatBody: Record<string, unknown> = {
462
+ capabilities: { runtimes: ["claude-code", "codex", "gemma"], platform: process.platform, version: (deps.capabilitiesVersion ?? announcedCapabilitiesVersion)() },
463
+ // #627 (JP, decision A): the new instance of each pair takes the old one's
464
+ // conversations — the server re-invites it once it is online.
465
+ roster: replaced.length > 0 ? { ...collected.roster, replaced } : collected.roster,
466
+ };
467
+ // #708: the roster first; runtime entries only take the room it leaves under the cap.
468
+ const runtimeSent = withRuntime(beatBody, collected.runtimeSources, deps.runtime);
469
+ const body = JSON.stringify(beatBody);
470
+
447
471
  let resp: Response;
448
472
  try {
449
473
  resp = await (deps.fetchImpl ?? fetch)(`${auth.base_url}/api/runners/${auth.runner_id}/heartbeat`, {
@@ -455,12 +479,7 @@ async function rosterBeatOnce(auth: RosterBeatAuth, deps: RosterBeatDeps): Promi
455
479
  "Content-Type": "application/json",
456
480
  ...(deps.headers?.() ?? {}),
457
481
  },
458
- body: JSON.stringify({
459
- capabilities: { runtimes: ["claude-code", "codex", "gemma"], platform: process.platform, version: (deps.capabilitiesVersion ?? announcedCapabilitiesVersion)() },
460
- // #627 (JP, decision A): the new instance of each pair takes the old one's
461
- // conversations — the server re-invites it once it is online.
462
- roster: replaced.length > 0 ? { ...collected.roster, replaced } : collected.roster,
463
- }),
482
+ body,
464
483
  });
465
484
  } catch {
466
485
  return "ok"; // best-effort: nothing acknowledged, nothing changed
@@ -473,7 +492,10 @@ async function rosterBeatOnce(auth: RosterBeatAuth, deps: RosterBeatDeps): Promi
473
492
  return "ok";
474
493
  }
475
494
 
476
- const ack = parseRosterAck(await resp.json().catch(() => null));
495
+ const ackBody: unknown = await resp.json().catch(() => null);
496
+ const ack = parseRosterAck(ackBody);
497
+ // #708: an entry counts as sent only once the server names it as stored.
498
+ if (runtimeSent) deps.runtime?.ack(storedRuntime(runtimeSent, ackBody));
477
499
  // A beat whose ended records are never acknowledged is not dangerous (coverage and
478
500
  // leases are still served) but it is SILENT: say it once it lasts (Opus, step-05).
479
501
  if (collected.endedFiles.size > 0 && !ack.presence_applied) {
@@ -584,3 +606,35 @@ export function pendingClosesOf(
584
606
  }
585
607
  return { closes, replaced };
586
608
  }
609
+
610
+ /** `,"runtime":{}` — what the field costs before its first entry. */
611
+ const RUNTIME_FIELD_BYTES = Buffer.byteLength(',"runtime":{}', "utf8");
612
+
613
+ /**
614
+ * #708: add the runtime delta to a beat body, in the room the rest leaves under
615
+ * `HEARTBEAT_MAX_BODY_BYTES`. Returns what was added (to acknowledge on success), or
616
+ * undefined when nothing was.
617
+ */
618
+ export function withRuntime(
619
+ body: Record<string, unknown>,
620
+ sources: readonly RuntimeSource[],
621
+ tracker: RuntimeTracker | undefined,
622
+ ): RuntimeDelta | undefined {
623
+ if (!tracker) return undefined;
624
+ tracker.setSources(sources);
625
+ tracker.poll();
626
+ const room = HEARTBEAT_MAX_BODY_BYTES - Buffer.byteLength(JSON.stringify(body), "utf8") - RUNTIME_FIELD_BYTES;
627
+ const delta = room > 0 ? tracker.delta(room) : {};
628
+ if (Object.keys(delta).length === 0) return undefined;
629
+ body.runtime = delta;
630
+ return delta;
631
+ }
632
+
633
+ /** #708: the entries of `sent` the server answered as stored (`runtime_applied: [ids]`). */
634
+ export function storedRuntime(sent: RuntimeDelta, ackBody: unknown): RuntimeDelta {
635
+ const ids = (ackBody as { runtime_applied?: unknown } | null)?.runtime_applied;
636
+ if (!Array.isArray(ids)) return {};
637
+ const out: RuntimeDelta = {};
638
+ for (const id of ids) if (typeof id === "string" && id in sent) out[id] = sent[id] as RuntimeDelta[string];
639
+ return out;
640
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * runner_runtime.ts — the runner's side of #708: what each present agent runs on,
3
+ * sent to the server ONLY when it changes.
4
+ *
5
+ * Source: `<presenceDir>/<id>.runtime.json`, written by the agent's own process — the
6
+ * codex bridge on each turn, the claude channel after each reply (JP's rule, a round
7
+ * trip: claude_runtime_publisher.ts). The runner never reads a session log: it stats
8
+ * these small files and re-reads one only when its size or mtime moved.
9
+ *
10
+ * On the wire, `runtime` sits NEXT TO the roster, as a delta: an instance absent
11
+ * from it means "no opinion", `null` means "clear it", an object is the value. The
12
+ * roster is serialized first; runtime entries only take the room that is left under
13
+ * the body cap — presence is never lost to metadata. An entry counts as sent only once
14
+ * the server names it in `runtime_applied: [ids]`; until then it rides the next beat.
15
+ *
16
+ * "Changed" means what the chip shows changed (`sameRuntime`): `observed_at` and the
17
+ * raw token count are never compared.
18
+ */
19
+ import { statSync } from "node:fs";
20
+ import { readRuntimeFile, runtimeFilePath } from "./codex_runtime.js";
21
+ import { type RuntimeV1, sameRuntime } from "./runtime_info.js";
22
+
23
+ export interface RuntimeSource {
24
+ instance_id: string;
25
+ }
26
+
27
+ export type RuntimeDelta = Record<string, RuntimeV1 | null>;
28
+
29
+ export interface RuntimeTrackerDeps {
30
+ identityBaseUrl: string;
31
+ lockDir?: string;
32
+ /** File signature (size + mtime), or undefined when the file is absent. */
33
+ signature?: (path: string) => string | undefined;
34
+ }
35
+
36
+ function fileSignature(path: string): string | undefined {
37
+ try {
38
+ const s = statSync(path);
39
+ return `${s.size}:${s.mtimeMs}`;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ /** Bytes `"id":<value>,` adds to a JSON object. */
46
+ function entryBytes(id: string, value: RuntimeV1 | null): number {
47
+ return Buffer.byteLength(`${JSON.stringify(id)}:${JSON.stringify(value)},`, "utf8");
48
+ }
49
+
50
+ interface Observed {
51
+ value: RuntimeV1 | undefined;
52
+ /** What was read, so an unchanged file is not read again. */
53
+ key: string | undefined;
54
+ }
55
+
56
+ export class RuntimeTracker {
57
+ private sources = new Map<string, RuntimeSource>();
58
+ private observed = new Map<string, Observed>();
59
+ /** Acknowledged by the server. Absent = never sent by this runner life. */
60
+ private sent = new Map<string, RuntimeV1 | null>();
61
+ private readonly signature: (path: string) => string | undefined;
62
+
63
+ constructor(private readonly deps: RuntimeTrackerDeps) {
64
+ this.signature = deps.signature ?? fileSignature;
65
+ }
66
+
67
+ /** The present agents of the last roster. An agent gone is forgotten: the server clears it on its end. */
68
+ setSources(list: readonly RuntimeSource[]): void {
69
+ const next = new Map(list.map((s) => [s.instance_id, s]));
70
+ for (const id of this.sources.keys()) {
71
+ if (!next.has(id)) {
72
+ this.observed.delete(id);
73
+ this.sent.delete(id);
74
+ }
75
+ }
76
+ this.sources = next;
77
+ }
78
+
79
+ /** Re-observe every source; true when some value changed since the last observation. */
80
+ poll(): boolean {
81
+ let changed = false;
82
+ for (const id of this.sources.keys()) {
83
+ const before = this.observed.get(id);
84
+ const after = this.observe(id, before);
85
+ this.observed.set(id, after);
86
+ if (!sameRuntime(before?.value, after.value)) changed = true;
87
+ }
88
+ return changed;
89
+ }
90
+
91
+ /** The entries the server does not hold yet, by instance id, within `budgetBytes`. */
92
+ delta(budgetBytes: number): RuntimeDelta {
93
+ const out: RuntimeDelta = {};
94
+ let room = budgetBytes;
95
+ for (const id of [...this.sources.keys()].sort()) {
96
+ const value = this.observed.get(id)?.value ?? null;
97
+ // Never sent: say it once, `null` included — a previous runner life may have left a value.
98
+ if (this.sent.has(id) && sameRuntime(this.sent.get(id), value)) continue;
99
+ const cost = entryBytes(id, value);
100
+ if (cost > room) break; // deterministic: the rest, in id order, goes next beat
101
+ out[id] = value;
102
+ room -= cost;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** The server stored these entries. */
108
+ ack(sent: RuntimeDelta): void {
109
+ for (const [id, value] of Object.entries(sent)) {
110
+ if (this.sources.has(id)) this.sent.set(id, value);
111
+ }
112
+ }
113
+
114
+ private observe(id: string, before: Observed | undefined): Observed {
115
+ let path: string;
116
+ try {
117
+ path = runtimeFilePath(this.deps.identityBaseUrl, id, this.deps.lockDir);
118
+ } catch {
119
+ return { value: undefined, key: undefined };
120
+ }
121
+ const sig = this.signature(path);
122
+ if (sig === undefined) return { value: undefined, key: undefined };
123
+ const key = `${path}|${sig}`;
124
+ if (before && before.key === key) return before;
125
+ const value = readRuntimeFile(path, id);
126
+ // Present but unreadable (a read racing a rename, an antivirus holding it): keep what was
127
+ // known — only an ABSENT file clears the chip. The key is not advanced, so it is read again.
128
+ if (value === undefined) return before ?? { value: undefined, key: undefined };
129
+ // Same thing shown: keep the earlier observation, `observed_at` included.
130
+ if (before && sameRuntime(before.value, value)) return { value: before.value, key };
131
+ return { value, key };
132
+ }
133
+ }
134
+
135
+ export const RUNTIME_SCAN_MS = 2_000;
136
+ export const RUNTIME_DEBOUNCE_MS = 500;
137
+
138
+ export interface RuntimeTriggerOptions {
139
+ tracker: RuntimeTracker;
140
+ onChange: () => void;
141
+ intervalMs?: number;
142
+ debounceMs?: number;
143
+ setInterval?: (fn: () => void, ms: number) => unknown;
144
+ clearInterval?: (handle: unknown) => void;
145
+ setTimeout?: (fn: () => void, ms: number) => unknown;
146
+ clearTimeout?: (handle: unknown) => void;
147
+ }
148
+
149
+ /**
150
+ * Beat when what an agent runs on CHANGES — a local poll, no request otherwise. Only a
151
+ * fresh local change triggers: a value the server has not acknowledged yet (a failed
152
+ * beat) rides the next periodic beat instead of hammering a failing server. The first
153
+ * poll is the BASELINE, covered by the runner's start-up beat.
154
+ */
155
+ export function startRuntimeTrigger(opts: RuntimeTriggerOptions): { scan: () => void; stop: () => void } {
156
+ const setIv = opts.setInterval ?? ((fn: () => void, ms: number) => unrefd(setInterval(fn, ms)));
157
+ const clearIv = opts.clearInterval ?? ((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
158
+ const setTo = opts.setTimeout ?? ((fn: () => void, ms: number) => unrefd(setTimeout(fn, ms)));
159
+ const clearTo = opts.clearTimeout ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
160
+ const debounceMs = opts.debounceMs ?? RUNTIME_DEBOUNCE_MS;
161
+ let pending: unknown = null;
162
+ let stopped = false;
163
+ opts.tracker.poll();
164
+ const scan = (): void => {
165
+ if (stopped) return;
166
+ if (!opts.tracker.poll() || pending !== null) return;
167
+ pending = setTo(() => {
168
+ pending = null;
169
+ if (!stopped) opts.onChange();
170
+ }, debounceMs);
171
+ };
172
+ const timer = setIv(scan, opts.intervalMs ?? RUNTIME_SCAN_MS);
173
+ return {
174
+ scan,
175
+ stop: () => {
176
+ stopped = true;
177
+ clearIv(timer);
178
+ if (pending !== null) clearTo(pending);
179
+ pending = null;
180
+ },
181
+ };
182
+ }
183
+
184
+ function unrefd<T>(h: T): T {
185
+ (h as { unref?: () => void }).unref?.();
186
+ return h;
187
+ }
@@ -23,6 +23,7 @@ import { type ExecDeps, reconcileOnce, runnerIdentity } from "./runner_exec.js";
23
23
  import { runRosterBeat } from "./runner_roster.js";
24
24
  import { presenceDir } from "./presence_record.js";
25
25
  import { startPresenceTrigger } from "./presence_trigger.js";
26
+ import { RuntimeTracker, startRuntimeTrigger } from "./runner_runtime.js";
26
27
 
27
28
  /** Remove leftover temp spec files (`.viber/runner-specs/`) from a prior crash
28
29
  * at daemon start — they may hold sensitive rolePrompts (Codex P1-1). The
@@ -129,8 +130,8 @@ async function safeReconcile(auth: RunnerAuth, deps: ExecDeps = {}): Promise<voi
129
130
  *
130
131
  * #627: the beat now carries the machine's ROSTER — the only presence signal of
131
132
  * the agents this runner vouches for (`runner_roster.ts`). */
132
- async function sendHeartbeat(auth: RunnerAuth, queueIfBusy = false): Promise<"ok" | "revoked"> {
133
- return runRosterBeat(auth, { headers: cfAccessHeaders }, { queueIfBusy });
133
+ async function sendHeartbeat(auth: RunnerAuth, queueIfBusy = false, runtime?: RuntimeTracker): Promise<"ok" | "revoked"> {
134
+ return runRosterBeat(auth, { headers: cfAccessHeaders, runtime }, { queueIfBusy });
134
135
  }
135
136
 
136
137
  export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -174,8 +175,10 @@ export async function runPersistentRunnerStream(
174
175
  // Shared abort so a heartbeat-detected revocation tears down the live SSE now,
175
176
  // instead of waiting for the next reconnect.
176
177
  let currentAbort: AbortController | null = null;
178
+ // #708: what each present agent runs on, pushed with the beat when it changes.
179
+ const runtime = new RuntimeTracker({ identityBaseUrl: runnerIdentity(auth) });
177
180
  const beat = (queueIfBusy: boolean): void => {
178
- void sendHeartbeat(auth, queueIfBusy)
181
+ void sendHeartbeat(auth, queueIfBusy, runtime)
179
182
  .then((r) => {
180
183
  if (r === "revoked") {
181
184
  revoked = true;
@@ -196,6 +199,8 @@ export async function runPersistentRunnerStream(
196
199
  dir: presenceDir(runnerIdentity(auth)),
197
200
  onChange: () => beat(true),
198
201
  });
202
+ // #708: and one more when what an agent runs on changes (a local poll, same request).
203
+ const runtimeTrigger = startRuntimeTrigger({ tracker: runtime, onChange: () => beat(true) });
199
204
  // Reconcile timer is a RE-JITTERED recursive setTimeout (not setInterval): each
200
205
  // tick draws a fresh crypto jitter so a fleet never re-synchronizes (Codex C6).
201
206
  let reconcileTimer: ReturnType<typeof setTimeout>;
@@ -247,6 +252,7 @@ export async function runPersistentRunnerStream(
247
252
  } finally {
248
253
  clearInterval(heartbeatTimer);
249
254
  presenceTrigger.stop();
255
+ runtimeTrigger.stop();
250
256
  clearTimeout(reconcileTimer!);
251
257
  }
252
258
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * runtime_info.ts — what an agent is running on, as the web shows it (#708).
3
+ *
4
+ * `RuntimeV1` is the contract of plan 708 step-01: model, effort and context
5
+ * occupation of ONE agent, observed locally (Claude's session log, Codex's
6
+ * token-usage notifications) and pushed by the runner when it changes.
7
+ *
8
+ * Two rules every producer and consumer shares:
9
+ * - an unknown field is OMITTED, never 0 — a 0 % looks plausible;
10
+ * - "changed" means what the chip shows changed: `observed_at` and the raw token
11
+ * count are excluded from every comparison (`sameRuntime`), otherwise each re-read
12
+ * or each call would beat the runner (#627 rule).
13
+ */
14
+
15
+ /** JP's decision (2026-10-17): every Claude model he runs has a 1 M window. */
16
+ export const CLAUDE_CONTEXT_WINDOW = 1_000_000;
17
+
18
+ export const RUNTIME_SCHEMA_VERSION = 1;
19
+ export const RUNTIME_MAX_BYTES = 512;
20
+ const MODEL_MAX = 64;
21
+ const EFFORT_MAX = 16;
22
+
23
+ export interface RuntimeV1 {
24
+ v: 1;
25
+ model?: string;
26
+ effort?: string;
27
+ /** Input-side tokens of the LAST call — never a sum over the session. */
28
+ context_tokens?: number;
29
+ context_window?: number;
30
+ /** Integer 0..100. */
31
+ context_percent?: number;
32
+ /** Epoch ms of the observation; moves only with a business field. */
33
+ observed_at: number;
34
+ }
35
+
36
+ export interface RuntimeFields {
37
+ model?: string;
38
+ effort?: string;
39
+ contextTokens?: number;
40
+ contextWindow?: number;
41
+ }
42
+
43
+ const isCount = (n: unknown): n is number => typeof n === "number" && Number.isInteger(n) && n >= 0;
44
+
45
+ /**
46
+ * Build a runtime from what is known. Anything missing or out of bounds is left
47
+ * out; the percent exists only when both its terms do, and is capped at 100.
48
+ */
49
+ export function buildRuntime(fields: RuntimeFields, observedAt: number): RuntimeV1 {
50
+ const r: RuntimeV1 = { v: 1, observed_at: observedAt };
51
+ if (typeof fields.model === "string" && fields.model !== "" && fields.model.length <= MODEL_MAX) r.model = fields.model;
52
+ if (typeof fields.effort === "string" && fields.effort !== "" && fields.effort.length <= EFFORT_MAX) r.effort = fields.effort;
53
+ if (isCount(fields.contextTokens)) r.context_tokens = fields.contextTokens;
54
+ if (isCount(fields.contextWindow) && fields.contextWindow > 0) r.context_window = fields.contextWindow;
55
+ if (r.context_tokens !== undefined && r.context_window !== undefined) {
56
+ r.context_percent = Math.min(100, Math.round((r.context_tokens / r.context_window) * 100));
57
+ }
58
+ return r;
59
+ }
60
+
61
+ /**
62
+ * True when two observations show the same thing: model, effort, window and percent.
63
+ * `observed_at` is ignored, and so is `context_tokens`: it moves on every call of a
64
+ * working agent, and comparing it would beat the runner — and write D1 — at that pace.
65
+ * The token count travels with the value; a change is a change of what the chip shows.
66
+ */
67
+ export function sameRuntime(a: RuntimeV1 | null | undefined, b: RuntimeV1 | null | undefined): boolean {
68
+ if (!a || !b) return !a && !b;
69
+ return (
70
+ a.model === b.model &&
71
+ a.effort === b.effort &&
72
+ a.context_window === b.context_window &&
73
+ a.context_percent === b.context_percent &&
74
+ // A count appearing or vanishing is a change; only its value is not compared.
75
+ (a.context_tokens === undefined) === (b.context_tokens === undefined)
76
+ );
77
+ }
78
+
79
+ /** True when nothing but the version and the timestamp is known. */
80
+ export function isEmptyRuntime(r: RuntimeV1): boolean {
81
+ return Object.keys(r).every((k) => k === "v" || k === "observed_at");
82
+ }
83
+
84
+ /**
85
+ * Strict check of a received value: the WHOLE entry is rejected on any violation,
86
+ * never one field — no half-trusted value (plan 708, step-01 contract).
87
+ */
88
+ export function parseRuntime(value: unknown): RuntimeV1 | undefined {
89
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
90
+ const v = value as Record<string, unknown>;
91
+ if (v.v !== RUNTIME_SCHEMA_VERSION) return undefined;
92
+ if (typeof v.observed_at !== "number" || !Number.isFinite(v.observed_at) || v.observed_at <= 0) return undefined;
93
+ const allowed = new Set(["v", "model", "effort", "context_tokens", "context_window", "context_percent", "observed_at"]);
94
+ if (Object.keys(v).some((k) => !allowed.has(k))) return undefined;
95
+ const str = (x: unknown, max: number): boolean => x === undefined || (typeof x === "string" && x !== "" && x.length <= max);
96
+ if (!str(v.model, MODEL_MAX) || !str(v.effort, EFFORT_MAX)) return undefined;
97
+ if (v.context_tokens !== undefined && !isCount(v.context_tokens)) return undefined;
98
+ if (v.context_window !== undefined && !(isCount(v.context_window) && v.context_window > 0)) return undefined;
99
+ if (v.context_percent !== undefined && !(isCount(v.context_percent) && v.context_percent <= 100)) return undefined;
100
+ if (Buffer.byteLength(JSON.stringify(v), "utf8") > RUNTIME_MAX_BYTES) return undefined;
101
+ return v as unknown as RuntimeV1;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.39",
3
+ "version": "0.8.41",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
package/viber-channel.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  import { channelPauseReason, type PresenceGate, startCoverageWatch } from "./lib/coverage_watch.ts";
55
55
  import { type PresenceEndReason, type PresenceRecordHandle, startPresenceRecord } from "./lib/presence_record.ts";
56
56
  import { startParentWatchdog } from "./lib/parent_watchdog.ts";
57
+ import { ClaudeRuntimePublisher } from "./lib/claude_runtime_publisher.ts";
57
58
  import { acquireInstance, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
58
59
  import { runPersistentControlStream } from "./lib/control_stream.ts";
59
60
  import { type DmWatermark, runDmStream } from "./lib/dm_stream.ts";
@@ -557,7 +558,22 @@ async function handleCallTool(
557
558
  }
558
559
  }
559
560
 
560
- mcp.setRequestHandler(CallToolRequestSchema, async (request) => toMcpToolResult(await handleCallTool(request)));
561
+ // #708: after each reply on the channel, the agent's model, effort and context are
562
+ // re-read from its claude's session log and left for the runner (JP: a round trip,
563
+ // not a watch). Created here; the instance id is read when it publishes.
564
+ const runtimePublisher = new ClaudeRuntimePublisher({
565
+ // Read at reply time: the watchdog starts after mcp.connect(), below.
566
+ getClaudePid: () => parentWatchdog.agent?.pid ?? null,
567
+ identityBaseUrl: WEB_URL,
568
+ getInstanceId: () => OWN_INSTANCE_ID,
569
+ });
570
+
571
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
572
+ const result = await handleCallTool(request);
573
+ const name = request.params.name;
574
+ if ((name === "send_message" || name === "message_agent") && !result.isError) runtimePublisher.afterReply();
575
+ return toMcpToolResult(result);
576
+ });
561
577
 
562
578
  // Prevent duplicate instances (#166); fingerprint namespacing for different
563
579
  // projects (#267); session-id namespacing for concurrent launches of the same
@@ -603,7 +619,7 @@ if (blockingPid !== null) {
603
619
  // the cmd.exe/bunx spawn chain), it becomes a zombie that keeps beating and shows
604
620
  // "online" with no one answering. The watchdog watches the agent PID+identity and
605
621
  // exits the instant it's gone — the reliable backstop to the stdin "end" handler.
606
- startParentWatchdog({
622
+ const parentWatchdog = startParentWatchdog({
607
623
  onParentGone: () => {
608
624
  scheduler?.cancel();
609
625
  releaseLock();