@agfpd/iapeer 0.4.7 → 0.4.9
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/cli/index.ts +13 -80
- package/src/cli/listTui.ts +4 -14
- package/src/launch/cmdlog.ts +7 -110
- package/src/launch/index.ts +14 -314
- package/src/launch/launchdRun.ts +9 -52
- package/src/launch/ptyHost.ts +0 -12
- package/src/launch/readyGateModel.ts +9 -80
- package/src/lifecycle/index.ts +25 -101
- package/src/supervisor/daemon.ts +35 -4
- package/src/{shadow → supervisor}/render.ts +10 -10
- package/src/transport/index.ts +41 -400
- package/src/update/index.ts +0 -9
- package/src/launch/canary.ts +0 -239
- package/src/shadow/gate.ts +0 -396
- package/src/shadow/index.ts +0 -174
- package/src/shadow/install.ts +0 -256
package/src/transport/index.ts
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
|
|
17
17
|
import { join } from 'path'
|
|
18
18
|
import { homedir } from 'os'
|
|
19
|
-
import { spawnSync } from 'child_process'
|
|
20
19
|
import {
|
|
21
20
|
MAX_ATTACHMENTS,
|
|
22
21
|
MAX_MESSAGE_LEN,
|
|
@@ -31,8 +30,6 @@ import { err, ok, type Result } from '../core/errors.ts'
|
|
|
31
30
|
import {
|
|
32
31
|
buildProcessAddress,
|
|
33
32
|
buildSocketPath,
|
|
34
|
-
parseSessionName,
|
|
35
|
-
parseSocketPath,
|
|
36
33
|
type ProcessAddress,
|
|
37
34
|
} from '../core/socket.ts'
|
|
38
35
|
import { buildEnvelope } from '../codec/index.ts'
|
|
@@ -42,7 +39,7 @@ import type { ResolvedCaller } from '../identity/index.ts'
|
|
|
42
39
|
// reads them from getAdapter(target.runtime) for the tui submit path. One-way
|
|
43
40
|
// dependency: launch does NOT import transport, so no cycle.
|
|
44
41
|
import { getAdapter } from '../launch/index.ts'
|
|
45
|
-
import type { ControlCommand
|
|
42
|
+
import type { ControlCommand } from '../launch/types.ts'
|
|
46
43
|
// Spawn-flip Ф0b-2: warm-deliver is HOST-AWARE. hostSessionAlive (runtime-state host detect, an
|
|
47
44
|
// @xterm-free pid-file check) + deliverHosted (the pure-protocol socket leaf, Ф0a) come from
|
|
48
45
|
// launch/ptyHost — already in transport's module graph via getAdapter above and @xterm-free by
|
|
@@ -70,32 +67,6 @@ export interface DeliveryTarget extends ProcessAddress {
|
|
|
70
67
|
socketPath: string
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
export interface TmuxResult {
|
|
74
|
-
ok: boolean
|
|
75
|
-
out: string
|
|
76
|
-
err: string
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function tmux(sock: string, ...args: string[]): TmuxResult {
|
|
80
|
-
const r = spawnSync('tmux', ['-S', sock, ...args], { encoding: 'utf8' })
|
|
81
|
-
return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Settle delay between bracketed paste and the submit Enter — non-TUI path only.
|
|
85
|
-
const PASTE_SETTLE_MS = (() => {
|
|
86
|
-
const raw = process.env.IAP_TMUX_PASTE_SETTLE_MS
|
|
87
|
-
const n = raw === undefined ? NaN : Number(raw)
|
|
88
|
-
return Number.isFinite(n) && n >= 0 ? n : 250
|
|
89
|
-
})()
|
|
90
|
-
|
|
91
|
-
const SUBMIT_POLL_MS = 40
|
|
92
|
-
const SUBMIT_LANDED_TIMEOUT_MS = 1500
|
|
93
|
-
const SUBMIT_CONFIRM_WINDOW_MS = 500
|
|
94
|
-
function submitTotalTimeoutMs(): number {
|
|
95
|
-
const raw = process.env.IAP_TMUX_SUBMIT_TIMEOUT_MS
|
|
96
|
-
const n = raw === undefined ? NaN : Number(raw)
|
|
97
|
-
return Number.isFinite(n) && n >= 0 ? n : 4000
|
|
98
|
-
}
|
|
99
70
|
|
|
100
71
|
function sleepSync(ms: number): void {
|
|
101
72
|
if (ms <= 0) return
|
|
@@ -106,174 +77,10 @@ function monotonicMs(): number {
|
|
|
106
77
|
return Number(process.hrtime.bigint() / 1_000_000n)
|
|
107
78
|
}
|
|
108
79
|
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function sessionAlive(sock: string, address: string): boolean {
|
|
116
|
-
// SPAWN-FLIP Ф0b-3: a supervisor-HOSTED session has no tmux entry — its liveness is the supervisor
|
|
117
|
-
// daemon (runtime-state), checked FIRST (a cheap pid-file). Non-hosted → the tmux session list, as
|
|
118
|
-
// before (byte-identical). Without this, resolveDeliveryTarget/isPeerLive see a live hosted peer as
|
|
119
|
-
// OFFLINE → routeSend mis-routes a warm hit to a wake and the post-wake verify-before-act false-fails
|
|
120
|
-
// (the canary symptom: "woke but not live" though delivery actually landed). `address` IS the identity.
|
|
121
|
-
return hostSessionAlive(address) || listSessions(sock).includes(address)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ─── Busy-composer detection (human input vs runtime ghost text) ─────────────
|
|
125
|
-
|
|
126
|
-
interface SgrState {
|
|
127
|
-
faint: boolean
|
|
128
|
-
fg256: number | null
|
|
129
|
-
fgBasic: number | null
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
interface GhostSgrConfig {
|
|
133
|
-
faint: boolean
|
|
134
|
-
fg256: Set<number>
|
|
135
|
-
fgBasic: Set<number>
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
interface PaneToken {
|
|
139
|
-
ch: string
|
|
140
|
-
ghost: boolean
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function parseSgrCodeList(raw: string): number[] | null {
|
|
144
|
-
if (raw.trim() === '') return [0]
|
|
145
|
-
const out = raw.split(';').map(part => (part === '' ? 0 : Number(part)))
|
|
146
|
-
return out.every(Number.isFinite) ? out : null
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function ghostSgrConfig(markers: DeliveryMarkers): GhostSgrConfig {
|
|
150
|
-
const cfg: GhostSgrConfig = { faint: false, fg256: new Set(), fgBasic: new Set() }
|
|
151
|
-
for (const raw of markers.ghostTextSgr ?? []) {
|
|
152
|
-
const codes = parseSgrCodeList(raw)
|
|
153
|
-
if (!codes) continue
|
|
154
|
-
if (codes.length === 1 && codes[0] === 2) {
|
|
155
|
-
cfg.faint = true
|
|
156
|
-
continue
|
|
157
|
-
}
|
|
158
|
-
if (codes.length === 1 && ((codes[0] >= 30 && codes[0] <= 37) || (codes[0] >= 90 && codes[0] <= 97))) {
|
|
159
|
-
cfg.fgBasic.add(codes[0])
|
|
160
|
-
continue
|
|
161
|
-
}
|
|
162
|
-
if (codes.length === 3 && codes[0] === 38 && codes[1] === 5) cfg.fg256.add(codes[2])
|
|
163
|
-
}
|
|
164
|
-
return cfg
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function applySgr(state: SgrState, codes: number[]): void {
|
|
168
|
-
let i = 0
|
|
169
|
-
while (i < codes.length) {
|
|
170
|
-
const code = codes[i]
|
|
171
|
-
if (code === 0) {
|
|
172
|
-
state.faint = false
|
|
173
|
-
state.fg256 = null
|
|
174
|
-
state.fgBasic = null
|
|
175
|
-
} else if (code === 2) {
|
|
176
|
-
state.faint = true
|
|
177
|
-
} else if (code === 22) {
|
|
178
|
-
state.faint = false
|
|
179
|
-
} else if (code === 39) {
|
|
180
|
-
state.fg256 = null
|
|
181
|
-
state.fgBasic = null
|
|
182
|
-
} else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
|
183
|
-
state.fgBasic = code
|
|
184
|
-
state.fg256 = null
|
|
185
|
-
} else if (code === 38 && codes[i + 1] === 5 && Number.isFinite(codes[i + 2])) {
|
|
186
|
-
state.fg256 = codes[i + 2]
|
|
187
|
-
state.fgBasic = null
|
|
188
|
-
i += 2
|
|
189
|
-
} else if (code === 38 && codes[i + 1] === 2) {
|
|
190
|
-
// Truecolor foreground: not one of the calibrated grey-256 markers. Track
|
|
191
|
-
// it as "not ghost" by clearing the 256/basic foreground slots.
|
|
192
|
-
state.fg256 = null
|
|
193
|
-
state.fgBasic = null
|
|
194
|
-
i += 4
|
|
195
|
-
}
|
|
196
|
-
i += 1
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function stateIsGhost(state: SgrState, cfg: GhostSgrConfig): boolean {
|
|
201
|
-
if (cfg.faint && state.faint) return true
|
|
202
|
-
if (state.fg256 !== null && cfg.fg256.has(state.fg256)) return true
|
|
203
|
-
if (state.fgBasic !== null && cfg.fgBasic.has(state.fgBasic)) return true
|
|
204
|
-
return false
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function parseAnsiLine(line: string, cfg: GhostSgrConfig): PaneToken[] {
|
|
208
|
-
const tokens: PaneToken[] = []
|
|
209
|
-
const state: SgrState = { faint: false, fg256: null, fgBasic: null }
|
|
210
|
-
let i = 0
|
|
211
|
-
while (i < line.length) {
|
|
212
|
-
if (line.charCodeAt(i) === 0x1b && line[i + 1] === '[') {
|
|
213
|
-
let end = i + 2
|
|
214
|
-
while (end < line.length && !/[A-Za-z]/.test(line[end])) end += 1
|
|
215
|
-
if (end < line.length) {
|
|
216
|
-
if (line[end] === 'm') {
|
|
217
|
-
const codes = parseSgrCodeList(line.slice(i + 2, end))
|
|
218
|
-
if (codes) applySgr(state, codes)
|
|
219
|
-
}
|
|
220
|
-
i = end + 1
|
|
221
|
-
continue
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
const ch = Array.from(line.slice(i))[0] ?? ''
|
|
225
|
-
if (!ch) break
|
|
226
|
-
tokens.push({ ch, ghost: stateIsGhost(state, cfg) })
|
|
227
|
-
i += ch.length
|
|
228
|
-
}
|
|
229
|
-
return tokens
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** True iff a captured TUI pane shows non-dim human text in the composer row.
|
|
233
|
-
* Grey/faint ghost suggestions and placeholders are intentionally NOT human
|
|
234
|
-
* input: holding IAP delivery behind them would be a false busy state. */
|
|
235
|
-
export function composerCaptureHasHumanInput(pane: string, markers: DeliveryMarkers): boolean {
|
|
236
|
-
if (markers.promptGlyphs.length === 0) return false
|
|
237
|
-
const cfg = ghostSgrConfig(markers)
|
|
238
|
-
let promptTokens: PaneToken[] | null = null
|
|
239
|
-
let promptIndex = -1
|
|
240
|
-
|
|
241
|
-
for (const line of pane.split(/\r?\n/)) {
|
|
242
|
-
const tokens = parseAnsiLine(line, cfg)
|
|
243
|
-
const firstVisible = tokens.findIndex(tok => tok.ch.trim() !== '')
|
|
244
|
-
if (firstVisible < 0) continue
|
|
245
|
-
if (!markers.promptGlyphs.includes(tokens[firstVisible].ch)) continue
|
|
246
|
-
promptTokens = tokens
|
|
247
|
-
promptIndex = firstVisible
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (!promptTokens || promptIndex < 0) return false
|
|
251
|
-
for (const tok of promptTokens.slice(promptIndex + 1)) {
|
|
252
|
-
if (tok.ch.trim() === '') continue
|
|
253
|
-
if (!tok.ghost) return true
|
|
254
|
-
}
|
|
255
|
-
return false
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export function hasAttachedTmuxClient(target: DeliveryTarget): boolean {
|
|
259
|
-
const r = tmux(target.socketPath, 'list-clients', '-F', '#{client_session}')
|
|
260
|
-
if (!r.ok) return false
|
|
261
|
-
return r.out.split(/\r?\n/).map(line => line.trim()).includes(target.address)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function targetComposerBlocksDelivery(target: DeliveryTarget): boolean {
|
|
265
|
-
if (!isSupportedLocalRuntime(target.runtime)) return false
|
|
266
|
-
const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
|
|
267
|
-
// With a live resolved target, a capture failure is an uncertainty in the
|
|
268
|
-
// dangerous direction: prefer a bounded false-busy queue/force over a false-free
|
|
269
|
-
// paste into a human composer. If the session is actually gone, the queue's
|
|
270
|
-
// session-token/identity check fails loudly.
|
|
271
|
-
if (!cap.ok) return true
|
|
272
|
-
return composerCaptureHasHumanInput(cap.out, getAdapter(target.runtime).deliveryMarkers)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export function shouldQueueForHumanComposer(target: DeliveryTarget): boolean {
|
|
276
|
-
return isSupportedLocalRuntime(target.runtime) && hasAttachedTmuxClient(target) && targetComposerBlocksDelivery(target)
|
|
80
|
+
function sessionAlive(_sock: string, address: string): boolean {
|
|
81
|
+
// pty-only: a peer's liveness is its supervisor daemon (a cheap pid-file check). `address` IS the
|
|
82
|
+
// identity. (`_sock` is retained for call-site compatibility; tmux sockets no longer exist.)
|
|
83
|
+
return hostSessionAlive(address)
|
|
277
84
|
}
|
|
278
85
|
|
|
279
86
|
/** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
|
|
@@ -282,35 +89,14 @@ export function isPeerLive(runtime: string, personality: string, sockDir = resol
|
|
|
282
89
|
return sessionAlive(socketPath, buildProcessAddress(runtime, personality))
|
|
283
90
|
}
|
|
284
91
|
|
|
285
|
-
export function listOnlinePeers(
|
|
92
|
+
export function listOnlinePeers(_sockDir = resolveSockDir()): OnlinePeer[] {
|
|
93
|
+
// pty-only: every live peer is a supervisor-hosted session (no tmux sockets to scan).
|
|
286
94
|
const out = new Map<string, OnlinePeer>()
|
|
287
|
-
let entries: string[] = []
|
|
288
|
-
try {
|
|
289
|
-
entries = readdirSync(sockDir)
|
|
290
|
-
} catch {
|
|
291
|
-
/* no tmux sock dir → only supervisor-hosted peers, merged below (never early-return past them) */
|
|
292
|
-
}
|
|
293
|
-
for (const entry of entries) {
|
|
294
|
-
const parsedSocket = parseSocketPath(join(sockDir, entry))
|
|
295
|
-
if (!parsedSocket) continue
|
|
296
|
-
for (const sessionName of listSessions(join(sockDir, entry))) {
|
|
297
|
-
const parsedSession = parseSessionName(sessionName)
|
|
298
|
-
if (!parsedSession) continue
|
|
299
|
-
if (parsedSession.address !== parsedSocket.address) continue
|
|
300
|
-
out.set(parsedSession.address, {
|
|
301
|
-
personality: parsedSession.personality,
|
|
302
|
-
runtime: parsedSession.runtime,
|
|
303
|
-
})
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
// SPAWN-FLIP Ф0b-3: supervisor-HOSTED peers have no tmux socket, so the scan above misses them —
|
|
307
|
-
// merge in the live hosted sessions so the online scan + the no-runtime resolve see them too.
|
|
308
95
|
for (const h of listHostedPeers()) {
|
|
309
96
|
out.set(buildProcessAddress(h.runtime, h.personality), { personality: h.personality, runtime: h.runtime })
|
|
310
97
|
}
|
|
311
98
|
return Array.from(out.values()).sort(
|
|
312
|
-
(a, b) =>
|
|
313
|
-
a.personality.localeCompare(b.personality) || a.runtime.localeCompare(b.runtime),
|
|
99
|
+
(a, b) => a.personality.localeCompare(b.personality) || a.runtime.localeCompare(b.runtime),
|
|
314
100
|
)
|
|
315
101
|
}
|
|
316
102
|
|
|
@@ -365,56 +151,6 @@ export function resolvePeerDeliveryTarget(
|
|
|
365
151
|
return resolveDeliveryTarget({ personality })
|
|
366
152
|
}
|
|
367
153
|
|
|
368
|
-
// ─── TUI submit (ported byte-for-byte from transport.ts) ────────────────────
|
|
369
|
-
|
|
370
|
-
function inputHoldsPaste(
|
|
371
|
-
sock: string,
|
|
372
|
-
address: string,
|
|
373
|
-
tailMarker: string,
|
|
374
|
-
markers: DeliveryMarkers,
|
|
375
|
-
): boolean {
|
|
376
|
-
const cap = tmux(sock, 'capture-pane', '-p', '-t', address)
|
|
377
|
-
if (!cap.ok) return true
|
|
378
|
-
const lines = cap.out.split('\n')
|
|
379
|
-
let promptRow = -1
|
|
380
|
-
for (let i = 0; i < lines.length; i++) {
|
|
381
|
-
const ch = lines[i]?.[0] ?? ''
|
|
382
|
-
if (markers.promptGlyphs.includes(ch)) promptRow = i
|
|
383
|
-
}
|
|
384
|
-
if (promptRow < 0) return true
|
|
385
|
-
const band = lines.slice(promptRow).join('\n')
|
|
386
|
-
if (band.includes(tailMarker)) return true
|
|
387
|
-
return markers.pastePatterns?.some(re => re.test(band)) ?? false
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Returns TRUE iff the input cleared after Enter within the budget — i.e. an
|
|
391
|
-
// (idle, responsive) TUI accepted the submit. FALSE on timeout (the input never
|
|
392
|
-
// cleared): either a DEAD/hung session, OR a BUSY session mid-turn whose input row
|
|
393
|
-
// is not even rendered (then liveness is confirmed by the transcript-mtime advance
|
|
394
|
-
// in deliverViaTmux, not here). The poll/re-press TIMING is the byte-for-byte 0.7.6
|
|
395
|
-
// port; only the return value is new (Ф-B: feed the delivery-liveness decision).
|
|
396
|
-
function submitIntoTui(sock: string, address: string, envelope: string, markers: DeliveryMarkers): boolean {
|
|
397
|
-
const tailMarker =
|
|
398
|
-
envelope.split('\n').map(line => line.trim()).filter(Boolean).pop() ?? envelope.trim()
|
|
399
|
-
const start = monotonicMs()
|
|
400
|
-
while (monotonicMs() - start < SUBMIT_LANDED_TIMEOUT_MS) {
|
|
401
|
-
if (inputHoldsPaste(sock, address, tailMarker, markers)) break
|
|
402
|
-
sleepSync(SUBMIT_POLL_MS)
|
|
403
|
-
}
|
|
404
|
-
while (monotonicMs() - start < submitTotalTimeoutMs()) {
|
|
405
|
-
tmux(sock, 'send-keys', '-t', address, 'Enter')
|
|
406
|
-
const windowStart = monotonicMs()
|
|
407
|
-
while (
|
|
408
|
-
monotonicMs() - windowStart < SUBMIT_CONFIRM_WINDOW_MS &&
|
|
409
|
-
monotonicMs() - start < submitTotalTimeoutMs()
|
|
410
|
-
) {
|
|
411
|
-
sleepSync(SUBMIT_POLL_MS)
|
|
412
|
-
if (!inputHoldsPaste(sock, address, tailMarker, markers)) return true
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return false
|
|
416
|
-
}
|
|
417
|
-
|
|
418
154
|
// Ф-B liveness: how long to keep polling the activity proxy for a transcript
|
|
419
155
|
// advance when the submit did NOT clear the input (a busy session whose input row
|
|
420
156
|
// is not rendered). Env-tunable for LIVE calibration (busy/idle/cold). Conservative
|
|
@@ -439,78 +175,12 @@ function hostLivenessGraceMs(): number {
|
|
|
439
175
|
return Number.isFinite(n) && n >= 0 ? n : livenessGraceMs()
|
|
440
176
|
}
|
|
441
177
|
|
|
442
|
-
|
|
443
|
-
* Deliver `envelope` into the target's tmux session and CONFIRM it landed in a LIVE
|
|
444
|
-
* session (Ф-B delivery guarantee, contract Демон §Гарантия доставки). `cwd` (the
|
|
445
|
-
* target peer's working dir) enables the transcript-mtime liveness probe; omit it and
|
|
446
|
-
* the tui path falls back to submit-confirmation only (existing direct callers/tests).
|
|
447
|
-
*
|
|
448
|
-
* tui liveness — ok ⟺ message landed in a live session, by EITHER strong signal:
|
|
449
|
-
* (a) submit confirmed — the input cleared after Enter (an idle session accepted it);
|
|
450
|
-
* (b) transcript-mtime advanced past the pre-submit baseline (a BUSY session is
|
|
451
|
-
* mid-turn — our message is queued and surfaces on its next tool call).
|
|
452
|
-
* Neither within the grace budget → the session is listed live but UNRESPONSIVE
|
|
453
|
-
* (dead/hung) → fail LOUDLY. Silent loss is forbidden; when in doubt prefer a false-
|
|
454
|
-
* FAIL (sender retries) over a false-OK. router (telegram/notifier) is always-on by
|
|
455
|
-
* launchd → its liveness is structural, the C-j path keeps its prior semantics.
|
|
456
|
-
*/
|
|
457
|
-
export function deliverViaTmux(target: DeliveryTarget, envelope: string, cwd?: string): Result<void> {
|
|
458
|
-
const bufferName = `iap-${process.pid}-${Date.now()}`
|
|
459
|
-
const load = spawnSync(
|
|
460
|
-
'tmux',
|
|
461
|
-
['-S', target.socketPath, 'load-buffer', '-b', bufferName, '-'],
|
|
462
|
-
{ input: envelope, encoding: 'utf8' },
|
|
463
|
-
)
|
|
464
|
-
if (load.status !== 0) {
|
|
465
|
-
return err(`tmux load-buffer failed: ${(load.stderr ?? '').trim() || `exit ${load.status}`}`)
|
|
466
|
-
}
|
|
467
|
-
const isTui = isSupportedLocalRuntime(target.runtime)
|
|
468
|
-
const pasteArgs = isTui
|
|
469
|
-
? ['paste-buffer', '-p', '-b', bufferName, '-t', target.address]
|
|
470
|
-
: ['paste-buffer', '-p', '-r', '-b', bufferName, '-t', target.address]
|
|
471
|
-
const paste = tmux(target.socketPath, ...pasteArgs)
|
|
472
|
-
if (!paste.ok) {
|
|
473
|
-
tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
|
|
474
|
-
return err(`tmux paste-buffer failed: ${paste.err.trim() || 'unknown error'}`)
|
|
475
|
-
}
|
|
476
|
-
if (isTui) {
|
|
477
|
-
const adapter = getAdapter(target.runtime)
|
|
478
|
-
// Baseline the activity proxy BEFORE the submit (a paste does not move the
|
|
479
|
-
// transcript — only a model turn does), so an advance afterwards proves a busy
|
|
480
|
-
// session is alive. null proxy / no cwd → 0 (the advance check is skipped below).
|
|
481
|
-
const baselineMtime = cwd ? adapter.newestActivityMtime(cwd) ?? 0 : 0
|
|
482
|
-
// Submit markers come from the target runtime's adapter.
|
|
483
|
-
const confirmed = submitIntoTui(target.socketPath, target.address, envelope, adapter.deliveryMarkers)
|
|
484
|
-
tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
|
|
485
|
-
if (confirmed) return ok(undefined) // idle session accepted the submit
|
|
486
|
-
if (!cwd) return ok(undefined) // no activity proxy available → confirmed-only (legacy callers)
|
|
487
|
-
// Busy session: the input never cleared, so confirm liveness by a transcript
|
|
488
|
-
// advance. Grace-poll the proxy (the model may take a beat to write its turn).
|
|
489
|
-
const graceDeadline = monotonicMs() + livenessGraceMs()
|
|
490
|
-
do {
|
|
491
|
-
if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ok(undefined)
|
|
492
|
-
sleepSync(LIVENESS_POLL_MS)
|
|
493
|
-
} while (monotonicMs() < graceDeadline)
|
|
494
|
-
return err(
|
|
495
|
-
`peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
496
|
-
`(no input-clear, no transcript advance within ${livenessGraceMs()}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
497
|
-
)
|
|
498
|
-
}
|
|
499
|
-
sleepSync(PASTE_SETTLE_MS)
|
|
500
|
-
const enter = tmux(target.socketPath, 'send-keys', '-t', target.address, 'C-j')
|
|
501
|
-
tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
|
|
502
|
-
if (!enter.ok) return err(`tmux send-keys failed: ${enter.err.trim() || 'unknown error'}`)
|
|
503
|
-
return ok(undefined)
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// ─── Host-aware warm delivery (spawn-flip cutover Block 2, Ф0b-2) ────────────────────────────────
|
|
178
|
+
// ─── Warm delivery (pty-only — supervisor socket) ────────────────────────────────────────────────
|
|
507
179
|
|
|
508
180
|
/** Test seam for `deliverWarm`. Production defaults route on the real supervisor; tests inject a
|
|
509
|
-
* fake host-
|
|
510
|
-
*
|
|
181
|
+
* fake host-deliver + a temp transcript proxy, so the deliver path is covered with no live daemon.
|
|
182
|
+
* Omitting all fields = pure production behaviour. */
|
|
511
183
|
export interface WarmDeliverSeam {
|
|
512
|
-
/** Runtime-state host detector (NOT the `.pty-host` marker). Default: the real `hostSessionAlive`. */
|
|
513
|
-
hostAlive?: (identity: string) => boolean
|
|
514
184
|
/** Deliver to the hosted session over its socket. Default: the real `deliverHosted` (Ф0a leaf). */
|
|
515
185
|
deliverHosted?: (identity: string, envelope: string) => Promise<DeliverResult>
|
|
516
186
|
/** Activity proxy for the landed-confirm. Default: the target runtime adapter's transcript mtime. */
|
|
@@ -518,13 +188,11 @@ export interface WarmDeliverSeam {
|
|
|
518
188
|
}
|
|
519
189
|
|
|
520
190
|
/**
|
|
521
|
-
* Warm-deliver `envelope` to a LIVE local target
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* trusting the ack would re-open the silent-loss class the contract forbids). Flag-off (no live
|
|
527
|
-
* supervisor session) → `hostAlive` is a cheap false → `deliverViaTmux` verbatim (byte-identical).
|
|
191
|
+
* Warm-deliver `envelope` to a LIVE local target over its supervisor socket (pty-only). Landing is
|
|
192
|
+
* CONFIRMED by a transcript-mtime advance past the pre-deliver baseline, NOT the socket-ack (a flushed
|
|
193
|
+
* CR proves the bytes left us, NOT that the session accepted them; trusting the ack would re-open the
|
|
194
|
+
* silent-loss class the contract forbids). A router (telegram/notifier) has no transcript proxy → it is
|
|
195
|
+
* confirmed by the socket-ack (its liveness is structural via launchd KeepAlive). See `deliverViaHost`.
|
|
528
196
|
*/
|
|
529
197
|
export async function deliverWarm(
|
|
530
198
|
target: DeliveryTarget,
|
|
@@ -532,8 +200,6 @@ export async function deliverWarm(
|
|
|
532
200
|
cwd?: string,
|
|
533
201
|
seam: WarmDeliverSeam = {},
|
|
534
202
|
): Promise<Result<void>> {
|
|
535
|
-
const hostAlive = seam.hostAlive ?? hostSessionAlive
|
|
536
|
-
if (!hostAlive(target.address)) return deliverViaTmux(target, envelope, cwd)
|
|
537
203
|
return deliverViaHost(target, envelope, cwd, seam)
|
|
538
204
|
}
|
|
539
205
|
|
|
@@ -819,12 +485,10 @@ export function waitForCompactDone(
|
|
|
819
485
|
const pollMs = opts.pollMs ?? compactDonePollMs(env)
|
|
820
486
|
const graceMs = opts.graceMs ?? compactReadyGraceMs(env)
|
|
821
487
|
const aliveFn = opts.seam?.sessionAlive ?? ((t: DeliveryTarget) => sessionAlive(t.socketPath, t.address))
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
return cap.ok ? cap.out : null
|
|
827
|
-
})
|
|
488
|
+
// pty-only: the AUTHORITATIVE compact-done signal is the transcript marker (compactTranscriptHasDone).
|
|
489
|
+
// The pane capture was a tmux fast-path (idle-composer detect) + error context; with tmux gone the
|
|
490
|
+
// default is no-capture (the transcript marker remains the contract). Tests inject seam.capturePane.
|
|
491
|
+
const captureFn = opts.seam?.capturePane ?? ((_t: DeliveryTarget): string | null => null)
|
|
828
492
|
const adapter = getAdapter(target.runtime)
|
|
829
493
|
const started = monotonicMs()
|
|
830
494
|
const deadline = started + timeoutMs
|
|
@@ -880,16 +544,14 @@ export function waitForCompactDone(
|
|
|
880
544
|
|
|
881
545
|
/**
|
|
882
546
|
* Perform an in-session control command on a LIVE target (Ф-E, docs/Control-команды).
|
|
883
|
-
* The target's adapter maps the abstract command to a
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
* unknown name) → explicit refusal, not a silent no-op.
|
|
547
|
+
* The target's adapter maps the abstract command to a control sequence (ControlPlan); each step is
|
|
548
|
+
* sent IN ORDER, UNCONDITIONALLY — NO ready-gate, NO submit-confirm (the point of `interrupt` is to
|
|
549
|
+
* break a stuck/raving turn exactly when normal delivery would not land). An unsupported command
|
|
550
|
+
* (router runtimes, unknown name) → explicit refusal, not a silent no-op.
|
|
888
551
|
*/
|
|
889
|
-
/** Test seam for the
|
|
890
|
-
*
|
|
552
|
+
/** Test seam for the control path: inject the socket control-send so it is covered without a live
|
|
553
|
+
* supervisor. Omitting all = prod. */
|
|
891
554
|
export interface ControlHostSeam {
|
|
892
|
-
hostAlive?: (identity: string) => boolean
|
|
893
555
|
sendControl?: (runDir: string, session: string, chunks: Buffer[], opts: { stepDelayMs?: number }) => Promise<DeliverResult>
|
|
894
556
|
}
|
|
895
557
|
|
|
@@ -902,24 +564,14 @@ export async function executeControlOnTarget(
|
|
|
902
564
|
if (!plan) {
|
|
903
565
|
return err(`runtime "${target.runtime}" does not support control command "${command.name}"`)
|
|
904
566
|
}
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const sent = await send(hostRunDir(), target.address, chunks, { stepDelayMs: plan.stepDelayMs })
|
|
914
|
-
if (!sent.ok) {
|
|
915
|
-
return err(`hosted control "${command.name}" failed for "${target.personality}" (${target.runtime}): ${sent.error ?? 'unknown error'}`)
|
|
916
|
-
}
|
|
917
|
-
return ok(undefined)
|
|
918
|
-
}
|
|
919
|
-
for (const keys of plan.sequence) {
|
|
920
|
-
const r = tmux(target.socketPath, 'send-keys', '-t', target.address, ...keys)
|
|
921
|
-
if (!r.ok) return err(`tmux send-keys failed for control "${command.name}": ${r.err.trim() || 'unknown error'}`)
|
|
922
|
-
if (plan.stepDelayMs) sleepSync(plan.stepDelayMs)
|
|
567
|
+
// pty-only: translate each plan step to pty bytes with keysToBytes (REUSED from the boot-driver) and
|
|
568
|
+
// send them over the supervisor socket. UNCONDITIONAL semantics (no ready-gate, no submit-confirm) —
|
|
569
|
+
// the point of interrupt is to break a stuck turn.
|
|
570
|
+
const chunks = plan.sequence.map(keys => keysToBytes(keys))
|
|
571
|
+
const send = seam.sendControl ?? sendControlToHost
|
|
572
|
+
const sent = await send(hostRunDir(), target.address, chunks, { stepDelayMs: plan.stepDelayMs })
|
|
573
|
+
if (!sent.ok) {
|
|
574
|
+
return err(`hosted control "${command.name}" failed for "${target.personality}" (${target.runtime}): ${sent.error ?? 'unknown error'}`)
|
|
923
575
|
}
|
|
924
576
|
return ok(undefined)
|
|
925
577
|
}
|
|
@@ -1139,38 +791,27 @@ function delay(ms: number): Promise<void> {
|
|
|
1139
791
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
1140
792
|
}
|
|
1141
793
|
|
|
1142
|
-
|
|
1143
|
-
const r = tmux(target.socketPath, 'display-message', '-p', '-t', target.address, '#{session_id}:#{pane_id}')
|
|
1144
|
-
if (!r.ok) return null
|
|
1145
|
-
return r.out.trim() || null
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// ── Host-aware composer-queue predicates (spawn-flip Ф0b-3 slice 3c) ─────────────────────────────────
|
|
1149
|
-
// Each dispatches on hostSessionAlive(address): a HOSTED target uses the supervisor signals, any other
|
|
1150
|
-
// target falls through to the UNCHANGED tmux predicate (so the tmux fleet's observable queue behavior
|
|
1151
|
-
// is byte-identical — only the call becomes awaitable).
|
|
794
|
+
// ── Composer-queue predicates (pty-only) ─────────────────────────────────────────────────────────
|
|
1152
795
|
|
|
1153
|
-
/** Enqueue gate.
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
796
|
+
/** Enqueue gate. Hold ONLY when a HUMAN is attached (supervisor client) AND the composer model is
|
|
797
|
+
* occupied — the anti-bug: with no operator attached the composer text is the AI's own output, never
|
|
798
|
+
* held. No pane-log dir / unreadable model → don't hold (deliver, never stall). */
|
|
1156
799
|
async function shouldQueueForComposer(target: DeliveryTarget, logDir: string | undefined): Promise<boolean> {
|
|
1157
|
-
if (!hostSessionAlive(target.address)) return shouldQueueForHumanComposer(target)
|
|
1158
800
|
if (!isSupportedLocalRuntime(target.runtime) || !hasAttachedSupervisorClient(target.address)) return false
|
|
1159
801
|
if (!logDir) return false // no pane-log dir → cannot read occupancy → don't hold (deliver, never stall)
|
|
1160
802
|
return paneLogComposerOccupied(`${logDir}/${target.address}.log`, hostGeometry(target.address).cols, hostGeometry(target.address).rows, target.runtime)
|
|
1161
803
|
}
|
|
1162
804
|
|
|
1163
805
|
/** Drain re-check (no attached-client prereq — once queued, abandoned input waits until cleared/ceiling).
|
|
1164
|
-
*
|
|
806
|
+
* Pane-log model occupancy. */
|
|
1165
807
|
async function composerStillBusy(target: DeliveryTarget, logDir: string | undefined): Promise<boolean> {
|
|
1166
|
-
if (!hostSessionAlive(target.address)) return targetComposerBlocksDelivery(target)
|
|
1167
808
|
if (!logDir) return false
|
|
1168
809
|
return paneLogComposerOccupied(`${logDir}/${target.address}.log`, hostGeometry(target.address).cols, hostGeometry(target.address).rows, target.runtime)
|
|
1169
810
|
}
|
|
1170
811
|
|
|
1171
|
-
/** Session-replacement token
|
|
812
|
+
/** Session-replacement token: the supervisor daemon pid. */
|
|
1172
813
|
function composerSessionToken(target: DeliveryTarget): string | null {
|
|
1173
|
-
return
|
|
814
|
+
return hostSessionToken(target.address)
|
|
1174
815
|
}
|
|
1175
816
|
|
|
1176
817
|
function queuedTargetStillSameSession(job: ComposerQueuedDelivery): boolean {
|
package/src/update/index.ts
CHANGED
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
type DaemonRestartResult,
|
|
47
47
|
type LaunchdJobCycleResult,
|
|
48
48
|
} from '../launch/launchd.ts'
|
|
49
|
-
import { cycleShadowJob, SHADOW_PERSONALITY, SHADOW_PLIST_LABEL } from '../shadow/install.ts'
|
|
50
49
|
import { defaultDaemonSocketPath } from '../daemon/index.ts'
|
|
51
50
|
// Type-only (erased at runtime → no import cycle): the cascade tail renders these
|
|
52
51
|
// shapes but receives the component-updaters as injected deps.
|
|
@@ -252,14 +251,6 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
|
|
|
252
251
|
const r = cycleLaunchdJob(label, plist, env)
|
|
253
252
|
out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
|
|
254
253
|
}
|
|
255
|
-
// The tmux→pty fidelity BURN-IN job (com.iapeer.shadow-fidelity) is foundation-owned and
|
|
256
|
-
// runs the just-replaced `iapeer shadow` off the same binary, so it carries the same LWCR
|
|
257
|
-
// constraint — but it is NOT a registry peer, so the scan above cannot reach it. Cycle it
|
|
258
|
-
// by label too (no-op when absent / not-loaded / foreign).
|
|
259
|
-
const shadow = cycleShadowJob(env)
|
|
260
|
-
if (shadow) {
|
|
261
|
-
out.push({ personality: SHADOW_PERSONALITY, runtime: 'foundation', label: SHADOW_PLIST_LABEL, state: shadow.state, detail: shadow.detail })
|
|
262
|
-
}
|
|
263
254
|
return out
|
|
264
255
|
}
|
|
265
256
|
|