@agfpd/iapeer 0.4.38 → 0.4.40
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/supervisor/client.ts +21 -4
- package/src/supervisor/daemon.ts +68 -19
- package/src/supervisor/index.ts +32 -8
- package/src/supervisor/paths.ts +44 -0
- package/src/supervisor/protocol.ts +45 -0
package/package.json
CHANGED
package/src/supervisor/client.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Block 2, ported from PoC pts.mjs). Detach key Ctrl-] (session keeps running). The two reattach
|
|
3
3
|
// port-deps live here + in protocol.ts: #1 TERM_RESET on EVERY exit path (incl. SIGHUP window-close,
|
|
4
4
|
// via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
|
|
5
|
-
import {
|
|
5
|
+
import { writeSync } from 'node:fs'
|
|
6
6
|
import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
|
|
7
|
-
import {
|
|
7
|
+
import { pidStartToken, readPidFile, sockPath } from './paths.ts'
|
|
8
8
|
|
|
9
9
|
const DETACH = 0x1d // Ctrl-]
|
|
10
10
|
|
|
@@ -16,9 +16,26 @@ const pidAlive = (p: number): boolean => {
|
|
|
16
16
|
return false
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
// В22 — OWNER-validated liveness. kill(pid,0) is the fast fail (dead pid → not alive). When the pid is
|
|
21
|
+
// alive AND the pidfile carries a start-token, verify the token matches the pid's CURRENT start time so a
|
|
22
|
+
// REUSED pid (a different process that inherited the number) never reads as our live session. `ps` is
|
|
23
|
+
// bounded by a short per-session cache — repeated liveness reads within a tick do not re-spawn it.
|
|
24
|
+
const OWNER_TTL_MS = 3000
|
|
25
|
+
const ownerCache = new Map<string, { pid: number; token: string; verdict: boolean; until: number }>()
|
|
26
|
+
|
|
19
27
|
export function sessionAlive(runDir: string, session: string): boolean {
|
|
20
|
-
const
|
|
21
|
-
|
|
28
|
+
const rec = readPidFile(runDir, session)
|
|
29
|
+
if (!rec || !pidAlive(rec.pid)) return false
|
|
30
|
+
if (!rec.token) return true // legacy tokenless pidfile → kill-0 only (no regression during rollout)
|
|
31
|
+
const key = `${runDir}\0${session}`
|
|
32
|
+
const now = Date.now()
|
|
33
|
+
const cached = ownerCache.get(key)
|
|
34
|
+
if (cached && cached.pid === rec.pid && cached.token === rec.token && now < cached.until) return cached.verdict
|
|
35
|
+
const live = pidStartToken(rec.pid)
|
|
36
|
+
const verdict = live !== null && live === rec.token // same process instance, not a reused pid
|
|
37
|
+
ownerCache.set(key, { pid: rec.pid, token: rec.token, verdict, until: now + OWNER_TTL_MS })
|
|
38
|
+
return verdict
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
/** Attach an interactive terminal client to a live daemon session. Resolves when the client
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -22,9 +22,10 @@ import {
|
|
|
22
22
|
frame,
|
|
23
23
|
makeFramer,
|
|
24
24
|
parseSize,
|
|
25
|
+
splitDanglingEscape,
|
|
25
26
|
stripQueries,
|
|
26
27
|
} from './protocol.ts'
|
|
27
|
-
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
|
|
28
|
+
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
|
|
28
29
|
import { nextBootAction, nextNagAction, type BootPredicates, type NagPredicate } from './boot.ts'
|
|
29
30
|
import { modelToPlainText } from './render.ts'
|
|
30
31
|
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
@@ -86,6 +87,10 @@ const BOOT_DRIVE_MS = 120_000
|
|
|
86
87
|
// survives the hold (our key did not take), the next tick re-fires, so retries stay bounded, not lost.
|
|
87
88
|
const NAG_POLL_MS = 2000
|
|
88
89
|
const NAG_COOLDOWN_MS = 4000
|
|
90
|
+
// В25 — cap the attach-snapshot stabilization wait: under saturating output the model never goes quiet,
|
|
91
|
+
// so without a budget firstAttach would never complete (frozen attach). On timeout we snapshot the
|
|
92
|
+
// current model; live-forward delivers the rest once the gate lifts.
|
|
93
|
+
const MODEL_STABLE_BUDGET_MS = 500
|
|
89
94
|
|
|
90
95
|
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
91
96
|
interface PtyChild {
|
|
@@ -187,7 +192,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
187
192
|
// (same inode) so a normal cap never triggers a spurious reopen. Tied to output (no idle wakeups) —
|
|
188
193
|
// exactly when the log matters.
|
|
189
194
|
const healPaneLog = (): void => {
|
|
190
|
-
if (!opts.paneLogPath
|
|
195
|
+
if (!opts.paneLogPath) return
|
|
191
196
|
const now = Date.now()
|
|
192
197
|
if (now - paneLogCheckedMs < PANELOG_HEAL_MS) return
|
|
193
198
|
paneLogCheckedMs = now
|
|
@@ -197,7 +202,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
197
202
|
} catch {
|
|
198
203
|
ino = -1 // path gone
|
|
199
204
|
}
|
|
200
|
-
|
|
205
|
+
// В19 — reopen when the fd is DEAD (initial open failed / a prior reopen failed) as well as when the
|
|
206
|
+
// path maps to a DIFFERENT inode (unlinked/replaced). The old `paneLogFd < 0` early-return above made
|
|
207
|
+
// a single failed open PERMANENT — the log stayed blind for the whole session (composer-busy detect,
|
|
208
|
+
// telegram typing, boot ready-gate all read this file). fd<0 with ino=-1 (path also gone) still needs
|
|
209
|
+
// a reopen attempt: openPaneLog recreates the file.
|
|
210
|
+
if (paneLogFd < 0 || ino !== paneLogIno) openPaneLog()
|
|
201
211
|
}
|
|
202
212
|
openPaneLog()
|
|
203
213
|
|
|
@@ -210,7 +220,11 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
210
220
|
// NO E3 capability) → smeared/overlapping scroll-up history. After the catch-up the supervisor does
|
|
211
221
|
// NOTHING on resize — the child redraws its viewport (SIGWINCH) and the terminal reflows its own
|
|
212
222
|
// scrollback, like a directly-launched claude.
|
|
213
|
-
|
|
223
|
+
// В23 — a SET (not a WeakSet) so `.size` gates the .attached marker on INTERACTIVE clients only. A
|
|
224
|
+
// client is painted iff it sent a FRAME_RESIZE (runClient always does on connect; a deliver-client
|
|
225
|
+
// NEVER resizes). Using clients.size counted deliver-clients too → a delivery briefly marked "human
|
|
226
|
+
// attached", falsely holding a concurrent delivery in the composer queue. Kept in sync with removals.
|
|
227
|
+
const painted = new Set<object>()
|
|
214
228
|
// INITIALIZING GATE (scrollback fix): a client is held OUT of live output-forwarding from connect until
|
|
215
229
|
// its one-time attach snapshot has been sent. Without this, live frames (claude's relative-cursor
|
|
216
230
|
// redraws) race ahead of the snapshot and corrupt the fresh terminal. The model still ingests those
|
|
@@ -223,6 +237,10 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
223
237
|
// write's parse-callback; `writeSeq` bumps on every write so the drain can detect new arrivals mid-await.
|
|
224
238
|
let writeChain: Promise<void> = Promise.resolve()
|
|
225
239
|
let writeSeq = 0
|
|
240
|
+
// В24 — a trailing INCOMPLETE escape sequence carried from one child-output chunk to the next, so a
|
|
241
|
+
// capability query split across a pty read boundary is detected/stripped as a whole (client-facing only;
|
|
242
|
+
// the model + pane-log always get the raw bytes immediately).
|
|
243
|
+
let queryTail = ''
|
|
226
244
|
// ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
|
|
227
245
|
// clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
|
|
228
246
|
// reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
|
|
@@ -231,7 +249,9 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
231
249
|
const attachedMarker = attachedPath(runDir, session)
|
|
232
250
|
const syncAttached = (): void => {
|
|
233
251
|
try {
|
|
234
|
-
|
|
252
|
+
// В23 — gate on PAINTED (interactive) clients, not raw client count: a deliver-client connection
|
|
253
|
+
// must never read as "a human is attached".
|
|
254
|
+
if (painted.size > 0) writeFileSync(attachedMarker, '')
|
|
235
255
|
else if (existsSync(attachedMarker)) unlinkSync(attachedMarker)
|
|
236
256
|
} catch {
|
|
237
257
|
/* */
|
|
@@ -253,7 +273,11 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
253
273
|
/** Backpressure-safe send to one client; drop it (and report false) if its socket is dead. */
|
|
254
274
|
const sendClient = (s: { write(b: Buffer): number }, buf: Buffer): boolean => {
|
|
255
275
|
const ok = writerFor(s).send(buf)
|
|
256
|
-
if (!ok)
|
|
276
|
+
if (!ok) {
|
|
277
|
+
clients.delete(s)
|
|
278
|
+
painted.delete(s as object) // В23 — keep the painted set (marker gate) in sync on every drop
|
|
279
|
+
syncAttached()
|
|
280
|
+
}
|
|
257
281
|
return ok
|
|
258
282
|
}
|
|
259
283
|
|
|
@@ -269,19 +293,26 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
269
293
|
const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
|
|
270
294
|
// PANE-LOG: the RAW child bytes, exactly as pipe-pane writes them (before any query-stripping
|
|
271
295
|
// for clients) — written as a Buffer, never a string. The `s` below is for query DETECTION only.
|
|
272
|
-
if (
|
|
273
|
-
healPaneLog() // reopen if
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
296
|
+
if (opts.paneLogPath) {
|
|
297
|
+
healPaneLog() // reopen if never-opened / unlinked / replaced out from under us (Defect 2 + В19)
|
|
298
|
+
if (paneLogFd >= 0) {
|
|
299
|
+
try {
|
|
300
|
+
writeSync(paneLogFd, buf)
|
|
301
|
+
} catch {
|
|
302
|
+
/* best-effort */
|
|
303
|
+
}
|
|
278
304
|
}
|
|
279
305
|
}
|
|
280
|
-
const s = buf.toString('latin1')
|
|
281
306
|
writeSeq++ // a new write arrived (lets the attach drain detect bytes that land mid-await)
|
|
282
|
-
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
|
|
283
|
-
|
|
284
|
-
|
|
307
|
+
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier) — RAW bytes
|
|
308
|
+
// В24 — run capability detect/strip over the chunk PLUS any carried tail, holding back a trailing
|
|
309
|
+
// INCOMPLETE escape for the next chunk so a query split across a pty read boundary is matched as a
|
|
310
|
+
// whole. The model + pane-log above get the RAW bytes immediately; only the client-facing detect/
|
|
311
|
+
// strip is tail-buffered (the held bytes reach the client next chunk, once complete).
|
|
312
|
+
const [complete, dangling] = splitDanglingEscape(queryTail + buf.toString('latin1'))
|
|
313
|
+
queryTail = dangling
|
|
314
|
+
for (const r of capabilityResponses(complete)) child.terminal.write(r) // answer capability queries
|
|
315
|
+
const fwd = Buffer.from(stripQueries(complete), 'latin1') // forward to clients sans queries (daemon is authoritative)
|
|
285
316
|
if (fwd.length) {
|
|
286
317
|
// Forward live output to every ATTACHED client — but NOT to one still INITIALIZING (its
|
|
287
318
|
// snapshot hasn't been sent; these bytes are already in the model and will be in that snapshot,
|
|
@@ -337,6 +368,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
337
368
|
// bytes still land in the model and bump writeSeq — loop until the sequence holds steady so the snapshot
|
|
338
369
|
// reflects every byte received so far (no stale screen, no dropped tail).
|
|
339
370
|
const awaitModelParsedStable = async (): Promise<void> => {
|
|
371
|
+
// В25 — BOUND the stabilization. Under a saturating output stream (a big file dump, a verbose build,
|
|
372
|
+
// a tailing log) every await window sees a new write → seq never holds steady → firstAttach never
|
|
373
|
+
// completes, so the attaching operator sees a FROZEN blank screen while their keystrokes go to the
|
|
374
|
+
// child blind. Cap the wait: on timeout, snapshot the CURRENT model — bytes that land after are
|
|
375
|
+
// delivered by normal live-forward once the gate lifts (the model already ingests them).
|
|
376
|
+
const deadline = Date.now() + MODEL_STABLE_BUDGET_MS
|
|
340
377
|
for (;;) {
|
|
341
378
|
const seq = writeSeq
|
|
342
379
|
try {
|
|
@@ -345,6 +382,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
345
382
|
/* a write parse-callback should not reject */
|
|
346
383
|
}
|
|
347
384
|
if (seq === writeSeq) return
|
|
385
|
+
if (Date.now() > deadline) return // saturating output — snapshot now, live-forward catches the rest
|
|
348
386
|
}
|
|
349
387
|
}
|
|
350
388
|
// Build + send the one-time attach SNAPSHOT (full resolved model: scrollback + screen + cursor + SGR colour
|
|
@@ -428,6 +466,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
428
466
|
const sz = parseSize(payload)
|
|
429
467
|
if (!painted.has(s as object)) {
|
|
430
468
|
painted.add(s as object)
|
|
469
|
+
syncAttached() // В23 — this client is now INTERACTIVE (it resized) → the .attached marker reflects it
|
|
431
470
|
void firstAttach(s, sz.cols, sz.rows) // split-resize: model → snapshot → gate-lift → child (clean frame0)
|
|
432
471
|
} else {
|
|
433
472
|
applyGeometry(sz.cols, sz.rows) // LATER resize: one real resize, no re-snapshot
|
|
@@ -458,23 +497,25 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
458
497
|
framers.get(s)?.(d)
|
|
459
498
|
},
|
|
460
499
|
drain(s: object) {
|
|
461
|
-
if (!writers.get(s)?.flush()) { clients.delete(s); syncAttached() } // writable again → flush queued tail
|
|
500
|
+
if (!writers.get(s)?.flush()) { clients.delete(s); painted.delete(s); syncAttached() } // writable again → flush queued tail
|
|
462
501
|
},
|
|
463
502
|
close(s: object) {
|
|
464
503
|
clients.delete(s)
|
|
504
|
+
painted.delete(s)
|
|
465
505
|
writers.delete(s)
|
|
466
506
|
syncAttached()
|
|
467
507
|
restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size
|
|
468
508
|
},
|
|
469
509
|
error(s: object) {
|
|
470
510
|
clients.delete(s)
|
|
511
|
+
painted.delete(s)
|
|
471
512
|
writers.delete(s)
|
|
472
513
|
syncAttached()
|
|
473
514
|
restoreServeGeometryIfIdle()
|
|
474
515
|
},
|
|
475
516
|
},
|
|
476
517
|
})
|
|
477
|
-
|
|
518
|
+
writePidFile(runDir, session, process.pid) // В22 — `<pid> <startToken>` so a reused pid can't impersonate this session
|
|
478
519
|
writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
|
|
479
520
|
|
|
480
521
|
let down = false
|
|
@@ -542,8 +583,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
542
583
|
} catch {
|
|
543
584
|
return // transient model read — retry next tick
|
|
544
585
|
}
|
|
586
|
+
// В21 — check the deadline FIRST, independent of the action. Previously the clearInterval only
|
|
587
|
+
// fired in the `else` branch, so a boot-dialog predicate that kept returning 'dialog' (a resume-
|
|
588
|
+
// picker whose layout changed after a claude update, a screen matching the dialog pattern) drove
|
|
589
|
+
// keystrokes into the pty EVERY tick for the whole session — the BOOT_DRIVE_MS backstop was dead.
|
|
590
|
+
if (Date.now() > driveDeadline) {
|
|
591
|
+
clearInterval(driver)
|
|
592
|
+
return
|
|
593
|
+
}
|
|
545
594
|
if (action.kind === 'dialog') child.terminal.write(action.bytes)
|
|
546
|
-
else if (action.kind === 'ready'
|
|
595
|
+
else if (action.kind === 'ready') clearInterval(driver)
|
|
547
596
|
}, BOOT_POLL_MS)
|
|
548
597
|
;(driver as { unref?: () => void }).unref?.()
|
|
549
598
|
}
|
package/src/supervisor/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { spawn as nodeSpawn } from 'node:child_process'
|
|
|
8
8
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
11
|
-
import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
|
|
11
|
+
import { defaultRunDir, logPath, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
|
|
12
12
|
|
|
13
13
|
// daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
|
|
14
14
|
// daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
|
|
@@ -125,17 +125,35 @@ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<S
|
|
|
125
125
|
const deadline = Date.now() + (opts.timeoutMs ?? 5000)
|
|
126
126
|
while (Date.now() < deadline) {
|
|
127
127
|
if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
|
|
128
|
-
return { state: 'started', pid:
|
|
128
|
+
return { state: 'started', pid: readPidFile(runDir, session)?.pid }
|
|
129
129
|
}
|
|
130
130
|
await Bun.sleep(80) // yield the loop — never a sync block in the daemon's wake path
|
|
131
131
|
}
|
|
132
|
-
// Failed to come up —
|
|
132
|
+
// Failed to come up in time. В20 — KILL the spawned child FIRST. A merely-slow child that becomes the
|
|
133
|
+
// daemon AFTER we unlink the serve-spec would readServeSpec → undefined → come up as a BARE CONDUIT
|
|
134
|
+
// ([bin] runtime with NO PEER_IDENTITY / composed argv / doctrine) that captures the peer's identity:
|
|
135
|
+
// liveness then reads it "online", messages route into an identity-less session, and if it slipped
|
|
136
|
+
// past enroll-on-FAILED it is invisible to idle-reap (an unbounded orphan). Better a dead peer (the
|
|
137
|
+
// next wake respawns clean) than an identity-less conduit. Kill, THEN drop the spec + any stale
|
|
138
|
+
// sock/pid the dying child created (SIGKILL skips the child's own cleanup).
|
|
139
|
+
try {
|
|
140
|
+
child.kill('SIGKILL')
|
|
141
|
+
} catch {
|
|
142
|
+
/* already gone */
|
|
143
|
+
}
|
|
133
144
|
if (opts.serve)
|
|
134
145
|
try {
|
|
135
146
|
unlinkSync(servePath(runDir, session))
|
|
136
147
|
} catch {
|
|
137
148
|
/* */
|
|
138
149
|
}
|
|
150
|
+
for (const p of [sockPath(runDir, session), pidPath(runDir, session)]) {
|
|
151
|
+
try {
|
|
152
|
+
if (existsSync(p)) unlinkSync(p)
|
|
153
|
+
} catch {
|
|
154
|
+
/* best-effort */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
139
157
|
return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
|
|
140
158
|
}
|
|
141
159
|
|
|
@@ -146,16 +164,22 @@ export function listSessions(runDir: string): Array<{ session: string; alive: bo
|
|
|
146
164
|
.map(f => f.replace(/\.sock$/, ''))
|
|
147
165
|
.map(session => {
|
|
148
166
|
const alive = sessionAlive(runDir, session)
|
|
149
|
-
return { session, alive, pid: alive ?
|
|
167
|
+
return { session, alive, pid: alive ? readPidFile(runDir, session)?.pid : undefined }
|
|
150
168
|
})
|
|
151
169
|
}
|
|
152
170
|
|
|
153
171
|
export function killSession(runDir: string, session: string): boolean {
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
156
|
-
|
|
172
|
+
const rec = readPidFile(runDir, session)
|
|
173
|
+
if (!rec) return false
|
|
174
|
+
// В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
|
|
175
|
+
// kill here would SIGTERM the INNOCENT process that inherited the pid. Skip the kill when the start-token
|
|
176
|
+
// no longer matches (a legacy tokenless pidfile keeps the old behavior — no regression during rollout).
|
|
177
|
+
if (rec.token) {
|
|
178
|
+
const live = pidStartToken(rec.pid)
|
|
179
|
+
if (live !== null && live !== rec.token) return false // reused pid — NOT our session; do not signal it
|
|
180
|
+
}
|
|
157
181
|
try {
|
|
158
|
-
process.kill(pid, 'SIGTERM')
|
|
182
|
+
process.kill(rec.pid, 'SIGTERM')
|
|
159
183
|
} catch {
|
|
160
184
|
/* already gone */
|
|
161
185
|
}
|
package/src/supervisor/paths.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// socket/pid files never touch a real location.
|
|
4
4
|
import { renameSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
6
7
|
import { join } from 'node:path'
|
|
7
8
|
import { pluginStateDir } from '../storage/index.ts'
|
|
8
9
|
|
|
@@ -13,6 +14,49 @@ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
13
14
|
|
|
14
15
|
export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
|
|
15
16
|
export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
|
|
17
|
+
|
|
18
|
+
// ── pid-OWNERSHIP token (В22) ───────────────────────────────────────────────────
|
|
19
|
+
// kill(pid,0) proves SOME process holds the pid, not that it is STILL our daemon: after an abnormal
|
|
20
|
+
// death (SIGKILL/panic/reboot) the pidfile survives, and if the OS reuses that pid for another same-uid
|
|
21
|
+
// process, a bare kill(pid,0) reads the dead session as "alive" (peer undeliverable + unreapable) and a
|
|
22
|
+
// kill() would SIGTERM the innocent reuser. The pidfile therefore stores `<pid> <startToken>` where the
|
|
23
|
+
// token is the process's start time; a reused pid has a DIFFERENT start time, so ownership is verifiable.
|
|
24
|
+
|
|
25
|
+
/** The OS-reported start time of `pid` (a stable per-process-instance token), or null if it is not
|
|
26
|
+
* running / unreadable. `ps -o lstart=` is a single spawn — callers bound its frequency (see the
|
|
27
|
+
* liveness cache in client.ts). */
|
|
28
|
+
export function pidStartToken(pid: number): string | null {
|
|
29
|
+
if (!Number.isInteger(pid) || pid <= 0) return null
|
|
30
|
+
try {
|
|
31
|
+
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' })
|
|
32
|
+
const t = (r.stdout ?? '').trim()
|
|
33
|
+
return t.length > 0 ? t : null
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Write the pidfile as `<pid> <startToken>` (В22). Falls back to bare `<pid>` if the token is
|
|
40
|
+
* unavailable — a tokenless pidfile is treated as legacy (owner-check skipped, kill-0 only). */
|
|
41
|
+
export function writePidFile(runDir: string, session: string, pid: number): void {
|
|
42
|
+
const token = pidStartToken(pid)
|
|
43
|
+
writeFileSync(pidPath(runDir, session), token ? `${pid} ${token}` : String(pid))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse the pidfile → { pid, token } (token '' for a legacy bare-pid file). null when absent/invalid. */
|
|
47
|
+
export function readPidFile(runDir: string, session: string): { pid: number; token: string } | null {
|
|
48
|
+
let raw: string
|
|
49
|
+
try {
|
|
50
|
+
raw = readFileSync(pidPath(runDir, session), 'utf8').trim()
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
if (!raw) return null
|
|
55
|
+
const sp = raw.indexOf(' ')
|
|
56
|
+
const pid = Number(sp < 0 ? raw : raw.slice(0, sp))
|
|
57
|
+
if (!Number.isInteger(pid) || pid <= 0) return null
|
|
58
|
+
return { pid, token: sp < 0 ? '' : raw.slice(sp + 1).trim() }
|
|
59
|
+
}
|
|
16
60
|
export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
|
|
17
61
|
/** Serving spec (cutover Block 2 slice b): the composed {argv, env, cwd} a detached daemon serves.
|
|
18
62
|
* Written 0600 by startSupervisorDaemon BEFORE spawn (env can carry launch.env secrets), read by
|
|
@@ -70,6 +70,51 @@ export function stripQueries(s: string): string {
|
|
|
70
70
|
return s.replace(QUERY_RE, '')
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ── chunk-boundary query buffering (В24) ────────────────────────────────────────
|
|
74
|
+
// A capability query split across a pty read boundary (`\x1b[>` in one chunk, `q` in the next)
|
|
75
|
+
// matches NEITHER chunk → the daemon neither answers nor strips it, and the fragment LEAKS to an
|
|
76
|
+
// attached client whose real terminal answers it as keystrokes into the child. Before running the
|
|
77
|
+
// capability detect/strip on a chunk, carry a trailing INCOMPLETE escape into the next one.
|
|
78
|
+
|
|
79
|
+
const MAX_DANGLING = 16 // a real short query fits well within this — a longer trailing ESC is passed through
|
|
80
|
+
|
|
81
|
+
function isCompleteEscape(seq: string): boolean {
|
|
82
|
+
if (seq.length < 2) return false // a bare ESC — could begin any sequence
|
|
83
|
+
switch (seq[1]) {
|
|
84
|
+
case '[': {
|
|
85
|
+
// CSI — parameter/intermediate bytes then a FINAL byte 0x40–0x7e
|
|
86
|
+
for (let i = 2; i < seq.length; i++) {
|
|
87
|
+
const c = seq.charCodeAt(i)
|
|
88
|
+
if (c >= 0x40 && c <= 0x7e) return true
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
case ']':
|
|
93
|
+
case 'P':
|
|
94
|
+
case 'X':
|
|
95
|
+
case '^':
|
|
96
|
+
case '_':
|
|
97
|
+
// OSC / DCS / SOS / PM / APC — terminated by BEL or ST (\x1b\)
|
|
98
|
+
return seq.includes('\x07') || seq.includes('\x1b\\')
|
|
99
|
+
case 'O':
|
|
100
|
+
return seq.length >= 3 // SS3 (ESC O <byte>)
|
|
101
|
+
default:
|
|
102
|
+
return true // any other 2-byte escape (ESC + one final char) is complete once the 2nd byte arrives
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Split `s` into [complete, dangling]: `dangling` is a trailing incomplete escape sequence to prepend
|
|
107
|
+
* to the NEXT chunk before capability detect/strip. Bounded — a lone trailing ESC with no follow-up
|
|
108
|
+
* never stalls the forward forever (it is passed through once it exceeds MAX_DANGLING). Pure/testable. */
|
|
109
|
+
export function splitDanglingEscape(s: string): [string, string] {
|
|
110
|
+
const esc = s.lastIndexOf('\x1b')
|
|
111
|
+
if (esc < 0) return [s, '']
|
|
112
|
+
const tail = s.slice(esc)
|
|
113
|
+
if (tail.length > MAX_DANGLING) return [s, '']
|
|
114
|
+
if (isCompleteEscape(tail)) return [s, '']
|
|
115
|
+
return [s.slice(0, esc), tail]
|
|
116
|
+
}
|
|
117
|
+
|
|
73
118
|
// ── key-name → pty bytes (boot-driver, serving slice a) ─────────────────────────
|
|
74
119
|
// Translate a tmux send-keys-style key list into the raw bytes a pty expects, so the supervisor
|
|
75
120
|
// boot-driver can answer startup dialogs (codex trust/update/hooks, claude trust/resume-picker) off
|