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.
- package/lib/claude_runtime_publisher.ts +86 -0
- package/lib/claude_session_log.ts +186 -0
- package/lib/codex_runtime.ts +123 -0
- package/lib/parent_watchdog.ts +4 -1
- package/lib/presence_trigger.ts +4 -2
- package/lib/runner_roster.ts +61 -7
- package/lib/runner_runtime.ts +187 -0
- package/lib/runner_stream.ts +9 -3
- package/lib/runtime_info.ts +102 -0
- package/package.json +1 -1
- package/viber-channel.ts +18 -2
- package/viber-codex-bridge.ts +29 -0
|
@@ -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/parent_watchdog.ts
CHANGED
|
@@ -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/presence_trigger.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/lib/runner_roster.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/lib/runner_stream.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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();
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -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;
|