viber-channel 0.8.40 → 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.
@@ -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
+ }
@@ -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
  }
@@ -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.40",
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();
@@ -119,6 +119,7 @@ import {
119
119
  import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
120
120
  import { type PresenceGate, REASON_NO_RUNNER as REASON_STARTING, startCoverageWatch } from "./lib/coverage_watch.ts";
121
121
  import { startPresenceRecord } from "./lib/presence_record.ts";
122
+ import { CodexRuntimeWriter, runtimeFilePath } from "./lib/codex_runtime.ts";
122
123
  import { startBridgeToolHost, type BridgeToolHost } from "./lib/bridge_tool_host.ts";
123
124
  import type { AgentToolsContext } from "./lib/agent_tools.ts";
124
125
  import type { OpenDmResult } from "./lib/peers.ts";
@@ -693,6 +694,14 @@ export class CodexAppServer {
693
694
  /** Set true once we intentionally `close()` so the exit handler can tell a
694
695
  * deliberate shutdown from an unexpected child death (#298). */
695
696
  private closing = false;
697
+ /** #708: model, effort and context left for the runner. Unset = not recorded. */
698
+ private runtime?: CodexRuntimeWriter;
699
+ /** #708: threads this bridge opened — a usage for any other thread is not this agent's. */
700
+ private readonly ownThreads = new Set<string>();
701
+
702
+ setRuntime(writer: CodexRuntimeWriter): void {
703
+ this.runtime = writer;
704
+ }
696
705
 
697
706
  /**
698
707
  * @param onUnexpectedExit fired ONLY when the Codex child dies on its own (not
@@ -840,6 +849,8 @@ export class CodexAppServer {
840
849
  // leaves this try (it must not fall back to a fresh thread and hide the problem).
841
850
  resumeMismatch = pairMismatch(this.pair, resumed, "thread/resume");
842
851
  if (resumeMismatch === undefined) {
852
+ this.ownThreads.add(resumedId);
853
+ this.runtime?.onThread(resumed);
843
854
  logTiming("ensureThread", nowMs() - t0, "outcome=resume");
844
855
  return resumedId;
845
856
  }
@@ -873,6 +884,8 @@ export class CodexAppServer {
873
884
  // #665: same check on a fresh thread — what codex SAYS it runs, not what we asked.
874
885
  const startMismatch = pairMismatch(this.pair, started, "thread/start");
875
886
  if (startMismatch !== undefined) this.stopOnPairMismatch(startMismatch);
887
+ this.ownThreads.add(threadId);
888
+ this.runtime?.onThread(started);
876
889
  state.threads[key] = {
877
890
  thread_id: threadId,
878
891
  conversation_id: options.conversationId,
@@ -998,6 +1011,12 @@ export class CodexAppServer {
998
1011
  return;
999
1012
  }
1000
1013
 
1014
+ if (msg.method === "thread/tokenUsage/updated") {
1015
+ const threadId = (msg.params as { threadId?: unknown } | undefined)?.threadId;
1016
+ if (typeof threadId === "string" && this.ownThreads.has(threadId)) this.runtime?.onTokenUsage(msg.params);
1017
+ return;
1018
+ }
1019
+
1001
1020
  if (msg.method === "turn/completed") {
1002
1021
  const params = msg.params as { threadId?: string; turn?: { id?: string; status?: unknown; error?: unknown } };
1003
1022
  const turnId = params.turn?.id;
@@ -1444,6 +1463,8 @@ function setupCodexRuntime(deps: {
1444
1463
  pauseReason: () => string | null;
1445
1464
  /** #665: the template's model/effort, or undefined. */
1446
1465
  pair: CodexPair | undefined;
1466
+ /** #708: this instance's id — the runtime file the runner reads is keyed on it. */
1467
+ instanceKey: string;
1447
1468
  }): { codex: CodexAppServer; toolHost: BridgeToolHost } {
1448
1469
  const { tracker } = deps;
1449
1470
  const bridgeToolsCtx: AgentToolsContext = {
@@ -1481,6 +1502,12 @@ function setupCodexRuntime(deps: {
1481
1502
  { url: toolHost.url, bearerEnvVar: "VIBER_MCP_TOKEN", bearerValue: toolHost.bearerToken },
1482
1503
  deps.pair,
1483
1504
  );
1505
+ try {
1506
+ codex.setRuntime(new CodexRuntimeWriter(runtimeFilePath(IDENTITY_URL, deps.instanceKey, LOCK_DIR()), deps.instanceKey));
1507
+ } catch (err) {
1508
+ process.stderr.write(`${LOG_PREFIX} runtime info disabled: ${safeErrorMessage(err)}
1509
+ `);
1510
+ }
1484
1511
  return { codex, toolHost };
1485
1512
  }
1486
1513
 
@@ -1606,6 +1633,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1606
1633
  requestShutdown: helpers.requestShutdown,
1607
1634
  pauseReason: helpers.pauseReason,
1608
1635
  pair,
1636
+ instanceKey: identity.instanceKey,
1609
1637
  });
1610
1638
  codex = rt.codex;
1611
1639
  toolHost = rt.toolHost;
@@ -1674,6 +1702,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1674
1702
  },
1675
1703
  pauseReason: () => gateRef.gate?.reason() ?? REASON_STARTING,
1676
1704
  pair,
1705
+ instanceKey: res.instanceKey,
1677
1706
  });
1678
1707
  codex = rt.codex;
1679
1708
  toolHost = rt.toolHost;