@agfpd/iapeer 0.2.60 → 0.2.62
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 +2 -1
- package/src/cli/index.ts +9 -0
- package/src/shadow/gate.test.ts +106 -0
- package/src/shadow/gate.ts +67 -1
- package/src/shadow/index.ts +9 -2
- package/src/supervisor/client.ts +118 -0
- package/src/supervisor/daemon.test.ts +112 -0
- package/src/supervisor/daemon.ts +210 -0
- package/src/supervisor/index.ts +137 -0
- package/src/supervisor/paths.ts +14 -0
- package/src/supervisor/protocol.test.ts +131 -0
- package/src/supervisor/protocol.ts +155 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.62",
|
|
4
4
|
"description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
36
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
36
37
|
"@xterm/headless": "6.0.0",
|
|
37
38
|
"proper-lockfile": "^4.1.2",
|
|
38
39
|
"transliteration": "^2.6.1"
|
package/src/cli/index.ts
CHANGED
|
@@ -939,6 +939,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
939
939
|
shadow [--once | --minutes <m>] [--interval <ms>] READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.
|
|
940
940
|
shadow-install install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback
|
|
941
941
|
shadow-uninstall bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)
|
|
942
|
+
supervisor up|start|attach|list|kill <sess> [runtime] DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)
|
|
942
943
|
`
|
|
943
944
|
|
|
944
945
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -1801,6 +1802,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1801
1802
|
out(`shadow burn-in job ${r.action}: ${r.path}\n`)
|
|
1802
1803
|
return 0
|
|
1803
1804
|
}
|
|
1805
|
+
case 'supervisor': {
|
|
1806
|
+
// DARK (cutover Block 2): the detach-persistent pty-supervisor (PoC pts.mjs port). NOT
|
|
1807
|
+
// wired into delivery/launch — it serves NOTHING on the live fleet; it is validated on
|
|
1808
|
+
// throwaway `tick` sessions. The dynamic import keeps @xterm OUT of the daemon hot path
|
|
1809
|
+
// (the daemon never loads this). Sub-commands: up|start|attach|list|kill|daemon.
|
|
1810
|
+
const { runSupervisorCli } = await import('../supervisor/index.ts')
|
|
1811
|
+
return await runSupervisorCli(rest)
|
|
1812
|
+
}
|
|
1804
1813
|
default: {
|
|
1805
1814
|
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
|
|
1806
1815
|
if (verb && isRuntime(verb)) {
|
package/src/shadow/gate.test.ts
CHANGED
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
GateTracker,
|
|
4
4
|
classifyDirection,
|
|
5
5
|
classifySampleState,
|
|
6
|
+
evaluateReadyGateFlipEligibility,
|
|
7
|
+
type GateSummary,
|
|
6
8
|
type PeerSample,
|
|
9
|
+
type VerdictGateStat,
|
|
7
10
|
} from './gate.ts'
|
|
8
11
|
|
|
9
12
|
// SYNTHETIC ACCEPTANCE (boris's Block-1 gate): prove the instrument classifies known-behind vs
|
|
@@ -141,3 +144,106 @@ describe('GateTracker — coverage matrix (clean counts only over traversed cell
|
|
|
141
144
|
expect(cov.runtimes).toEqual(['claude', 'codex'])
|
|
142
145
|
})
|
|
143
146
|
})
|
|
147
|
+
|
|
148
|
+
describe('GateTracker — center cell (codex×wake-resume×history readiness caught by the model)', () => {
|
|
149
|
+
test('a history-bearing peer whose readiness the model catches at the tmux transition → counted', () => {
|
|
150
|
+
const g = new GateTracker()
|
|
151
|
+
g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
|
|
152
|
+
g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } })) // ready, model agreed
|
|
153
|
+
expect(g.summary().coverage.readyResumeCaught['codex']).toBe(1)
|
|
154
|
+
})
|
|
155
|
+
test('model MISSES readiness at the transition → NOT counted, and it is a pty-behind (dirty)', () => {
|
|
156
|
+
const g = new GateTracker()
|
|
157
|
+
g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
|
|
158
|
+
g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: true } })) // tmux ready, model NOT
|
|
159
|
+
const s = g.summary()
|
|
160
|
+
expect(s.coverage.readyResumeCaught['codex']).toBeUndefined()
|
|
161
|
+
expect(s.verdicts.readyGate.behind).toBeGreaterThanOrEqual(1) // the exact splash-blind bug
|
|
162
|
+
})
|
|
163
|
+
test('a FRESH (no-history) peer ready transition is NOT a center-cell hit', () => {
|
|
164
|
+
const g = new GateTracker()
|
|
165
|
+
g.sample(0, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: false, tmux: false } }))
|
|
166
|
+
g.sample(1, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: true, tmux: true } }))
|
|
167
|
+
expect(g.summary().coverage.readyResumeCaught['codex']).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('evaluateReadyGateFlipEligibility (boris guardrail: the eligibility logic itself under test)', () => {
|
|
172
|
+
const stat = (over: Partial<VerdictGateStat> = {}): VerdictGateStat => ({
|
|
173
|
+
samples: 10,
|
|
174
|
+
agree: 10,
|
|
175
|
+
behind: 0,
|
|
176
|
+
aheadBenign: 0,
|
|
177
|
+
aheadHard: 0,
|
|
178
|
+
selfHeal: { count: 0, max: 0, mean: 0 },
|
|
179
|
+
gateClean: true,
|
|
180
|
+
...over,
|
|
181
|
+
})
|
|
182
|
+
const mkSummary = (over: {
|
|
183
|
+
rgBehind?: number
|
|
184
|
+
rgAheadHard?: number
|
|
185
|
+
center?: Record<string, number>
|
|
186
|
+
births?: Record<string, number>
|
|
187
|
+
restarts?: Record<string, number>
|
|
188
|
+
} = {}): GateSummary => ({
|
|
189
|
+
verdicts: {
|
|
190
|
+
occupancy: stat(),
|
|
191
|
+
readyGate: stat({ behind: over.rgBehind ?? 0, aheadHard: over.rgAheadHard ?? 0 }),
|
|
192
|
+
liveness: stat(),
|
|
193
|
+
},
|
|
194
|
+
hardViolations: [],
|
|
195
|
+
coverage: {
|
|
196
|
+
fleetWidthMax: 2,
|
|
197
|
+
runtimes: ['claude', 'codex'],
|
|
198
|
+
byRuntimeState: {},
|
|
199
|
+
births: over.births ?? {},
|
|
200
|
+
restarts: over.restarts ?? {},
|
|
201
|
+
historySamples: {},
|
|
202
|
+
readyResumeCaught: over.center ?? {},
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
const COMPLETE = {
|
|
206
|
+
center: { codex: 1 },
|
|
207
|
+
births: { 'claude/fresh': 1, 'codex/wake-resume': 1 },
|
|
208
|
+
restarts: { codex: 1 },
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
test('incomplete (empty) → NOT eligible, reasons name every missing cell (actionable)', () => {
|
|
212
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary())
|
|
213
|
+
expect(e.eligible).toBe(false)
|
|
214
|
+
expect(e.reasons.some(r => /center codex×wake-resume×history/.test(r))).toBe(true)
|
|
215
|
+
expect(e.reasons.some(r => /no codex birth/.test(r))).toBe(true)
|
|
216
|
+
expect(e.reasons.some(r => /no claude birth/.test(r))).toBe(true)
|
|
217
|
+
expect(e.reasons.some(r => /no restart/.test(r))).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('complete + clean → eligible', () => {
|
|
221
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary(COMPLETE))
|
|
222
|
+
expect(e.eligible).toBe(true)
|
|
223
|
+
expect(e.reasons).toHaveLength(0)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('complete BUT dirty (readyGate behind) → NOT eligible, reason names the dirt', () => {
|
|
227
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, rgBehind: 2 }))
|
|
228
|
+
expect(e.eligible).toBe(false)
|
|
229
|
+
expect(e.reasons.some(r => /readyGate dirty: behind=2/.test(r))).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('complete except the CENTER cell empty → NOT eligible, center reason only', () => {
|
|
233
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, center: {} }))
|
|
234
|
+
expect(e.eligible).toBe(false)
|
|
235
|
+
expect(e.reasons).toHaveLength(1)
|
|
236
|
+
expect(e.reasons[0]).toMatch(/center codex×wake-resume×history: 0/)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('complete except no restart → NOT eligible, restart reason only', () => {
|
|
240
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, restarts: {} }))
|
|
241
|
+
expect(e.eligible).toBe(false)
|
|
242
|
+
expect(e.reasons).toEqual(['no restart observed'])
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('composer-busy is ORTHOGONAL — its absence never blocks ready-gate eligibility', () => {
|
|
246
|
+
// COMPLETE carries no composer-busy state anywhere; eligibility must still be true.
|
|
247
|
+
expect(evaluateReadyGateFlipEligibility(mkSummary(COMPLETE)).eligible).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
})
|
package/src/shadow/gate.ts
CHANGED
|
@@ -93,6 +93,11 @@ interface PeerState {
|
|
|
93
93
|
firstTick: number
|
|
94
94
|
readyStreak: number
|
|
95
95
|
bootDone: boolean
|
|
96
|
+
/** History present at first sight ⇒ this peer woke WITH resume-history (vs a fresh empty birth)
|
|
97
|
+
* — the population the center cell (codex×wake-resume×history) is measured on. */
|
|
98
|
+
bornWithHistory: boolean
|
|
99
|
+
/** Previous tmux ready value, to detect the false/none→true readiness transition. */
|
|
100
|
+
prevReadyTmux: boolean | null
|
|
96
101
|
open: Record<Verdict, OpenAhead | null>
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -118,6 +123,10 @@ export class GateTracker {
|
|
|
118
123
|
private births = new Map<string, number>() // `${runtime}/${fresh|wake-resume}` → count
|
|
119
124
|
private restarts = new Map<string, number>() // runtime → count
|
|
120
125
|
private historySamples = new Map<string, number>() // runtime → count
|
|
126
|
+
// CENTER cell: a wake-resume-with-history peer whose readiness the MODEL caught at the tmux
|
|
127
|
+
// ready transition (rgTmux false/none→true with rgPty agreeing) — the codex splash-off-screen
|
|
128
|
+
// case the ready-gate flip exists for. A behind at that transition instead lands in readyGate.behind.
|
|
129
|
+
private readyResumeCaught = new Map<string, number>() // runtime → count
|
|
121
130
|
private runtimesSeen = new Set<string>()
|
|
122
131
|
|
|
123
132
|
constructor(cfg: GateConfig = {}) {
|
|
@@ -154,7 +163,15 @@ export class GateTracker {
|
|
|
154
163
|
this.inc(this.restarts, s.runtime)
|
|
155
164
|
this.goneOnce.delete(s.peer)
|
|
156
165
|
}
|
|
157
|
-
ps = {
|
|
166
|
+
ps = {
|
|
167
|
+
runtime: s.runtime,
|
|
168
|
+
firstTick: tick,
|
|
169
|
+
readyStreak: 0,
|
|
170
|
+
bootDone: false,
|
|
171
|
+
bornWithHistory: s.hasHistory,
|
|
172
|
+
prevReadyTmux: null,
|
|
173
|
+
open: { occupancy: null, readyGate: null, liveness: null },
|
|
174
|
+
}
|
|
158
175
|
this.peers.set(s.peer, ps)
|
|
159
176
|
}
|
|
160
177
|
|
|
@@ -167,6 +184,20 @@ export class GateTracker {
|
|
|
167
184
|
}
|
|
168
185
|
const bootWindow = !ps.bootDone
|
|
169
186
|
|
|
187
|
+
// Center-cell signal: at the readiness transition (rgTmux false/none→true) of a
|
|
188
|
+
// wake-resume-with-history peer, did the MODEL catch readiness too (rgPty=true)? That is the
|
|
189
|
+
// codex splash-off-screen case the ready-gate flip exists for. (A model MISS at this transition
|
|
190
|
+
// is a pty-behind → readyGate.behind → dirty → ineligible, which is exactly right.)
|
|
191
|
+
if (s.readyGate.tmux !== null) {
|
|
192
|
+
// becameReady requires an OBSERVED not-ready→ready transition (prev === false), NOT
|
|
193
|
+
// first-sight-already-ready (prev === null): the center cell must capture a REAL resume
|
|
194
|
+
// splash→ready the observer witnessed (boris's representativeness guardrail), not a warm
|
|
195
|
+
// peer that was already up when the observer attached.
|
|
196
|
+
const becameReady = s.readyGate.tmux === true && ps.prevReadyTmux === false
|
|
197
|
+
if (becameReady && ps.bornWithHistory && s.readyGate.pty === true) this.inc(this.readyResumeCaught, s.runtime)
|
|
198
|
+
ps.prevReadyTmux = s.readyGate.tmux
|
|
199
|
+
}
|
|
200
|
+
|
|
170
201
|
// Coverage.
|
|
171
202
|
const state = classifySampleState(s.occupancy.tmux === true, s.bytesFed)
|
|
172
203
|
this.inc(this.stateCells, `${s.runtime}/${state}`)
|
|
@@ -275,11 +306,37 @@ export class GateTracker {
|
|
|
275
306
|
births: Object.fromEntries([...this.births].sort()),
|
|
276
307
|
restarts: Object.fromEntries([...this.restarts].sort()),
|
|
277
308
|
historySamples: Object.fromEntries([...this.historySamples].sort()),
|
|
309
|
+
readyResumeCaught: Object.fromEntries([...this.readyResumeCaught].sort()),
|
|
278
310
|
},
|
|
279
311
|
}
|
|
280
312
|
}
|
|
281
313
|
}
|
|
282
314
|
|
|
315
|
+
/** Ready-gate FLIP eligibility — boris's verdict-scoped gate, operationalized so the burn-in
|
|
316
|
+
* SELF-REPORTS instead of being eyeballed. NECESSARY input, NOT an auto-trigger: the flip stays
|
|
317
|
+
* an explicit human decision (the representativeness of the filled cells — e.g. the splash truly
|
|
318
|
+
* scrolled off-screen — is verified separately on top of this). Eligible iff:
|
|
319
|
+
* • ready-gate is HARD-clean (behind=0 ∧ aheadHard=0), AND
|
|
320
|
+
* • the CENTER cell is filled: codex woke WITH history and the model caught its readiness, AND
|
|
321
|
+
* • a codex AND a claude birth were observed, AND
|
|
322
|
+
* • a restart was observed.
|
|
323
|
+
* composer-busy is ORTHOGONAL to readiness (a typing human already passed readiness) → excluded;
|
|
324
|
+
* it gates the LATER occupancy flip. On !eligible `reasons` say WHICH cell is empty/dirty —
|
|
325
|
+
* actionable, never a black-box "not yet". PURE → its own logic is unit-testable (boris's guardrail). */
|
|
326
|
+
export function evaluateReadyGateFlipEligibility(s: GateSummary): FlipEligibility {
|
|
327
|
+
const reasons: string[] = []
|
|
328
|
+
const rg = s.verdicts.readyGate
|
|
329
|
+
if (rg.behind > 0 || rg.aheadHard > 0) reasons.push(`readyGate dirty: behind=${rg.behind} aheadHard=${rg.aheadHard}`)
|
|
330
|
+
const center = s.coverage.readyResumeCaught['codex'] ?? 0
|
|
331
|
+
if (center < 1) reasons.push(`center codex×wake-resume×history: ${center} readiness caught (need ≥1, representatively splash-off-screen)`)
|
|
332
|
+
const hasBirth = (rt: string): boolean => Object.keys(s.coverage.births).some(k => k.startsWith(`${rt}/`))
|
|
333
|
+
if (!hasBirth('codex')) reasons.push('no codex birth observed')
|
|
334
|
+
if (!hasBirth('claude')) reasons.push('no claude birth observed')
|
|
335
|
+
const restarts = Object.values(s.coverage.restarts).reduce((a, b) => a + b, 0)
|
|
336
|
+
if (restarts < 1) reasons.push('no restart observed')
|
|
337
|
+
return { verdict: 'readyGate', eligible: reasons.length === 0, reasons }
|
|
338
|
+
}
|
|
339
|
+
|
|
283
340
|
export interface VerdictGateStat {
|
|
284
341
|
samples: number
|
|
285
342
|
agree: number
|
|
@@ -297,6 +354,8 @@ export interface GateCoverage {
|
|
|
297
354
|
births: Record<string, number>
|
|
298
355
|
restarts: Record<string, number>
|
|
299
356
|
historySamples: Record<string, number>
|
|
357
|
+
/** runtime → count of wake-resume-with-history readiness transitions the MODEL caught (center cell). */
|
|
358
|
+
readyResumeCaught: Record<string, number>
|
|
300
359
|
}
|
|
301
360
|
|
|
302
361
|
export interface GateSummary {
|
|
@@ -304,3 +363,10 @@ export interface GateSummary {
|
|
|
304
363
|
hardViolations: HardViolation[]
|
|
305
364
|
coverage: GateCoverage
|
|
306
365
|
}
|
|
366
|
+
|
|
367
|
+
export interface FlipEligibility {
|
|
368
|
+
verdict: 'readyGate'
|
|
369
|
+
eligible: boolean
|
|
370
|
+
/** On !eligible: one actionable line per unmet requirement (which cell is empty/dirty). */
|
|
371
|
+
reasons: string[]
|
|
372
|
+
}
|
package/src/shadow/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transpor
|
|
|
19
19
|
import { getAdapter } from '../launch/index.ts'
|
|
20
20
|
import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
|
|
21
21
|
import type { Runtime } from '../core/constants.ts'
|
|
22
|
-
import { GateTracker, classifyDirection, VERDICTS } from './gate.ts'
|
|
22
|
+
import { GateTracker, classifyDirection, evaluateReadyGateFlipEligibility, VERDICTS } from './gate.ts'
|
|
23
23
|
|
|
24
24
|
const SEED_BYTES = 4 * 1024 * 1024
|
|
25
25
|
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
@@ -122,7 +122,10 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
|
122
122
|
for (;;) {
|
|
123
123
|
if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
|
|
124
124
|
if (existsSync(stopFile)) break
|
|
125
|
-
|
|
125
|
+
// Re-enroll every ~3 ticks (~15s), not 15 (~75s): a freshly-woken peer must be enrolled
|
|
126
|
+
// and sampled DURING its not-ready phase for a readiness transition (the center cell) to
|
|
127
|
+
// register — a 75s enroll-lag could first-sight it already-ready and miss the transition.
|
|
128
|
+
if (i > 0 && i % 3 === 0) enumerate(models, opts, tracker)
|
|
126
129
|
tracker.tickStart(models.size)
|
|
127
130
|
for (const o of models.values()) {
|
|
128
131
|
try {
|
|
@@ -178,6 +181,10 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
|
178
181
|
// boot-window ahead that self-heals within budget. Coverage proves the matrix was traversed.
|
|
179
182
|
gateClean: VERDICTS.every(v => gs.verdicts[v].gateClean),
|
|
180
183
|
hardTotal: gs.hardViolations.length,
|
|
184
|
+
// Ready-gate FLIP eligibility (first-flip verdict) self-reported: NECESSARY input, not an
|
|
185
|
+
// auto-trigger — the flip stays an explicit decision. On !eligible, `reasons` say which cell
|
|
186
|
+
// is empty/dirty (actionable). composer-busy is excluded (orthogonal — gates the later occupancy flip).
|
|
187
|
+
readyGateFlip: evaluateReadyGateFlipEligibility(gs),
|
|
181
188
|
verdicts: gs.verdicts,
|
|
182
189
|
coverage: gs.coverage,
|
|
183
190
|
recentHard: gs.hardViolations.slice(-20),
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Supervisor CLIENT — attaches a terminal to a running daemon over the unix socket (cutover
|
|
2
|
+
// Block 2, ported from PoC pts.mjs). Detach key Ctrl-] (session keeps running). The two reattach
|
|
3
|
+
// port-deps live here + in protocol.ts: #1 TERM_RESET on EVERY exit path (incl. SIGHUP window-close,
|
|
4
|
+
// via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
|
|
5
|
+
import { existsSync, readFileSync, writeSync } from 'node:fs'
|
|
6
|
+
import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
|
|
7
|
+
import { pidPath, sockPath } from './paths.ts'
|
|
8
|
+
|
|
9
|
+
const DETACH = 0x1d // Ctrl-]
|
|
10
|
+
|
|
11
|
+
const pidAlive = (p: number): boolean => {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(p, 0)
|
|
14
|
+
return true
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function sessionAlive(runDir: string, session: string): boolean {
|
|
20
|
+
const pf = pidPath(runDir, session)
|
|
21
|
+
return existsSync(pf) && pidAlive(Number(readFileSync(pf, 'utf8')))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Attach an interactive terminal client to a live daemon session. Resolves when the client
|
|
25
|
+
* detaches / the session ends (it calls process.exit on every terminal path). */
|
|
26
|
+
export async function runSupervisorClient(runDir: string, session: string): Promise<void> {
|
|
27
|
+
if (!sessionAlive(runDir, session)) {
|
|
28
|
+
console.error(`no live session "${session}". start it: supervisor start ${session} <runtime>`)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
let restored = false
|
|
32
|
+
// writeSync(1, …) works inside a signal handler (no event-loop dependency) — so a
|
|
33
|
+
// SIGHUP/window-close client still resets the terminal before it dies (port-dep #1).
|
|
34
|
+
const restore = (): void => {
|
|
35
|
+
if (restored) return
|
|
36
|
+
restored = true
|
|
37
|
+
try {
|
|
38
|
+
writeSync(1, TERM_RESET)
|
|
39
|
+
} catch {
|
|
40
|
+
/* */
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
44
|
+
} catch {
|
|
45
|
+
/* */
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
process.stdin.pause()
|
|
49
|
+
} catch {
|
|
50
|
+
/* */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const framer = makeFramer((type, payload) => {
|
|
55
|
+
if (type === FRAME_DATA) process.stdout.write(Buffer.from(payload))
|
|
56
|
+
})
|
|
57
|
+
// Ready the terminal BEFORE connecting, so the repaint snapshot renders the instant it arrives.
|
|
58
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
59
|
+
process.stdin.resume()
|
|
60
|
+
|
|
61
|
+
const curSize = (): Buffer => sizePayload(process.stdout.columns || 80, process.stdout.rows || 24)
|
|
62
|
+
const sock = await Bun.connect({
|
|
63
|
+
unix: sockPath(runDir, session),
|
|
64
|
+
socket: {
|
|
65
|
+
open(s) {
|
|
66
|
+
s.write(frame(FRAME_RESIZE, curSize())) // tell the daemon our size → repaint at our size
|
|
67
|
+
},
|
|
68
|
+
data(_s, d) {
|
|
69
|
+
framer(d)
|
|
70
|
+
},
|
|
71
|
+
close() {
|
|
72
|
+
restore()
|
|
73
|
+
process.stdout.write('\r\n[supervisor] session ended.\r\n')
|
|
74
|
+
process.exit(0)
|
|
75
|
+
},
|
|
76
|
+
error() {
|
|
77
|
+
restore()
|
|
78
|
+
process.exit(1)
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
process.stdin.on('data', b => {
|
|
84
|
+
if (b.includes(DETACH)) {
|
|
85
|
+
restore()
|
|
86
|
+
try {
|
|
87
|
+
sock.end()
|
|
88
|
+
} catch {
|
|
89
|
+
/* */
|
|
90
|
+
}
|
|
91
|
+
process.stdout.write(`\r\n[supervisor] detached — session still running. reattach: supervisor attach ${session}\r\n`)
|
|
92
|
+
process.exit(0)
|
|
93
|
+
}
|
|
94
|
+
sock.write(frame(FRAME_DATA, b))
|
|
95
|
+
})
|
|
96
|
+
process.on('SIGWINCH', () => {
|
|
97
|
+
try {
|
|
98
|
+
sock.write(frame(FRAME_RESIZE, curSize()))
|
|
99
|
+
} catch {
|
|
100
|
+
/* */
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
// Window-close (SIGHUP) / kill (SIGTERM) of the CLIENT: detach cleanly (session keeps running)
|
|
104
|
+
// and reset the terminal — the prior gap leaked the child's app-modes and broke the next reattach.
|
|
105
|
+
for (const sig of ['SIGHUP', 'SIGTERM'] as const) {
|
|
106
|
+
process.on(sig, () => {
|
|
107
|
+
restore()
|
|
108
|
+
try {
|
|
109
|
+
sock.end()
|
|
110
|
+
} catch {
|
|
111
|
+
/* */
|
|
112
|
+
}
|
|
113
|
+
process.exit(0)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
process.on('exit', () => restore()) // last-resort sync net
|
|
117
|
+
process.stdout.write(`\x1b[2m[supervisor] attached to "${session}". detach: Ctrl-] (session keeps running)\x1b[0m\r\n`)
|
|
118
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { FRAME_DATA, FRAME_RESIZE, frame, makeFramer, sizePayload } from './protocol.ts'
|
|
6
|
+
import { killSession, listSessions, sessionAlive, sockPath, startSupervisorDaemon } from './index.ts'
|
|
7
|
+
|
|
8
|
+
// Integration: a REAL Bun-native pty daemon over a real unix socket, driven by the deterministic
|
|
9
|
+
// `tick` runtime (no claude/codex needed) — hermetic (temp run dir, throwaway session). Proves
|
|
10
|
+
// boris's Block-2 acceptance points: spawn + responder-served boot + attach repaint + live
|
|
11
|
+
// forward + detach-survival + REATTACH FRESHNESS (the heavy-reattach bug the port-deps fixed).
|
|
12
|
+
// Gated on Bun-native PTY (≥1.3.5) so a runner without it skips rather than reds.
|
|
13
|
+
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
14
|
+
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
15
|
+
const d = bunPty ? describe : describe.skip
|
|
16
|
+
|
|
17
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
18
|
+
async function waitFor(pred: () => boolean, timeoutMs = 5000, step = 50): Promise<boolean> {
|
|
19
|
+
const end = Date.now() + timeoutMs
|
|
20
|
+
while (Date.now() < end) {
|
|
21
|
+
if (pred()) return true
|
|
22
|
+
await sleep(step)
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
const maxTick = (s: string): number => {
|
|
27
|
+
let m = -1
|
|
28
|
+
for (const x of s.matchAll(/STREAMTICK_(\d+)/g)) m = Math.max(m, Number(x[1]))
|
|
29
|
+
return m
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Client {
|
|
33
|
+
text(): string
|
|
34
|
+
close(): void
|
|
35
|
+
}
|
|
36
|
+
async function attach(runDir: string, session: string, cols = 80, rows = 24): Promise<Client> {
|
|
37
|
+
const frames: Buffer[] = []
|
|
38
|
+
const framer = makeFramer((t, p) => {
|
|
39
|
+
if (t === FRAME_DATA) frames.push(Buffer.from(p))
|
|
40
|
+
})
|
|
41
|
+
const sock = await Bun.connect({
|
|
42
|
+
unix: sockPath(runDir, session),
|
|
43
|
+
socket: {
|
|
44
|
+
open(s) {
|
|
45
|
+
s.write(frame(FRAME_RESIZE, sizePayload(cols, rows)))
|
|
46
|
+
},
|
|
47
|
+
data(_s, dd) {
|
|
48
|
+
framer(dd)
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
return {
|
|
53
|
+
text: () => Buffer.concat(frames).toString('latin1'),
|
|
54
|
+
close: () => {
|
|
55
|
+
try {
|
|
56
|
+
sock.end()
|
|
57
|
+
} catch {
|
|
58
|
+
/* */
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
d('supervisor daemon (Bun-native pty, tick runtime)', () => {
|
|
65
|
+
let runDir: string
|
|
66
|
+
const session = 'ptstest'
|
|
67
|
+
|
|
68
|
+
beforeAll(() => {
|
|
69
|
+
runDir = mkdtempSync(join(tmpdir(), 'iapeer-supervisor-'))
|
|
70
|
+
})
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
try {
|
|
73
|
+
killSession(runDir, session)
|
|
74
|
+
} catch {
|
|
75
|
+
/* */
|
|
76
|
+
}
|
|
77
|
+
rmSync(runDir, { recursive: true, force: true })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('spawns a detached daemon that comes up live', () => {
|
|
81
|
+
const r = startSupervisorDaemon({ session, runtime: 'tick', runDir })
|
|
82
|
+
expect(r.state).toBe('started')
|
|
83
|
+
expect(sessionAlive(runDir, session)).toBe(true)
|
|
84
|
+
expect(listSessions(runDir).some(s => s.session === session && s.alive)).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('attach forwards live child output (the pty conduit works end-to-end)', async () => {
|
|
88
|
+
const c = await attach(runDir, session)
|
|
89
|
+
const got = await waitFor(() => /STREAMTICK_\d+/.test(c.text()))
|
|
90
|
+
expect(got).toBe(true) // live STREAMTICK_N stream reached the client
|
|
91
|
+
c.close()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('detach keeps the daemon alive (detach-persistence)', async () => {
|
|
95
|
+
await sleep(150)
|
|
96
|
+
expect(sessionAlive(runDir, session)).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('REATTACH repaints the CURRENT frame, not a stale one (port-dep #2 freshness)', async () => {
|
|
100
|
+
const first = await attach(runDir, session)
|
|
101
|
+
await waitFor(() => maxTick(first.text()) >= 0)
|
|
102
|
+
const base = maxTick(first.text())
|
|
103
|
+
first.close()
|
|
104
|
+
await sleep(800) // the stream advances while detached
|
|
105
|
+
const second = await attach(runDir, session)
|
|
106
|
+
// the reattach repaint (serialize of the CURRENT screen) must reflect ticks emitted AFTER the
|
|
107
|
+
// first detach — a stale close-moment frame would show base, not a higher index.
|
|
108
|
+
const fresh = await waitFor(() => maxTick(second.text()) > base, 5000)
|
|
109
|
+
expect(fresh).toBe(true)
|
|
110
|
+
second.close()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Supervisor DAEMON — holds the pty + child + headless-xterm model + capability responder
|
|
2
|
+
// independent of any terminal; clients attach/detach over a unix socket (cutover Block 2, ported
|
|
3
|
+
// from the validated PoC pts.mjs). DARK: reachable only via the `supervisor` CLI verb (dynamic
|
|
4
|
+
// import), so @xterm never loads in the delivery hot path. NOT wired into launch/lifecycle — it
|
|
5
|
+
// serves NOTHING on the live fleet yet; it is validated on throwaway sessions only.
|
|
6
|
+
//
|
|
7
|
+
// Loads @xterm/headless + @xterm/addon-serialize (the latter is the repaint source: a reattaching
|
|
8
|
+
// client gets the full current screen even if the child is silent). Both bundle under
|
|
9
|
+
// `bun build --compile` (pure JS; the Bun-native pty has no native addon — PoC-proven).
|
|
10
|
+
import { Terminal } from '@xterm/headless'
|
|
11
|
+
import { SerializeAddon } from '@xterm/addon-serialize'
|
|
12
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import {
|
|
14
|
+
BackpressureWriter,
|
|
15
|
+
FRAME_DATA,
|
|
16
|
+
FRAME_RESIZE,
|
|
17
|
+
capabilityResponses,
|
|
18
|
+
frame,
|
|
19
|
+
makeFramer,
|
|
20
|
+
parseSize,
|
|
21
|
+
stripQueries,
|
|
22
|
+
} from './protocol.ts'
|
|
23
|
+
import { pidPath, sockPath } from './paths.ts'
|
|
24
|
+
|
|
25
|
+
/** Known runtime launchers; `tick` is a deterministic test stream (no TUI noise) for hermetic
|
|
26
|
+
* spawn/attach/reattach tests — it never needs a real claude/codex on the box. */
|
|
27
|
+
const KNOWN: Record<string, string> = {
|
|
28
|
+
claude: '/Users/macmini/.local/bin/claude',
|
|
29
|
+
codex: '/opt/homebrew/bin/codex',
|
|
30
|
+
}
|
|
31
|
+
const TICK_CMD = ['sh', '-c', 'i=0; while true; do printf "STREAMTICK_%d\\r\\n" "$i"; i=$((i+1)); sleep 0.3; done']
|
|
32
|
+
|
|
33
|
+
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
34
|
+
interface PtyChild {
|
|
35
|
+
terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
|
|
36
|
+
kill(sig?: number | string): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SupervisorDaemonOptions {
|
|
40
|
+
session: string
|
|
41
|
+
runtime: string
|
|
42
|
+
runDir: string
|
|
43
|
+
/** Child working directory (default: runDir — a throwaway scratch for dark validation). */
|
|
44
|
+
cwd?: string
|
|
45
|
+
/** Override the runtime launcher (else KNOWN[runtime] if it exists, else the bare name). */
|
|
46
|
+
bin?: string
|
|
47
|
+
cols?: number
|
|
48
|
+
rows?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Run the supervisor daemon in THIS process (blocks until the child exits / SIGTERM). Spawn it
|
|
52
|
+
* detached via startSupervisorDaemon (index.ts) for a real session; call directly in a test that
|
|
53
|
+
* owns its own process. */
|
|
54
|
+
export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
55
|
+
const { session, runtime, runDir } = opts
|
|
56
|
+
let cols = opts.cols ?? 80
|
|
57
|
+
let rows = opts.rows ?? 30
|
|
58
|
+
const sock = sockPath(runDir, session)
|
|
59
|
+
const pid = pidPath(runDir, session)
|
|
60
|
+
|
|
61
|
+
const xterm = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 5000 })
|
|
62
|
+
const ser = new SerializeAddon()
|
|
63
|
+
xterm.loadAddon(ser)
|
|
64
|
+
|
|
65
|
+
const bin = opts.bin ?? (KNOWN[runtime] && existsSync(KNOWN[runtime]!) ? KNOWN[runtime]! : runtime)
|
|
66
|
+
const CMD = runtime === 'tick' ? TICK_CMD : [bin]
|
|
67
|
+
const env = { ...process.env, TERM: 'xterm-256color' } as Record<string, string>
|
|
68
|
+
delete env.TMUX
|
|
69
|
+
delete env.TMUX_PANE
|
|
70
|
+
|
|
71
|
+
const clients = new Set<unknown>()
|
|
72
|
+
const writers = new WeakMap<object, BackpressureWriter>()
|
|
73
|
+
const framers = new WeakMap<object, (chunk: Uint8Array) => void>()
|
|
74
|
+
const writerFor = (s: { write(b: Buffer): number }): BackpressureWriter => {
|
|
75
|
+
let w = writers.get(s as object)
|
|
76
|
+
if (!w) {
|
|
77
|
+
w = new BackpressureWriter(b => s.write(b))
|
|
78
|
+
writers.set(s as object, w)
|
|
79
|
+
}
|
|
80
|
+
return w
|
|
81
|
+
}
|
|
82
|
+
/** Backpressure-safe send to one client; drop it (and report false) if its socket is dead. */
|
|
83
|
+
const sendClient = (s: { write(b: Buffer): number }, buf: Buffer): boolean => {
|
|
84
|
+
const ok = writerFor(s).send(buf)
|
|
85
|
+
if (!ok) clients.delete(s)
|
|
86
|
+
return ok
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const child = Bun.spawn(CMD, {
|
|
90
|
+
cwd: opts.cwd ?? runDir,
|
|
91
|
+
env,
|
|
92
|
+
// Bun-native PTY: a real pty pair, child output arrives via terminal.data(t, bytes).
|
|
93
|
+
terminal: {
|
|
94
|
+
cols,
|
|
95
|
+
rows,
|
|
96
|
+
name: 'xterm-256color',
|
|
97
|
+
data(_t: unknown, d: Uint8Array | string) {
|
|
98
|
+
const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
|
|
99
|
+
const s = buf.toString('latin1')
|
|
100
|
+
xterm.write(buf) // authoritative model
|
|
101
|
+
for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
|
|
102
|
+
const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
|
|
103
|
+
if (fwd.length) for (const c of [...clients]) sendClient(c as { write(b: Buffer): number }, frame(FRAME_DATA, fwd))
|
|
104
|
+
},
|
|
105
|
+
exit() {
|
|
106
|
+
shutdown()
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
} as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
|
|
110
|
+
xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child
|
|
111
|
+
|
|
112
|
+
/** Repaint a (re)attaching client from the headless snapshot: clear+home, then the full grid.
|
|
113
|
+
* Backpressure-safe — a ~90 KB heavy snapshot MUST NOT drop past the socket buffer (port-dep #2). */
|
|
114
|
+
const repaint = (s: { write(b: Buffer): number }): void => {
|
|
115
|
+
const snap = '\x1b[2J\x1b[3J\x1b[H' + ser.serialize()
|
|
116
|
+
sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const onClientFrame = (s: { write(b: Buffer): number }, type: number, payload: Buffer): void => {
|
|
120
|
+
if (type === FRAME_DATA) {
|
|
121
|
+
child.terminal.write(payload) // keystrokes → child
|
|
122
|
+
} else if (type === FRAME_RESIZE) {
|
|
123
|
+
const sz = parseSize(payload)
|
|
124
|
+
cols = sz.cols
|
|
125
|
+
rows = sz.rows
|
|
126
|
+
try {
|
|
127
|
+
xterm.resize(cols, rows)
|
|
128
|
+
child.terminal.resize(cols, rows)
|
|
129
|
+
} catch {
|
|
130
|
+
/* transient */
|
|
131
|
+
}
|
|
132
|
+
repaint(s) // instant paint from the snapshot (delivered COMPLETE via backpressure)
|
|
133
|
+
// Best-effort SIGWINCH nudge for a SIGWINCH-reactive child; NOT load-bearing (the complete
|
|
134
|
+
// snapshot above is the real freshness fix).
|
|
135
|
+
try {
|
|
136
|
+
child.terminal.resize(cols, Math.max(1, rows - 1))
|
|
137
|
+
child.terminal.resize(cols, rows)
|
|
138
|
+
} catch {
|
|
139
|
+
/* */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync(sock)) unlinkSync(sock)
|
|
146
|
+
} catch {
|
|
147
|
+
/* */
|
|
148
|
+
}
|
|
149
|
+
const server = Bun.listen({
|
|
150
|
+
unix: sock,
|
|
151
|
+
socket: {
|
|
152
|
+
open(s: { write(b: Buffer): number }) {
|
|
153
|
+
framers.set(s as object, makeFramer((t, p) => onClientFrame(s, t, p)))
|
|
154
|
+
clients.add(s)
|
|
155
|
+
repaint(s) // UNCONDITIONAL repaint on attach (tmux-like)
|
|
156
|
+
},
|
|
157
|
+
data(s: object, d: Uint8Array) {
|
|
158
|
+
framers.get(s)?.(d)
|
|
159
|
+
},
|
|
160
|
+
drain(s: object) {
|
|
161
|
+
if (!writers.get(s)?.flush()) clients.delete(s) // writable again → flush queued tail
|
|
162
|
+
},
|
|
163
|
+
close(s: object) {
|
|
164
|
+
clients.delete(s)
|
|
165
|
+
writers.delete(s)
|
|
166
|
+
},
|
|
167
|
+
error(s: object) {
|
|
168
|
+
clients.delete(s)
|
|
169
|
+
writers.delete(s)
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
writeFileSync(pid, String(process.pid))
|
|
174
|
+
|
|
175
|
+
let down = false
|
|
176
|
+
function shutdown(): void {
|
|
177
|
+
if (down) return
|
|
178
|
+
down = true
|
|
179
|
+
try {
|
|
180
|
+
child.kill('SIGKILL')
|
|
181
|
+
} catch {
|
|
182
|
+
/* */
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
server.stop(true)
|
|
186
|
+
} catch {
|
|
187
|
+
/* */
|
|
188
|
+
}
|
|
189
|
+
for (const f of [sock, pid]) {
|
|
190
|
+
try {
|
|
191
|
+
unlinkSync(f)
|
|
192
|
+
} catch {
|
|
193
|
+
/* */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
process.exit(0)
|
|
197
|
+
}
|
|
198
|
+
process.on('SIGTERM', shutdown)
|
|
199
|
+
process.on('SIGINT', shutdown)
|
|
200
|
+
process.on('SIGHUP', () => {
|
|
201
|
+
/* daemon survives client window close */
|
|
202
|
+
})
|
|
203
|
+
process.on('uncaughtException', e => {
|
|
204
|
+
try {
|
|
205
|
+
console.error('[supervisor-daemon] uncaught', (e && (e as Error).stack) || e)
|
|
206
|
+
} catch {
|
|
207
|
+
/* */
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Supervisor entry + CLI dispatch (cutover Block 2, ported from PoC pts.mjs). DARK: this module
|
|
2
|
+
// (and daemon.ts/client.ts) load @xterm — they are reachable ONLY via the `supervisor` CLI verb
|
|
3
|
+
// (dynamic import) and are NOT imported by transport/lifecycle/launch/daemon. The supervisor
|
|
4
|
+
// serves NOTHING on the live fleet; it is validated on throwaway `tick` sessions in-repo. When
|
|
5
|
+
// serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
|
|
6
|
+
// binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
|
|
7
|
+
import { spawn as nodeSpawn } from 'node:child_process'
|
|
8
|
+
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { runSupervisorDaemon } from './daemon.ts'
|
|
11
|
+
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
12
|
+
import { defaultRunDir, logPath, pidPath, sockPath } from './paths.ts'
|
|
13
|
+
|
|
14
|
+
export { runSupervisorDaemon } from './daemon.ts'
|
|
15
|
+
export { runSupervisorClient, sessionAlive } from './client.ts'
|
|
16
|
+
export { defaultRunDir, sockPath, pidPath } from './paths.ts'
|
|
17
|
+
|
|
18
|
+
export interface StartDaemonOptions {
|
|
19
|
+
session: string
|
|
20
|
+
runtime: string
|
|
21
|
+
runDir: string
|
|
22
|
+
/** How to launch the detached daemon process (default: bun + this file). The trailing
|
|
23
|
+
* `daemon <session> <runtime> --run-dir <runDir>` is appended. Serving-prod injects the
|
|
24
|
+
* installed-binary argv here. */
|
|
25
|
+
daemonArgv?: string[]
|
|
26
|
+
/** Max ms to wait for the daemon socket+pid to appear (default 5000). */
|
|
27
|
+
timeoutMs?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StartDaemonResult {
|
|
31
|
+
state: 'started' | 'already-running' | 'failed'
|
|
32
|
+
pid?: number
|
|
33
|
+
detail?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const selfArgv = (): string[] => [process.execPath, fileURLToPath(import.meta.url)]
|
|
37
|
+
|
|
38
|
+
/** Start a detached supervisor daemon and wait until its socket is live. Idempotent. */
|
|
39
|
+
export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResult {
|
|
40
|
+
const { session, runtime, runDir } = opts
|
|
41
|
+
if (sessionAlive(runDir, session)) return { state: 'already-running' }
|
|
42
|
+
mkdirSync(runDir, { recursive: true })
|
|
43
|
+
try {
|
|
44
|
+
if (existsSync(sockPath(runDir, session))) unlinkSync(sockPath(runDir, session))
|
|
45
|
+
} catch {
|
|
46
|
+
/* */
|
|
47
|
+
}
|
|
48
|
+
const out = openSync(logPath(runDir, session), 'a')
|
|
49
|
+
const argv = opts.daemonArgv ?? selfArgv()
|
|
50
|
+
const child = nodeSpawn(argv[0]!, [...argv.slice(1), 'daemon', session, runtime, '--run-dir', runDir], {
|
|
51
|
+
detached: true,
|
|
52
|
+
stdio: ['ignore', out, out],
|
|
53
|
+
})
|
|
54
|
+
child.unref()
|
|
55
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 5000)
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
|
|
58
|
+
return { state: 'started', pid: Number(readFileSync(pidPath(runDir, session), 'utf8')) }
|
|
59
|
+
}
|
|
60
|
+
Bun.sleepSync(80)
|
|
61
|
+
}
|
|
62
|
+
return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function listSessions(runDir: string): Array<{ session: string; alive: boolean; pid?: number }> {
|
|
66
|
+
if (!existsSync(runDir)) return []
|
|
67
|
+
return readdirSync(runDir)
|
|
68
|
+
.filter(f => f.endsWith('.sock'))
|
|
69
|
+
.map(f => f.replace(/\.sock$/, ''))
|
|
70
|
+
.map(session => {
|
|
71
|
+
const alive = sessionAlive(runDir, session)
|
|
72
|
+
return { session, alive, pid: alive ? Number(readFileSync(pidPath(runDir, session), 'utf8')) : undefined }
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function killSession(runDir: string, session: string): boolean {
|
|
77
|
+
const pf = pidPath(runDir, session)
|
|
78
|
+
if (!existsSync(pf)) return false
|
|
79
|
+
const pid = Number(readFileSync(pf, 'utf8'))
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 'SIGTERM')
|
|
82
|
+
} catch {
|
|
83
|
+
/* already gone */
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Parse a `--run-dir <dir>` flag out of an argv slice; returns the value or the default. */
|
|
89
|
+
function runDirFrom(args: string[]): string {
|
|
90
|
+
const i = args.indexOf('--run-dir')
|
|
91
|
+
return i >= 0 && args[i + 1] ? args[i + 1]! : defaultRunDir()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** CLI dispatch (also the `import.meta.main` entry used by the spawned daemon process). */
|
|
95
|
+
export async function runSupervisorCli(argv: string[]): Promise<number> {
|
|
96
|
+
const [cmd, a, b] = argv
|
|
97
|
+
const runDir = runDirFrom(argv)
|
|
98
|
+
switch (cmd) {
|
|
99
|
+
case 'daemon':
|
|
100
|
+
runSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
101
|
+
// The daemon is event-driven: runSupervisorDaemon RETURNS after wiring the pty + socket
|
|
102
|
+
// server, and the event loop keeps the process alive. Await a never-resolving promise so the
|
|
103
|
+
// caller's `process.exit` does NOT fire — shutdown() (child-exit / SIGTERM) owns the exit.
|
|
104
|
+
await new Promise<never>(() => {})
|
|
105
|
+
return 0 // unreachable
|
|
106
|
+
case 'start': {
|
|
107
|
+
const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
108
|
+
console.log(`${r.state}${r.pid ? ` pid=${r.pid}` : ''}${r.detail ? ` — ${r.detail}` : ''}: ${a}`)
|
|
109
|
+
return r.state === 'failed' ? 1 : 0
|
|
110
|
+
}
|
|
111
|
+
case 'attach':
|
|
112
|
+
await runSupervisorClient(runDir, a!)
|
|
113
|
+
return 0
|
|
114
|
+
case 'up': {
|
|
115
|
+
const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
116
|
+
if (r.state === 'failed') {
|
|
117
|
+
console.error(r.detail)
|
|
118
|
+
return 1
|
|
119
|
+
}
|
|
120
|
+
await runSupervisorClient(runDir, a!)
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
case 'list':
|
|
124
|
+
for (const s of listSessions(runDir)) console.log(`${s.session}\t${s.alive ? `running pid=${s.pid}` : 'dead'}`)
|
|
125
|
+
return 0
|
|
126
|
+
case 'kill':
|
|
127
|
+
console.log(killSession(runDir, a!) ? `killed "${a}"` : `no session "${a}"`)
|
|
128
|
+
return 0
|
|
129
|
+
default:
|
|
130
|
+
console.log('usage: supervisor up|start|attach|list|kill <session> [runtime] [--run-dir <dir>] (detach: Ctrl-])')
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (import.meta.main) {
|
|
136
|
+
process.exit(await runSupervisorCli(process.argv.slice(2)))
|
|
137
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Supervisor session paths — under a configurable run dir (NOT hardcoded). Prod resolves it to
|
|
2
|
+
// ~/.iapeer/state/supervisor via the storage layer; tests pass a temp dir, so the daemon's
|
|
3
|
+
// socket/pid files never touch a real location.
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { pluginStateDir } from '../storage/index.ts'
|
|
6
|
+
|
|
7
|
+
/** Default run dir for the supervisor's session sockets/pids: ~/.iapeer/state/supervisor. */
|
|
8
|
+
export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
9
|
+
return pluginStateDir('supervisor', { env })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
|
|
13
|
+
export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
|
|
14
|
+
export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
BackpressureWriter,
|
|
4
|
+
FRAME_DATA,
|
|
5
|
+
FRAME_RESIZE,
|
|
6
|
+
TERM_RESET,
|
|
7
|
+
capabilityResponses,
|
|
8
|
+
frame,
|
|
9
|
+
makeFramer,
|
|
10
|
+
parseSize,
|
|
11
|
+
sizePayload,
|
|
12
|
+
stripQueries,
|
|
13
|
+
} from './protocol.ts'
|
|
14
|
+
|
|
15
|
+
describe('frame protocol', () => {
|
|
16
|
+
test('frame() lays out [type][len:u32be][payload]', () => {
|
|
17
|
+
const f = frame(FRAME_DATA, 'hi')
|
|
18
|
+
expect(f[0]).toBe(FRAME_DATA)
|
|
19
|
+
expect(f.readUInt32BE(1)).toBe(2)
|
|
20
|
+
expect(f.subarray(5).toString()).toBe('hi')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('makeFramer reassembles a frame split across chunk boundaries', () => {
|
|
24
|
+
const got: Array<{ t: number; p: string }> = []
|
|
25
|
+
const fr = makeFramer((t, p) => got.push({ t, p: p.toString() }))
|
|
26
|
+
const f = frame(FRAME_DATA, 'hello world')
|
|
27
|
+
fr(f.subarray(0, 3)) // partial header
|
|
28
|
+
fr(f.subarray(3, 8)) // rest of header + part of payload
|
|
29
|
+
expect(got).toHaveLength(0) // nothing complete yet
|
|
30
|
+
fr(f.subarray(8)) // tail
|
|
31
|
+
expect(got).toEqual([{ t: FRAME_DATA, p: 'hello world' }])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('makeFramer splits multiple coalesced frames', () => {
|
|
35
|
+
const got: string[] = []
|
|
36
|
+
const fr = makeFramer((_t, p) => got.push(p.toString()))
|
|
37
|
+
fr(Buffer.concat([frame(FRAME_DATA, 'a'), frame(FRAME_DATA, 'bb'), frame(FRAME_DATA, 'ccc')]))
|
|
38
|
+
expect(got).toEqual(['a', 'bb', 'ccc'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('sizePayload/parseSize round-trip', () => {
|
|
42
|
+
expect(parseSize(sizePayload(132, 50))).toEqual({ cols: 132, rows: 50 })
|
|
43
|
+
expect(frame(FRAME_RESIZE, sizePayload(80, 24))[0]).toBe(FRAME_RESIZE)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('capability responder (the queries @xterm does not auto-answer)', () => {
|
|
48
|
+
test('secondary-DA-style XTVERSION query → version report', () => {
|
|
49
|
+
expect(capabilityResponses('\x1b[>0q')).toEqual(['\x1bP>|iapeer-pts(0.1)\x1b\\'])
|
|
50
|
+
})
|
|
51
|
+
test('OSC-11 theme query → an rgb background report', () => {
|
|
52
|
+
expect(capabilityResponses('\x1b]11;?\x07')[0]).toContain('\x1b]11;rgb:')
|
|
53
|
+
})
|
|
54
|
+
test('secondary DA (>c) → a DA2 report', () => {
|
|
55
|
+
expect(capabilityResponses('\x1b[>0c')).toEqual(['\x1b[>0;276;0c'])
|
|
56
|
+
})
|
|
57
|
+
test('plain output → no responses', () => {
|
|
58
|
+
expect(capabilityResponses('just some text\r\n')).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
test('stripQueries removes DA1/DSR/theme but keeps real output', () => {
|
|
61
|
+
const s = 'before\x1b[0c\x1b[6nmid\x1b]11;?\x07after'
|
|
62
|
+
const out = stripQueries(s)
|
|
63
|
+
expect(out).toBe('beforemidafter')
|
|
64
|
+
expect(out).not.toContain('\x1b[6n')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('TERM_RESET (port-dep #1: clean every client exit path)', () => {
|
|
69
|
+
test('resets the leak-prone app-modes', () => {
|
|
70
|
+
expect(TERM_RESET).toContain('\x1b[?1049l') // leave alt-screen
|
|
71
|
+
expect(TERM_RESET).toContain('\x1b[?1000l') // mouse tracking off
|
|
72
|
+
expect(TERM_RESET).toContain('\x1b[?25h') // show cursor
|
|
73
|
+
expect(TERM_RESET).toContain('\x1b[<u') // pop kitty-keyboard
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('BackpressureWriter (port-dep #2: the ~90 KB-over-8 KB truncation that staled reattach)', () => {
|
|
78
|
+
test('a fully-accepted write leaves nothing queued', () => {
|
|
79
|
+
const w = new BackpressureWriter(b => b.length)
|
|
80
|
+
expect(w.send(Buffer.alloc(100))).toBe(true)
|
|
81
|
+
expect(w.queuedBytes).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('a heavy frame past the socket buffer is QUEUED, then fully delivered across drains', () => {
|
|
85
|
+
const CAP = 8192
|
|
86
|
+
let delivered = 0
|
|
87
|
+
const w = new BackpressureWriter(b => {
|
|
88
|
+
const n = Math.min(CAP, b.length)
|
|
89
|
+
delivered += n
|
|
90
|
+
return n
|
|
91
|
+
})
|
|
92
|
+
const heavy = Buffer.alloc(89305, 0x41) // the measured heavy-claude serialize size
|
|
93
|
+
expect(w.send(heavy)).toBe(true)
|
|
94
|
+
expect(delivered).toBe(CAP) // first write took only the socket buffer
|
|
95
|
+
expect(w.queuedBytes).toBe(89305 - CAP) // the rest is queued, NOT dropped
|
|
96
|
+
let guard = 0
|
|
97
|
+
while (w.queuedBytes > 0 && guard++ < 1000) w.flush() // drain events
|
|
98
|
+
expect(delivered).toBe(89305) // every byte eventually delivered (no truncation)
|
|
99
|
+
expect(w.queuedBytes).toBe(0)
|
|
100
|
+
expect(w.isDead).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('coalesces a new send onto the pending tail (ordering preserved)', () => {
|
|
104
|
+
const CAP = 4
|
|
105
|
+
const chunks: string[] = []
|
|
106
|
+
const w = new BackpressureWriter(b => {
|
|
107
|
+
const n = Math.min(CAP, b.length)
|
|
108
|
+
chunks.push(b.subarray(0, n).toString())
|
|
109
|
+
return n
|
|
110
|
+
})
|
|
111
|
+
w.send(Buffer.from('ABCDEF')) // 'ABCD' written, 'EF' queued
|
|
112
|
+
w.send(Buffer.from('GH')) // coalesced onto 'EF' → 'EFGH'
|
|
113
|
+
let guard = 0
|
|
114
|
+
while (w.queuedBytes > 0 && guard++ < 100) w.flush()
|
|
115
|
+
expect(chunks.join('')).toBe('ABCDEFGH')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('a dead socket (write throws) → send returns false and the writer is dead', () => {
|
|
119
|
+
const w = new BackpressureWriter(() => {
|
|
120
|
+
throw new Error('EPIPE')
|
|
121
|
+
})
|
|
122
|
+
expect(w.send(Buffer.alloc(10))).toBe(false)
|
|
123
|
+
expect(w.isDead).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a negative write count → dead', () => {
|
|
127
|
+
const w = new BackpressureWriter(() => -1)
|
|
128
|
+
expect(w.send(Buffer.alloc(10))).toBe(false)
|
|
129
|
+
expect(w.isDead).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Supervisor wire/terminal PRIMITIVES — the pure, hermetically-testable core of the
|
|
2
|
+
// detach-persistent pty-supervisor (cutover Block 2, ported from the validated PoC pts.mjs).
|
|
3
|
+
//
|
|
4
|
+
// PURE: no Bun, no @xterm, no socket — frames, the capability responder, the backpressure
|
|
5
|
+
// writer (port-dep #2), and the terminal-reset string (port-dep #1) are all data-in/data-out,
|
|
6
|
+
// so the two reattach port-deps that bit the PoC are unit-provable without spawning anything.
|
|
7
|
+
|
|
8
|
+
const EMPTY = Buffer.alloc(0)
|
|
9
|
+
|
|
10
|
+
// ── client/daemon frame protocol: [type:u8][len:u32be][payload] ──
|
|
11
|
+
export const FRAME_DATA = 0
|
|
12
|
+
export const FRAME_RESIZE = 1
|
|
13
|
+
|
|
14
|
+
export function frame(type: number, payload: Buffer | string): Buffer {
|
|
15
|
+
const p = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
|
|
16
|
+
const h = Buffer.alloc(5)
|
|
17
|
+
h[0] = type
|
|
18
|
+
h.writeUInt32BE(p.length, 1)
|
|
19
|
+
return Buffer.concat([h, p])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Streaming frame de-chunker: reassembles frames across arbitrary TCP/unix chunk boundaries
|
|
23
|
+
* (a frame may arrive split, or several may arrive coalesced). Calls onFrame per complete frame. */
|
|
24
|
+
export function makeFramer(onFrame: (type: number, payload: Buffer) => void): (chunk: Uint8Array | Buffer) => void {
|
|
25
|
+
let buf: Buffer = EMPTY
|
|
26
|
+
return chunk => {
|
|
27
|
+
buf = buf.length ? Buffer.concat([buf, Buffer.from(chunk)]) : Buffer.from(chunk)
|
|
28
|
+
while (buf.length >= 5) {
|
|
29
|
+
const type = buf[0]!
|
|
30
|
+
const len = buf.readUInt32BE(1)
|
|
31
|
+
if (buf.length < 5 + len) break
|
|
32
|
+
onFrame(type, buf.subarray(5, 5 + len))
|
|
33
|
+
buf = buf.subarray(5 + len)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sizePayload(cols: number, rows: number): Buffer {
|
|
39
|
+
const b = Buffer.alloc(4)
|
|
40
|
+
b.writeUInt16BE(cols || 80, 0)
|
|
41
|
+
b.writeUInt16BE(rows || 24, 2)
|
|
42
|
+
return b
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseSize(payload: Buffer): { cols: number; rows: number } {
|
|
46
|
+
return { cols: payload.readUInt16BE(0), rows: payload.readUInt16BE(2) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── capability responder ──────────────────────────────────────────────────────
|
|
50
|
+
// The child (claude/codex TUI) emits terminal capability QUERIES (device attributes, cursor
|
|
51
|
+
// position, theme) and BLOCKS its first paint until they are answered. tmux answers them; the
|
|
52
|
+
// supervisor daemon must too. @xterm/headless auto-answers DA1/DSR via onData; these are the
|
|
53
|
+
// ones it does NOT: secondary/tertiary DA (`>q`, `>c`) and the OSC-11 theme query.
|
|
54
|
+
|
|
55
|
+
export const QUERY_RE = /\x1b\[0?c|\x1b\[>\d*[qc]|\x1b\[6n|\x1b\]11;\?(?:\x07|\x1b\\)/g
|
|
56
|
+
|
|
57
|
+
/** Supplementary capability answers to write back INTO the child for queries @xterm does not
|
|
58
|
+
* auto-answer. Returns the response strings (possibly empty). Pure — testable without a pty. */
|
|
59
|
+
export function capabilityResponses(s: string): string[] {
|
|
60
|
+
const out: string[] = []
|
|
61
|
+
if (/\x1b\[>\d*q/.test(s)) out.push('\x1bP>|iapeer-pts(0.1)\x1b\\') // XTVERSION (DA2-style)
|
|
62
|
+
if (/\x1b\]11;\?(\x07|\x1b\\)/.test(s)) out.push('\x1b]11;rgb:1e1e/1e1e/1e1e\x07') // OSC-11 theme
|
|
63
|
+
if (/\x1b\[>\d*c/.test(s)) out.push('\x1b[>0;276;0c') // secondary DA
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Strip capability queries from a child-output chunk before forwarding to clients (the daemon
|
|
68
|
+
* is the authoritative responder — clients must not see/answer the queries themselves). */
|
|
69
|
+
export function stripQueries(s: string): string {
|
|
70
|
+
return s.replace(QUERY_RE, '')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── port-dep #1: terminal reset on every client exit path ───────────────────────
|
|
74
|
+
// The child sets a STACK of app-modes on the live terminal (alt-screen, bracketed-paste,
|
|
75
|
+
// focus-events, theme-update, ALL mouse tracking, kitty-keyboard, scroll-region, hidden cursor).
|
|
76
|
+
// They persist in the window after the client goes. A SIGHUP/window-close client that ran NOTHING
|
|
77
|
+
// leaked them → broke the NEXT reattach (mouse tracking eats the scroll wheel, kitty garbles keys,
|
|
78
|
+
// leftover modes freeze the render). Reset to a clean base on EVERY exit path; the next attach's
|
|
79
|
+
// repaint + the child's live re-render re-assert whatever the session actually needs.
|
|
80
|
+
export const TERM_RESET =
|
|
81
|
+
'\x1b[?1049l' + // leave alt-screen (normal buffer)
|
|
82
|
+
'\x1b[?2004l' + // bracketed-paste off
|
|
83
|
+
'\x1b[?1004l' + // focus-event reporting off
|
|
84
|
+
'\x1b[?2031l' + // theme/color-scheme-update off
|
|
85
|
+
'\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l' + // ALL mouse tracking off
|
|
86
|
+
'\x1b[<u' + // pop kitty-keyboard flags
|
|
87
|
+
'\x1b[r' + // reset scroll region (DECSTBM) to full
|
|
88
|
+
'\x1b[?25h' + // show cursor
|
|
89
|
+
'\x1b[?7h' // wraparound on (default)
|
|
90
|
+
|
|
91
|
+
// ── port-dep #2: backpressure-safe socket writer ────────────────────────────────
|
|
92
|
+
// Bun's sock.write() of a large buffer accepts only the socket buffer (~8 KB) and RETURNS the
|
|
93
|
+
// count actually taken — the rest is DROPPED unless queued and re-sent on `drain`. The PoC's old
|
|
94
|
+
// code ignored the return, so a heavy-claude repaint frame (~90 KB serialize) arrived TRUNCATED
|
|
95
|
+
// (measured live: len=89305, only 8192 delivered) → the client's framer waited forever for a
|
|
96
|
+
// frame that never completed → reattach showed the STALE close-moment frame. This writer queues
|
|
97
|
+
// the unwritten tail and flushes it on drain, for BOTH the repaint snapshot and live forward.
|
|
98
|
+
|
|
99
|
+
export interface SocketWrite {
|
|
100
|
+
/** Write up to buf.length bytes; return the count ACCEPTED (Bun semantics), or <0 on a dead socket. */
|
|
101
|
+
(buf: Buffer): number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Per-socket backpressure queue. send() coalesces+writes and queues the unaccepted tail;
|
|
105
|
+
* flush() (call on the socket `drain` event) drains the queue. Dead once write throws / returns <0. */
|
|
106
|
+
export class BackpressureWriter {
|
|
107
|
+
private pending: Buffer = EMPTY
|
|
108
|
+
private dead = false
|
|
109
|
+
constructor(private readonly write: SocketWrite) {}
|
|
110
|
+
|
|
111
|
+
/** Queue and send `buf`. Returns false iff the socket is dead (caller should drop the client). */
|
|
112
|
+
send(buf: Buffer): boolean {
|
|
113
|
+
if (this.dead) return false
|
|
114
|
+
const data = this.pending.length ? Buffer.concat([this.pending, buf]) : buf
|
|
115
|
+
let n: number
|
|
116
|
+
try {
|
|
117
|
+
n = this.write(data)
|
|
118
|
+
} catch {
|
|
119
|
+
this.dead = true
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
if (n < 0) {
|
|
123
|
+
this.dead = true
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
this.pending = n < data.length ? data.subarray(n) : EMPTY
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Flush the queued tail (call on `drain`). Returns false iff the socket died flushing. */
|
|
131
|
+
flush(): boolean {
|
|
132
|
+
if (this.dead) return false
|
|
133
|
+
if (!this.pending.length) return true
|
|
134
|
+
let n: number
|
|
135
|
+
try {
|
|
136
|
+
n = this.write(this.pending)
|
|
137
|
+
} catch {
|
|
138
|
+
this.dead = true
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
if (n < 0) {
|
|
142
|
+
this.dead = true
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
this.pending = n < this.pending.length ? this.pending.subarray(n) : EMPTY
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get isDead(): boolean {
|
|
150
|
+
return this.dead
|
|
151
|
+
}
|
|
152
|
+
get queuedBytes(): number {
|
|
153
|
+
return this.pending.length
|
|
154
|
+
}
|
|
155
|
+
}
|