@agfpd/iapeer 0.4.26 → 0.4.28
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/package.json +1 -1
- package/src/launch/adapters/claude.ts +60 -0
- package/src/launch/adapters/codex.ts +22 -3
- package/src/launch/adapters/notifier.ts +4 -0
- package/src/launch/adapters/telegram.ts +4 -0
- package/src/launch/adapters/transcriptTail.ts +64 -0
- package/src/launch/adapters/voicetalk.ts +4 -0
- package/src/launch/types.ts +23 -2
- package/src/lifecycle/index.ts +28 -56
- package/src/supervisor/boot.ts +22 -0
- package/src/supervisor/daemon.ts +41 -1
package/package.json
CHANGED
|
@@ -24,6 +24,7 @@ import { homedir } from 'os'
|
|
|
24
24
|
import { join } from 'path'
|
|
25
25
|
import { readdirSync, realpathSync, statSync } from 'fs'
|
|
26
26
|
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
27
|
+
import { lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
27
28
|
|
|
28
29
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
30
|
// Boot dialog + ready markers (lifecycle claudeBootDialog / claudeInputReady)
|
|
@@ -239,6 +240,38 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
239
240
|
return null
|
|
240
241
|
},
|
|
241
242
|
|
|
243
|
+
/**
|
|
244
|
+
* MID-SESSION nag/upsell auto-dismiss (livability — distinct from bootDialogKeys,
|
|
245
|
+
* which the supervisor drives only until ready). claude pops a ONE-TIME interactive
|
|
246
|
+
* upsell modal AFTER the session is live that BLOCKS a headless peer (no human to
|
|
247
|
+
* answer):
|
|
248
|
+
*
|
|
249
|
+
* Try the new fullscreen renderer?
|
|
250
|
+
* · Flicker-free output …
|
|
251
|
+
* ❯ 1. Yes, try it
|
|
252
|
+
* 2. Not now
|
|
253
|
+
* Enter to confirm · Esc to cancel
|
|
254
|
+
*
|
|
255
|
+
* The default cursor sits on "1. Yes, try it", so a BARE Enter would ENABLE the
|
|
256
|
+
* fullscreen renderer — which switches claude to the alt-screen TUI and changes the
|
|
257
|
+
* surface the pane-log model + composer-occupancy + ready-gate all read off. We must
|
|
258
|
+
* DECLINE: type the literal '2' then Enter ('2','Enter' → "2\r"; '2' is not a named/
|
|
259
|
+
* cursor key, so it is emitted verbatim without a `-l` literal switch — and `-l`
|
|
260
|
+
* would wrongly make the following 'Enter' literal too). VERIFIED-SAFE on the live
|
|
261
|
+
* fleet: '2'+Enter cleared the modal on boris and doc and returned them to the
|
|
262
|
+
* composer; an arrow-step+Enter MIS-FIRED into "tui":"fullscreen" on scriber, and Esc/
|
|
263
|
+
* 'n' are unverified — so this is the ONLY accepted sequence. Match the FULL signature
|
|
264
|
+
* (title AND the "2. Not now" decline row) so a transcript merely DISCUSSING the
|
|
265
|
+
* fullscreen renderer can never trigger a stray keystroke. Future one-time CC upsells
|
|
266
|
+
* of this class extend the match here (each with its own verified-safe decline key).
|
|
267
|
+
*/
|
|
268
|
+
nagDismissKeys(pane: string): string[] | null {
|
|
269
|
+
if (pane.includes('Try the new fullscreen renderer?') && pane.includes('2. Not now')) {
|
|
270
|
+
return ['2', 'Enter']
|
|
271
|
+
}
|
|
272
|
+
return null
|
|
273
|
+
},
|
|
274
|
+
|
|
242
275
|
/**
|
|
243
276
|
* Ready for the first message iff the input surface is rendered AND no startup
|
|
244
277
|
* dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
|
|
@@ -280,6 +313,33 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
280
313
|
return newest > 0 ? newest : null
|
|
281
314
|
},
|
|
282
315
|
|
|
316
|
+
/**
|
|
317
|
+
* IDLE-accounting signal: the content-timestamp of the last meaningful entry in the ACTIVE transcript
|
|
318
|
+
* (newest .jsonl by file mtime), read from its tail. Immune to the file re-save (file-mtime is fresh
|
|
319
|
+
* but the last ENTRY is not) and to the statusline pane-log tick (the transcript is untouched by a
|
|
320
|
+
* status redraw). null when the dir is absent/empty or no timestamped entry sits in the tail.
|
|
321
|
+
*/
|
|
322
|
+
lastTurnMtime(cwd: string): number | null {
|
|
323
|
+
let entries: string[]
|
|
324
|
+
try {
|
|
325
|
+
entries = readdirSync(transcriptDir(cwd))
|
|
326
|
+
} catch {
|
|
327
|
+
return null
|
|
328
|
+
}
|
|
329
|
+
let best = { path: '', mt: 0 }
|
|
330
|
+
for (const name of entries) {
|
|
331
|
+
if (!name.endsWith('.jsonl')) continue
|
|
332
|
+
try {
|
|
333
|
+
const path = join(transcriptDir(cwd), name)
|
|
334
|
+
const mt = statSync(path).mtimeMs
|
|
335
|
+
if (mt > best.mt) best = { path, mt }
|
|
336
|
+
} catch {
|
|
337
|
+
/* race */
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return best.path ? lastTimestampedEntryMs(best.path) : null
|
|
341
|
+
},
|
|
342
|
+
|
|
283
343
|
/**
|
|
284
344
|
* Resume preflight (fail-loud — never a silent fresh fallback): resolve the
|
|
285
345
|
* newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { homedir } from 'os'
|
|
39
39
|
import { join } from 'path'
|
|
40
40
|
import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
|
|
41
|
+
import { lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
41
42
|
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
42
43
|
|
|
43
44
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -114,9 +115,16 @@ function canonicalPath(p: string): string {
|
|
|
114
115
|
* (the model produced its first turn); idle accounting reads the same proxy.
|
|
115
116
|
*/
|
|
116
117
|
function newestCodexSessionMtime(cwd: string): number | null {
|
|
118
|
+
return newestCodexSession(cwd)?.mt ?? null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The newest ~/.codex/sessions/**\/*.jsonl whose recorded session cwd realpath-matches `cwd`, as
|
|
122
|
+
* {path, mt}, or null when none. Shared by newestActivityMtime (file-mtime) and lastTurnMtime (needs
|
|
123
|
+
* the PATH to read the tail). Codex nests its session logs in a date tree, so the scan recurses. */
|
|
124
|
+
function newestCodexSession(cwd: string): { path: string; mt: number } | null {
|
|
117
125
|
const root = join(homedir(), '.codex', 'sessions')
|
|
118
126
|
const target = canonicalPath(cwd)
|
|
119
|
-
let
|
|
127
|
+
let best = { path: '', mt: 0 }
|
|
120
128
|
function visit(dir: string): void {
|
|
121
129
|
let entries
|
|
122
130
|
try {
|
|
@@ -134,14 +142,14 @@ function newestCodexSessionMtime(cwd: string): number | null {
|
|
|
134
142
|
if (canonicalPath(codexSessionCwd(path) ?? '') !== target) continue
|
|
135
143
|
try {
|
|
136
144
|
const mt = statSync(path).mtimeMs
|
|
137
|
-
if (mt >
|
|
145
|
+
if (mt > best.mt) best = { path, mt }
|
|
138
146
|
} catch {
|
|
139
147
|
/* race — entry vanished between readdir and stat */
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
151
|
visit(root)
|
|
144
|
-
return
|
|
152
|
+
return best.path ? best : null
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -259,6 +267,17 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
259
267
|
return newestCodexSessionMtime(cwd)
|
|
260
268
|
},
|
|
261
269
|
|
|
270
|
+
/**
|
|
271
|
+
* IDLE-accounting signal: the content-timestamp of the last meaningful entry in the active codex
|
|
272
|
+
* session jsonl (newest cwd-matching file), read from its tail. codex entries (event_msg /
|
|
273
|
+
* response_item) carry a top-level `timestamp`, so this reflects real turn activity — immune to a
|
|
274
|
+
* file re-save and to a statusline pane-log tick. null when no cwd-matching session / no timestamp.
|
|
275
|
+
*/
|
|
276
|
+
lastTurnMtime(cwd: string): number | null {
|
|
277
|
+
const s = newestCodexSession(cwd)
|
|
278
|
+
return s ? lastTimestampedEntryMs(s.path) : null
|
|
279
|
+
},
|
|
280
|
+
|
|
262
281
|
/**
|
|
263
282
|
* Resume preflight (fail-loud — never a silent fresh fallback). Codex resumes
|
|
264
283
|
* via 'resume --last' with NO ref: it matches the newest session for cwd
|
|
@@ -68,6 +68,10 @@ export const notifierAdapter: RuntimeAdapter = {
|
|
|
68
68
|
return null
|
|
69
69
|
},
|
|
70
70
|
|
|
71
|
+
lastTurnMtime(_cwd: string): number | null {
|
|
72
|
+
return null // router — no transcript
|
|
73
|
+
},
|
|
74
|
+
|
|
71
75
|
/** Nothing to resume: a router does not replay a transcript. */
|
|
72
76
|
resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
|
|
73
77
|
return { ok: true }
|
|
@@ -98,6 +98,10 @@ export const telegramAdapter: RuntimeAdapter = {
|
|
|
98
98
|
return null
|
|
99
99
|
},
|
|
100
100
|
|
|
101
|
+
lastTurnMtime(_cwd: string): number | null {
|
|
102
|
+
return null // router — no transcript
|
|
103
|
+
},
|
|
104
|
+
|
|
101
105
|
/**
|
|
102
106
|
* Nothing to resume: a router does not replay a transcript, so resume cannot
|
|
103
107
|
* fail-loud the way claude/codex do — there is no prior session ref to resolve
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Shared transcript-tail reader for the idle-accounting signal.
|
|
2
|
+
//
|
|
3
|
+
// The RELIABLE "last real turn" time is the content-timestamp of the LAST meaningful transcript entry —
|
|
4
|
+
// NOT the file mtime (a live claude session RE-SAVES its .jsonl without a new entry, so file-mtime is
|
|
5
|
+
// falsely fresh at idle) and NOT the pane-log mtime (a statusline / footer re-render writes pty bytes at
|
|
6
|
+
// idle, so the pane-log is falsely fresh too). The transcript ENTRY stream only advances on actual turn
|
|
7
|
+
// activity (user/assistant/tool/turn_duration for claude; event_msg/response_item for codex), so the
|
|
8
|
+
// last entry that carries a `timestamp` is the true idle signal — immune to both noise sources.
|
|
9
|
+
|
|
10
|
+
import { openSync, readSync, closeSync, statSync } from 'fs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Content-timestamp (epoch ms) of the LAST jsonl entry carrying a `timestamp` field, read from the
|
|
14
|
+
* file's TAIL (cheap — only the last `tailBytes` are read). Metadata records without a `timestamp`
|
|
15
|
+
* (claude `mode` / `ai-title` / `bridge-session`) are skipped. Returns null when the file is
|
|
16
|
+
* unreadable or no timestamped entry sits in the tail (the caller then floors at wokeAt). A partial
|
|
17
|
+
* FIRST line in the tail buffer is harmless: we scan from the END and return on the first complete
|
|
18
|
+
* timestamped entry, so a truncated leading line is only ever reached (and skipped) when nothing
|
|
19
|
+
* matched.
|
|
20
|
+
*/
|
|
21
|
+
export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number | null {
|
|
22
|
+
let fd: number
|
|
23
|
+
try {
|
|
24
|
+
fd = openSync(path, 'r')
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const size = statSync(path).size
|
|
30
|
+
const start = Math.max(0, size - tailBytes)
|
|
31
|
+
const len = size - start
|
|
32
|
+
if (len <= 0) return null
|
|
33
|
+
const buf = Buffer.allocUnsafe(len)
|
|
34
|
+
let read = 0
|
|
35
|
+
while (read < len) {
|
|
36
|
+
const n = readSync(fd, buf, read, len - read, start + read)
|
|
37
|
+
if (n <= 0) break
|
|
38
|
+
read += n
|
|
39
|
+
}
|
|
40
|
+
const lines = buf.subarray(0, read).toString('utf8').split(/\r?\n/)
|
|
41
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
42
|
+
const line = lines[i].trim()
|
|
43
|
+
if (!line) continue
|
|
44
|
+
let obj: unknown
|
|
45
|
+
try {
|
|
46
|
+
obj = JSON.parse(line)
|
|
47
|
+
} catch {
|
|
48
|
+
continue // partial leading line / non-json → skip
|
|
49
|
+
}
|
|
50
|
+
const ts = (obj as { timestamp?: unknown })?.timestamp
|
|
51
|
+
if (typeof ts === 'string') {
|
|
52
|
+
const ms = Date.parse(ts)
|
|
53
|
+
if (!Number.isNaN(ms)) return ms
|
|
54
|
+
} else if (typeof ts === 'number' && Number.isFinite(ts)) {
|
|
55
|
+
return ts
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
} finally {
|
|
62
|
+
closeSync(fd)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -81,6 +81,10 @@ export const voicetalkAdapter: RuntimeAdapter = {
|
|
|
81
81
|
return null
|
|
82
82
|
},
|
|
83
83
|
|
|
84
|
+
lastTurnMtime(_cwd: string): number | null {
|
|
85
|
+
return null // router — no transcript
|
|
86
|
+
},
|
|
87
|
+
|
|
84
88
|
/** Nothing to resume: a router does not replay a transcript, so resume cannot
|
|
85
89
|
* fail-loud the way claude/codex do. Always {ok:true}. */
|
|
86
90
|
resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
|
package/src/launch/types.ts
CHANGED
|
@@ -259,14 +259,35 @@ export interface RuntimeAdapter {
|
|
|
259
259
|
*/
|
|
260
260
|
bootDialogKeys(pane: string): string[] | null
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* MID-SESSION nag/upsell auto-dismiss (livability). Distinct from bootDialogKeys:
|
|
264
|
+
* the boot-driver stops at ready, but claude/codex can pop a ONE-TIME interactive
|
|
265
|
+
* upsell modal AFTER the session is live (e.g. "Try the new fullscreen renderer?")
|
|
266
|
+
* that BLOCKS the pty waiting for a keypress no headless peer answers — it froze the
|
|
267
|
+
* live fleet until a human cleared it. A persistent supervisor watcher (daemon.ts)
|
|
268
|
+
* runs THIS off the authoritative model for the whole session and writes the keys.
|
|
269
|
+
* Return the send-keys args that dismiss with the VERIFIED-SAFE default (decline),
|
|
270
|
+
* else null. Match the FULL modal signature so ordinary content mentioning the
|
|
271
|
+
* feature can never trigger a stray keystroke into the composer. Optional — a runtime
|
|
272
|
+
* with no known mid-session nags (codex/router) omits it. tui only.
|
|
273
|
+
*/
|
|
274
|
+
nagDismissKeys?(pane: string): string[] | null
|
|
275
|
+
|
|
262
276
|
/** Is the input surface ready for the first message? (tui: ready marker present
|
|
263
277
|
* AND startup dialogs gone). Router runtimes return true (no input surface). */
|
|
264
278
|
isInputReady(pane: string): boolean
|
|
265
279
|
|
|
266
|
-
/** Newest activity-proxy mtime
|
|
267
|
-
*
|
|
280
|
+
/** Newest activity-proxy FILE mtime — used by the READY-GATE (it waits for ANY transcript write past
|
|
281
|
+
* baseline = "the model produced its first turn"). NOT for idle accounting: a live session re-saves
|
|
282
|
+
* this file without a new entry, so it is falsely fresh at idle. null for a router (no proxy). */
|
|
268
283
|
newestActivityMtime(cwd: string): number | null
|
|
269
284
|
|
|
285
|
+
/** Content-timestamp (epoch ms) of the LAST meaningful transcript ENTRY — the reliable IDLE-accounting
|
|
286
|
+
* signal. Unlike newestActivityMtime (file mtime, re-touched at idle) and the pane-log mtime (a
|
|
287
|
+
* statusline re-render ticks it at idle), the entry stream only advances on real turn activity.
|
|
288
|
+
* superviseTick uses THIS for idle/quiet age. null for a router (no transcript). */
|
|
289
|
+
lastTurnMtime(cwd: string): number | null
|
|
290
|
+
|
|
270
291
|
/** Resume preflight — validate a resume request fail-loud (never silent fresh).
|
|
271
292
|
* Returns the resolved ref (claude uuid) or ok:false with a reason. */
|
|
272
293
|
resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string }
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -344,21 +344,6 @@ export function clearEphemeralArmed(cfg: LifecycleConfig, identity: string): voi
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
/** mtime (ms) of a hosted session's pane-log (`<logDir>/<identity>.log`) — the RAW TUI
|
|
348
|
-
* render byte-stream the supervisor appends for the session's whole life. It advances
|
|
349
|
-
* ~1s while the TUI is rendering a turn (the "esc to interrupt" elapsed counter ticks
|
|
350
|
-
* during BOTH model generation AND a tool run) and goes quiet at the prompt. So it is
|
|
351
|
-
* the truest active-turn liveness signal — the SAME mtime telegram-runtime reads for its
|
|
352
|
-
* typing/activity indicator — and unlike the transcript proxy it does NOT go quiet during
|
|
353
|
-
* a long model generation (no JSONL write until the message completes). null if absent. */
|
|
354
|
-
function paneLogMtimeMs(cfg: LifecycleConfig, identity: string): number | null {
|
|
355
|
-
try {
|
|
356
|
-
return statSync(join(cfg.logDir, `${identity}.log`)).mtimeMs
|
|
357
|
-
} catch {
|
|
358
|
-
return null
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
347
|
function deathsPath(cfg: LifecycleConfig, identity: string): string {
|
|
363
348
|
return join(cfg.stateDir, `${identity}.deaths`)
|
|
364
349
|
}
|
|
@@ -1157,15 +1142,14 @@ export interface SuperviseOutcome {
|
|
|
1157
1142
|
export interface SuperviseDeps {
|
|
1158
1143
|
env?: NodeJS.ProcessEnv
|
|
1159
1144
|
nowMs?: number
|
|
1160
|
-
/**
|
|
1161
|
-
*
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1145
|
+
/** IDLE-proxy seam (default: the runtime adapter's `lastTurnMtime` = content-ts of the last meaningful
|
|
1146
|
+
* transcript entry). This is the RELIABLE idle signal — NOT the transcript FILE mtime (re-saved at
|
|
1147
|
+
* idle) and NOT the pane-log mtime (a statusline re-render ticks it at idle); both falsely keep an
|
|
1148
|
+
* idle session young. Tests inject a controlled value to exercise the idle/quiet age hermetically. */
|
|
1149
|
+
lastTurnMtime?: (runtime: string, cwd: string) => number | null
|
|
1150
|
+
/** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
|
|
1151
|
+
* verdict so the idle path is exercised hermetically — no live pty session needed. */
|
|
1165
1152
|
sessionAlive?: (sock: string, identity: string) => boolean
|
|
1166
|
-
/** Pane-log mtime seam (default: the real pane-log file mtime). Tests inject a controlled value to
|
|
1167
|
-
* exercise the ephemeral active-turn vs idle distinction without a real pane-log on disk. */
|
|
1168
|
-
paneLogMtime?: (identity: string) => number | null
|
|
1169
1153
|
}
|
|
1170
1154
|
|
|
1171
1155
|
/** Resolved per-tick context threaded into superviseOnePeer (deps already defaulted by superviseTick). */
|
|
@@ -1173,9 +1157,8 @@ interface SuperviseCtx {
|
|
|
1173
1157
|
cfg: LifecycleConfig
|
|
1174
1158
|
env: NodeJS.ProcessEnv
|
|
1175
1159
|
nowMs: number
|
|
1176
|
-
|
|
1160
|
+
lastTurnMt: (runtime: string, cwd: string) => number | null
|
|
1177
1161
|
isAlive: (sock: string, identity: string) => boolean
|
|
1178
|
-
paneLogMt: (identity: string) => number | null
|
|
1179
1162
|
verbose: boolean
|
|
1180
1163
|
trace: (fields: Record<string, string | number | undefined>) => void
|
|
1181
1164
|
}
|
|
@@ -1188,7 +1171,7 @@ interface SuperviseCtx {
|
|
|
1188
1171
|
* returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
|
|
1189
1172
|
*/
|
|
1190
1173
|
function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
|
|
1191
|
-
const { cfg, env, nowMs,
|
|
1174
|
+
const { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace } = ctx
|
|
1192
1175
|
// H4 — FIRST, before any reap. A launchd-managed peer is read-only.
|
|
1193
1176
|
if (isLaunchdManaged(s.personality, env)) {
|
|
1194
1177
|
if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
|
|
@@ -1240,34 +1223,24 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1240
1223
|
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1241
1224
|
return { identity: s.identity, action: 'reaped-gone', reason: gone.reason }
|
|
1242
1225
|
}
|
|
1243
|
-
// Idle accounting via the
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
// transcript
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1226
|
+
// Idle accounting via the LAST MEANINGFUL TRANSCRIPT ENTRY's content-timestamp (adapter.lastTurnMtime)
|
|
1227
|
+
// — the only RELIABLE idle signal. The two raw mtimes both report FALSE freshness for an idle session:
|
|
1228
|
+
// • pane-log mtime — a statusline / footer re-render writes pty bytes at idle (the clock/usage/ctx%
|
|
1229
|
+
// redraw + Claude Code's own update-check/token-counter), so the pane-log ticks at the prompt. The
|
|
1230
|
+
// 0.4.16 invariant "the pane-log goes quiet ONLY at the prompt" was broken by the statusline.
|
|
1231
|
+
// • transcript FILE mtime — a live claude session RE-SAVES its .jsonl without appending a new entry,
|
|
1232
|
+
// so the file is fresh while the last real turn was hours ago (the 0.4.16 re-touch).
|
|
1233
|
+
// Both made real peers live idle 3-5h UNREAPED (incident 23.06: perplex/mrwriter/doc). The transcript
|
|
1234
|
+
// ENTRY stream only advances on actual turn activity, so the last timestamped entry is the true last
|
|
1235
|
+
// turn. The launch ready-gate keeps newestActivityMtime (file-mtime advance = "a turn produced
|
|
1236
|
+
// output"). FLOORED at wokeAt: a freshly-woken session that produced no turn yet (lastTurn from a PRIOR
|
|
1237
|
+
// session is old) must not be reaped out from under an attaching op — max(lastTurn, wokeAt) reaps it
|
|
1238
|
+
// idleSecs after the wake, never before. null lastTurn (unreadable / churned out of the tail) → wokeAt
|
|
1239
|
+
// governs (an old wokeAt = woken-long-ago = idle → reaped; a recent wokeAt = just-woken → protected).
|
|
1255
1240
|
const ephemeral = isEphemeralPeer(s.cwd)
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
mt = Math.max(plMt, s.wokeAt)
|
|
1260
|
-
} else {
|
|
1261
|
-
// Pane-log MISSING (a legacy pre-pane-log supervisor) → fall back to the transcript
|
|
1262
|
-
// proxy, NEVER to wokeAt-as-activity: a wokeAt-only age would read an ACTIVE legacy
|
|
1263
|
-
// session's idle from its hours-old wokeAt and reap it MID-WORK. The transcript is
|
|
1264
|
-
// false-fresh at idle (so an IDLE legacy peer lingers warm until its next respawn
|
|
1265
|
-
// earns a pane-log — bounded, self-heals), but an ACTIVE legacy peer keeps a
|
|
1266
|
-
// genuinely-fresh transcript and is correctly protected. Safe direction preserved.
|
|
1267
|
-
let tx = 0
|
|
1268
|
-
try { tx = activityMtime(s.runtime, s.cwd) ?? 0 } catch { /* no adapter → wokeAt */ }
|
|
1269
|
-
mt = Math.max(tx, s.wokeAt)
|
|
1270
|
-
}
|
|
1241
|
+
let lt = 0
|
|
1242
|
+
try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
|
|
1243
|
+
const mt = Math.max(lt, s.wokeAt)
|
|
1271
1244
|
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1272
1245
|
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
|
1273
1246
|
// daemon routed its outbound reply) is reaped after a QUIET window, checked
|
|
@@ -1337,9 +1310,8 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1337
1310
|
export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
|
|
1338
1311
|
const env = deps.env ?? process.env
|
|
1339
1312
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1340
|
-
const
|
|
1313
|
+
const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
|
|
1341
1314
|
const isAlive = deps.sessionAlive ?? sessionAlive
|
|
1342
|
-
const paneLogMt = deps.paneLogMtime ?? ((identity: string) => paneLogMtimeMs(cfg, identity))
|
|
1343
1315
|
const verbose = superviseLogVerbose(env)
|
|
1344
1316
|
// Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
|
|
1345
1317
|
// (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
|
|
@@ -1356,7 +1328,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1356
1328
|
const trace = (fields: Record<string, string | number | undefined>): void =>
|
|
1357
1329
|
appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
|
|
1358
1330
|
const out: SuperviseOutcome[] = []
|
|
1359
|
-
const ctx: SuperviseCtx = { cfg, env, nowMs,
|
|
1331
|
+
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace }
|
|
1360
1332
|
for (const s of readSessionStates(cfg)) {
|
|
1361
1333
|
// Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
|
|
1362
1334
|
// the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
|
package/src/supervisor/boot.ts
CHANGED
|
@@ -32,3 +32,25 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
|
|
|
32
32
|
if (adapter.isInputReady(viewport)) return { kind: 'ready' }
|
|
33
33
|
return { kind: 'wait' }
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/** The slice of a RuntimeAdapter the MID-SESSION nag-watcher consumes — the optional
|
|
37
|
+
* nagDismissKeys (claudeAdapter satisfies it; codex/router omit it → no nags). */
|
|
38
|
+
export interface NagPredicate {
|
|
39
|
+
nagDismissKeys?(pane: string): string[] | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type NagAction = { kind: 'dismiss'; keys: string[]; bytes: Buffer } | { kind: 'none' }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* MID-SESSION nag/upsell step (livability) — the persistent sibling of nextBootAction. Unlike the
|
|
46
|
+
* boot-driver, which STOPS at ready, the daemon loops this for the WHOLE session: a one-time CC upsell
|
|
47
|
+
* modal (e.g. "Try the new fullscreen renderer?") can pop AFTER the session is live and BLOCK the pty on
|
|
48
|
+
* a keypress no headless peer answers. If the adapter recognizes the (FULL-signature) modal it returns
|
|
49
|
+
* the verified-safe DECLINE keys; the caller writes `action.bytes` to the pty (cooldown-guarded so a
|
|
50
|
+
* cleared modal is never double-answered). An adapter with no nagDismissKeys → always 'none'.
|
|
51
|
+
*/
|
|
52
|
+
export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
|
|
53
|
+
const keys = adapter.nagDismissKeys?.(viewport)
|
|
54
|
+
if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
|
|
55
|
+
return { kind: 'none' }
|
|
56
|
+
}
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
stripQueries,
|
|
26
26
|
} from './protocol.ts'
|
|
27
27
|
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
|
|
28
|
-
import { nextBootAction, type BootPredicates } from './boot.ts'
|
|
28
|
+
import { nextBootAction, nextNagAction, type BootPredicates, type NagPredicate } from './boot.ts'
|
|
29
29
|
import { modelToPlainText } from './render.ts'
|
|
30
30
|
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
31
31
|
import { claudeAdapter } from '../launch/adapters/claude.ts'
|
|
@@ -80,6 +80,12 @@ const BOOT_POLL_MS = 2000
|
|
|
80
80
|
// Stop driving after this long even if readiness is never observed (a hung/unknown startup); the
|
|
81
81
|
// driver is best-effort and never blocks the conduit, which keeps serving regardless.
|
|
82
82
|
const BOOT_DRIVE_MS = 120_000
|
|
83
|
+
// MID-SESSION nag-watcher cadence: same 2 s model poll as the boot-driver. After answering a nag, hold
|
|
84
|
+
// this long before the next check so a just-dismissed modal (claude repaints it away within a frame) is
|
|
85
|
+
// never re-answered — a stray keystroke would land in the now-visible composer. If the modal genuinely
|
|
86
|
+
// survives the hold (our key did not take), the next tick re-fires, so retries stay bounded, not lost.
|
|
87
|
+
const NAG_POLL_MS = 2000
|
|
88
|
+
const NAG_COOLDOWN_MS = 4000
|
|
83
89
|
|
|
84
90
|
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
85
91
|
interface PtyChild {
|
|
@@ -541,4 +547,38 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
541
547
|
}, BOOT_POLL_MS)
|
|
542
548
|
;(driver as { unref?: () => void }).unref?.()
|
|
543
549
|
}
|
|
550
|
+
|
|
551
|
+
// ── mid-session nag-watcher (livability) ────────────────────────────────────────
|
|
552
|
+
// The boot-driver above STOPS at ready. But claude/codex can pop a ONE-TIME upsell modal
|
|
553
|
+
// MID-SESSION (e.g. "Try the new fullscreen renderer?", which appears on the return-to-prompt
|
|
554
|
+
// boundary after a tool result) that BLOCKS the pty on a keypress no headless peer answers — it
|
|
555
|
+
// froze live fleet peers (boris, doc) until a human cleared it by hand. A PERSISTENT watcher answers
|
|
556
|
+
// the curated, verified-safe DECLINE for that class off the SAME authoritative @xterm model, for the
|
|
557
|
+
// WHOLE session lifetime, exactly like the boot-driver answers startup dialogs — model-read
|
|
558
|
+
// (modelToPlainText) + raw pty-byte-write (keysToBytes). Cooldown-guarded: after firing, hold
|
|
559
|
+
// NAG_COOLDOWN_MS so a just-dismissed modal is never double-answered (a stray keystroke would land in
|
|
560
|
+
// the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
|
|
561
|
+
const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
|
|
562
|
+
if (nagAdapter?.nagDismissKeys) {
|
|
563
|
+
let nagFiredMs = 0
|
|
564
|
+
const nagWatch = setInterval(() => {
|
|
565
|
+
if (down) {
|
|
566
|
+
clearInterval(nagWatch)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
if (Date.now() - nagFiredMs < NAG_COOLDOWN_MS) return // let a just-answered modal repaint away first
|
|
570
|
+
let action
|
|
571
|
+
try {
|
|
572
|
+
const enc = { appCursorKeys: xterm.modes.applicationCursorKeysMode }
|
|
573
|
+
action = nextNagAction(nagAdapter, modelToPlainText(xterm, cols, rows), enc)
|
|
574
|
+
} catch {
|
|
575
|
+
return // transient model read — retry next tick
|
|
576
|
+
}
|
|
577
|
+
if (action.kind === 'dismiss') {
|
|
578
|
+
child.terminal.write(action.bytes)
|
|
579
|
+
nagFiredMs = Date.now()
|
|
580
|
+
}
|
|
581
|
+
}, NAG_POLL_MS)
|
|
582
|
+
;(nagWatch as { unref?: () => void }).unref?.()
|
|
583
|
+
}
|
|
544
584
|
}
|