@agfpd/iapeer 0.2.70 → 0.2.72
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
CHANGED
package/src/launch/index.ts
CHANGED
|
@@ -256,10 +256,14 @@ export const launch: LaunchFn = async (
|
|
|
256
256
|
// read off the pane-log model (the supervisor's boot-driver answers startup dialogs internally);
|
|
257
257
|
// delivery goes over the supervisor socket. UNCONDITIONAL SPAWN-FALLBACK: any host-bringup failure
|
|
258
258
|
// falls through to the tmux path below — a flipped peer is NEVER worse than tmux ON SPAWN. Flag-off →
|
|
259
|
-
// this whole block is skipped and the tmux path is byte-identical (golden). Routers
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
259
|
+
// this whole block is skipped and the tmux path is byte-identical (golden). Routers (telegram/
|
|
260
|
+
// notifier) are ALSO hostable flag-on (cutover infra-track): launchd STILL owns their lifecycle via
|
|
261
|
+
// run-infra (H4) — the supervisor only provides the pty + delivery socket + pane-log; the router
|
|
262
|
+
// branch below skips every TUI phase (no boot dialogs — BOOT_ADAPTERS has no router entry so the
|
|
263
|
+
// supervisor's boot-driver is skipped; no transcript ready-gate — newestActivityMtime=null). @xterm
|
|
264
|
+
// stays out of THIS process (ptyHost: @xterm-free start + deliver + sessionAlive; readiness's @xterm
|
|
265
|
+
// import is dynamic and only loads here, flag-on — and the router branch never reaches it).
|
|
266
|
+
if ((adapter.kind === 'tui' || adapter.kind === 'router') && ptyHostEnabled(cfg.logDir, identity)) {
|
|
263
267
|
const invocation = buildLaunchInvocation(spec, adapter, cfg)
|
|
264
268
|
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
|
|
265
269
|
const paneLogPath = `${cfg.logDir}/${identity}.log`
|
|
@@ -272,6 +276,17 @@ export const launch: LaunchFn = async (
|
|
|
272
276
|
invocation,
|
|
273
277
|
})
|
|
274
278
|
if (host.ok) {
|
|
279
|
+
// ROUTER (telegram/notifier): no startup dialogs and no transcript. The session is "up" the
|
|
280
|
+
// instant the supervisor child is serving — symmetric to the tmux router path, where launch
|
|
281
|
+
// returns READY the moment `tmux new-session` succeeds and the always-on caller (runAlwaysOn)
|
|
282
|
+
// does its own BOOT_RECHECK to catch an immediate child-death. Always-on routers launch with an
|
|
283
|
+
// EMPTY firstMessage; deliveries arrive LATER over the socket (deliverHosted → child stdin → the
|
|
284
|
+
// router's <iap>-marker stdin reader). So confirm the hosted session is live and return ready —
|
|
285
|
+
// no waitHostReady (would load @xterm for nothing), no first-message deliver, no ready-gate.
|
|
286
|
+
if (adapter.kind === 'router') {
|
|
287
|
+
if (hostSessionAlive(identity)) return ready(identity)
|
|
288
|
+
return fail(identity, 'hosted router session not alive immediately after bringup')
|
|
289
|
+
}
|
|
275
290
|
// BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
|
|
276
291
|
// surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
|
|
277
292
|
// the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
|
|
|
23
23
|
import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
|
|
24
24
|
import { readPeerProfile } from '../identity/index.ts'
|
|
25
25
|
import { getAdapter, launch } from './index.ts'
|
|
26
|
+
import { hostSessionAlive, killPtyHost } from './ptyHost.ts'
|
|
26
27
|
import { dismissCanary, signalCanaryClean } from './canary.ts'
|
|
27
28
|
import type { LaunchConfig, LaunchSpec } from './types.ts'
|
|
28
29
|
|
|
@@ -115,6 +116,14 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
115
116
|
const sock = buildSocketPath(runtime, personality, sockDir)
|
|
116
117
|
const adapter = getAdapter(runtime)
|
|
117
118
|
|
|
119
|
+
// Host-aware liveness (cutover infra-track): a flag-on router is supervisor-HOSTED (no tmux session);
|
|
120
|
+
// a flag-off one is tmux. Probe BOTH backings by RUNTIME STATE (not the `.pty-host` marker), so this
|
|
121
|
+
// is robust to a spawn-FALLBACK — if startPtyHost failed and launch fell back to tmux, the marker is
|
|
122
|
+
// present yet the live session is tmux. The router is "up" iff EITHER backing is alive; on child
|
|
123
|
+
// death BOTH go false → block-watch exits → run-infra exits → launchd KeepAlive respawns (the SOLE
|
|
124
|
+
// respawn owner; the supervisor never self-respawns — H4 held).
|
|
125
|
+
const isAlive = (): boolean => hostSessionAlive(identity) || sessionAlive(sock, identity)
|
|
126
|
+
|
|
118
127
|
const cfg: LaunchConfig = {
|
|
119
128
|
claudeBin: env.CLAUDE_BIN ?? 'claude',
|
|
120
129
|
codexBin: env.CODEX_BIN ?? 'codex',
|
|
@@ -143,7 +152,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
143
152
|
const spec = buildAlwaysOnSpec(personality, runtime, cwd, sockDir)
|
|
144
153
|
|
|
145
154
|
// Idempotent: bring up only when not already live.
|
|
146
|
-
if (!
|
|
155
|
+
if (!isAlive()) {
|
|
147
156
|
const result = await launch(spec, adapter, '', cfg)
|
|
148
157
|
if (result.status !== 'READY') {
|
|
149
158
|
process.stderr.write(`launchdRun: launch FAILED for ${identity}: ${result.reason ?? 'unknown'}\n`)
|
|
@@ -155,7 +164,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
155
164
|
// (a diagnostic, throttled by the plist) instead of a clean exit that reads as a
|
|
156
165
|
// healthy run in the launchd logs.
|
|
157
166
|
await sleep(BOOT_RECHECK_MS)
|
|
158
|
-
if (!
|
|
167
|
+
if (!isAlive()) {
|
|
159
168
|
process.stderr.write(
|
|
160
169
|
`launchdRun: ${identity} session died immediately after launch — check ${cfg.notifierBin ?? `${runtime}-runtime`}\n`,
|
|
161
170
|
)
|
|
@@ -175,7 +184,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
175
184
|
}
|
|
176
185
|
process.on('SIGTERM', onSignal)
|
|
177
186
|
process.on('SIGINT', onSignal)
|
|
178
|
-
while (!stop &&
|
|
187
|
+
while (!stop && isAlive()) {
|
|
179
188
|
await new Promise<void>(resolve => {
|
|
180
189
|
const timer = setTimeout(resolve, WATCH_INTERVAL_MS)
|
|
181
190
|
interrupt = () => {
|
|
@@ -190,7 +199,14 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
190
199
|
// holding stale in-memory state (см. teardownAlwaysOnSession). A natural session
|
|
191
200
|
// death (stop=false) skips this: there is nothing to tear down, exit 0 →
|
|
192
201
|
// KeepAlive respawns a fresh bring-up.
|
|
193
|
-
|
|
202
|
+
// Signal-initiated stop (bootout / shutdown / kickstart -k) tears the session down WITH this watcher.
|
|
203
|
+
// A hosted router → kill the supervisor daemon (killPtyHost: SIGTERM → it SIGKILLs the child + cleans
|
|
204
|
+
// up); a tmux router → the tmux teardown. Pick by the live backing. A NATURAL death (stop=false) skips
|
|
205
|
+
// this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
|
|
206
|
+
if (stop) {
|
|
207
|
+
if (hostSessionAlive(identity)) killPtyHost(identity)
|
|
208
|
+
else teardownAlwaysOnSession(sock, identity)
|
|
209
|
+
}
|
|
194
210
|
return 0
|
|
195
211
|
}
|
|
196
212
|
|
|
@@ -174,4 +174,40 @@ d('supervisor daemon (Bun-native pty, tick runtime)', () => {
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
})
|
|
177
|
+
|
|
178
|
+
// FINDING-2 — restore the SERVE geometry on last detach. The daemon resizes the child pty to a
|
|
179
|
+
// client's size on attach and (before this) never restored it, so an unattached session stuck at a
|
|
180
|
+
// departed client's size → the fixed-geometry warm-deliver/ready-gate viewport model mismatched the
|
|
181
|
+
// pty. Drive it with a child that prints its live pty winsize ("ROWS COLS") to the pane-log: attach
|
|
182
|
+
// small → pty shrinks; detach → pty restores to the serve geometry.
|
|
183
|
+
test('FINDING-2: last detach restores the serve geometry (no sticking at the client size)', async () => {
|
|
184
|
+
const gSession = 'ptsgeo'
|
|
185
|
+
const paneLog = join(runDir, 'geo-pane.log')
|
|
186
|
+
const lastSize = (): string => {
|
|
187
|
+
const t = existsSync(paneLog) ? readFileSync(paneLog, 'utf8') : ''
|
|
188
|
+
const m = [...t.matchAll(/(\d+) (\d+)/g)]
|
|
189
|
+
return m.length ? `${m[m.length - 1][1]}x${m[m.length - 1][2]}` : ''
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const r = startSupervisorDaemon({
|
|
193
|
+
session: gSession,
|
|
194
|
+
runtime: 'tick',
|
|
195
|
+
runDir,
|
|
196
|
+
// serve geometry 200x50 (distinct from defaults); child reports its live pty winsize as "rows cols"
|
|
197
|
+
serve: { argv: ['sh', '-c', 'while :; do stty size 2>/dev/null; sleep 0.3; done'], env: { PATH: process.env.PATH ?? '' }, cwd: runDir, paneLogPath: paneLog, cols: 200, rows: 50 },
|
|
198
|
+
})
|
|
199
|
+
expect(r.state).toBe('started')
|
|
200
|
+
expect(await waitFor(() => lastSize() === '50 200'.replace(' ', 'x'), 6000)).toBe(true) // at serve geometry
|
|
201
|
+
const c = await attach(runDir, gSession, 90, 30) // attach at a DIFFERENT size → pty resizes to it
|
|
202
|
+
expect(await waitFor(() => lastSize() === '30x90', 6000)).toBe(true) // pty shrank to the client size
|
|
203
|
+
c.close() // last client gone
|
|
204
|
+
expect(await waitFor(() => lastSize() === '50x200', 6000)).toBe(true) // RESTORED to serve geometry
|
|
205
|
+
} finally {
|
|
206
|
+
try {
|
|
207
|
+
killSession(runDir, gSession)
|
|
208
|
+
} catch {
|
|
209
|
+
/* */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})
|
|
177
213
|
})
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -90,6 +90,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
90
90
|
const { session, runtime, runDir } = opts
|
|
91
91
|
let cols = opts.cols ?? 80
|
|
92
92
|
let rows = opts.rows ?? 30
|
|
93
|
+
// The SERVE geometry — the size the session is hosted at (HOST_COLS×HOST_ROWS for a pty-host peer),
|
|
94
|
+
// immutable. `cols`/`rows` above track the CURRENT (client-driven) size. A client attaches at its own
|
|
95
|
+
// terminal size and the pty resizes to match; on LAST detach we restore the serve geometry (below) so
|
|
96
|
+
// the unattached session never sticks at a departed client's size — otherwise the warm-deliver /
|
|
97
|
+
// ready-gate viewport model (built at the fixed serve geometry) mismatches the actually-resized pty.
|
|
98
|
+
const serveCols = cols
|
|
99
|
+
const serveRows = rows
|
|
93
100
|
const sock = sockPath(runDir, session)
|
|
94
101
|
const pid = pidPath(runDir, session)
|
|
95
102
|
|
|
@@ -217,28 +224,40 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
217
224
|
sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
|
|
218
225
|
}
|
|
219
226
|
|
|
227
|
+
// Resize the model + child pty to (c, r). Shared by client FRAME_RESIZE and the last-detach restore.
|
|
228
|
+
const applyGeometry = (c: number, r: number): void => {
|
|
229
|
+
cols = c
|
|
230
|
+
rows = r
|
|
231
|
+
try {
|
|
232
|
+
xterm.resize(c, r)
|
|
233
|
+
child.terminal.resize(c, r)
|
|
234
|
+
} catch {
|
|
235
|
+
/* transient */
|
|
236
|
+
}
|
|
237
|
+
// Best-effort SIGWINCH nudge for a SIGWINCH-reactive child (forces a repaint at the new size); NOT
|
|
238
|
+
// load-bearing for an attached client (the complete snapshot repaint is), but for the unattached
|
|
239
|
+
// restore it is what makes the child redraw into the pane-log at the serve geometry.
|
|
240
|
+
try {
|
|
241
|
+
child.terminal.resize(c, Math.max(1, r - 1))
|
|
242
|
+
child.terminal.resize(c, r)
|
|
243
|
+
} catch {
|
|
244
|
+
/* */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Restore the serve geometry when the last client detaches, so the unattached session never sticks at
|
|
248
|
+
// a departed client's size (FINDING-2 — would mismatch the fixed-geometry warm-deliver/ready-gate
|
|
249
|
+
// viewport model). No-op when already at serve geometry or a client is still attached.
|
|
250
|
+
const restoreServeGeometryIfIdle = (): void => {
|
|
251
|
+
if (clients.size === 0 && (cols !== serveCols || rows !== serveRows)) applyGeometry(serveCols, serveRows)
|
|
252
|
+
}
|
|
253
|
+
|
|
220
254
|
const onClientFrame = (s: { write(b: Buffer): number }, type: number, payload: Buffer): void => {
|
|
221
255
|
if (type === FRAME_DATA) {
|
|
222
256
|
child.terminal.write(payload) // keystrokes → child
|
|
223
257
|
} else if (type === FRAME_RESIZE) {
|
|
224
258
|
const sz = parseSize(payload)
|
|
225
|
-
cols
|
|
226
|
-
rows = sz.rows
|
|
227
|
-
try {
|
|
228
|
-
xterm.resize(cols, rows)
|
|
229
|
-
child.terminal.resize(cols, rows)
|
|
230
|
-
} catch {
|
|
231
|
-
/* transient */
|
|
232
|
-
}
|
|
259
|
+
applyGeometry(sz.cols, sz.rows)
|
|
233
260
|
repaint(s) // instant paint from the snapshot (delivered COMPLETE via backpressure)
|
|
234
|
-
// Best-effort SIGWINCH nudge for a SIGWINCH-reactive child; NOT load-bearing (the complete
|
|
235
|
-
// snapshot above is the real freshness fix).
|
|
236
|
-
try {
|
|
237
|
-
child.terminal.resize(cols, Math.max(1, rows - 1))
|
|
238
|
-
child.terminal.resize(cols, rows)
|
|
239
|
-
} catch {
|
|
240
|
-
/* */
|
|
241
|
-
}
|
|
242
261
|
}
|
|
243
262
|
}
|
|
244
263
|
|
|
@@ -266,11 +285,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
266
285
|
clients.delete(s)
|
|
267
286
|
writers.delete(s)
|
|
268
287
|
syncAttached()
|
|
288
|
+
restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size
|
|
269
289
|
},
|
|
270
290
|
error(s: object) {
|
|
271
291
|
clients.delete(s)
|
|
272
292
|
writers.delete(s)
|
|
273
293
|
syncAttached()
|
|
294
|
+
restoreServeGeometryIfIdle()
|
|
274
295
|
},
|
|
275
296
|
},
|
|
276
297
|
})
|
|
@@ -100,3 +100,38 @@ describe('deliverWarm — hosted target delivers over the socket + confirms by t
|
|
|
100
100
|
expect(mtimeProbes).toBe(0) // no cwd → the transcript proxy is never consulted
|
|
101
101
|
})
|
|
102
102
|
})
|
|
103
|
+
|
|
104
|
+
// Cutover infra-track — a hosted ROUTER (telegram/notifier) has NO transcript proxy
|
|
105
|
+
// (adapter.newestActivityMtime=null), so the mtime-advance confirm a TUI uses can never be satisfied
|
|
106
|
+
// and would false-FAIL every router delivery. The host path confirms a router by the socket-ack —
|
|
107
|
+
// PARITY with deliverViaTmux's router C-j path (delivery-level confirm; router liveness is structural
|
|
108
|
+
// via launchd, not a model turn). These cases pin that behavior.
|
|
109
|
+
const routerTarget: DeliveryTarget = {
|
|
110
|
+
runtime: 'notifier', // notifierAdapter.kind === 'router'
|
|
111
|
+
personality: 'timer',
|
|
112
|
+
address: 'notifier-timer',
|
|
113
|
+
socketPath: '/tmp/nonexistent-iap-test.sock',
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('deliverWarm — hosted ROUTER confirms by socket-ack (no transcript proxy)', () => {
|
|
117
|
+
test('router deliver ok → ok EVEN THOUGH mtime never advances (the exact scenario a TUI fails on)', async () => {
|
|
118
|
+
process.env.IAP_HOST_LIVENESS_GRACE_MS = '0' // a mtime-gated path would fail at once here
|
|
119
|
+
const seam: WarmDeliverSeam = {
|
|
120
|
+
hostAlive: () => true,
|
|
121
|
+
deliverHosted: async () => ({ ok: true }),
|
|
122
|
+
newestActivityMtime: () => 100, // constant — a router writes no transcript; a TUI here → loud fail
|
|
123
|
+
}
|
|
124
|
+
const r = await deliverWarm(routerTarget, '<iap>x</iap>', '/peer/cwd', seam)
|
|
125
|
+
expect(r.ok).toBe(true) // socket-ack IS the confirm for a router; the non-advancing transcript is irrelevant
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('router socket deliver fails (dead/stalled) → loud fail, no false ok', async () => {
|
|
129
|
+
const seam: WarmDeliverSeam = {
|
|
130
|
+
hostAlive: () => true,
|
|
131
|
+
deliverHosted: async () => ({ ok: false, error: 'socket dead during paste' }),
|
|
132
|
+
}
|
|
133
|
+
const r = await deliverWarm(routerTarget, '<iap>x</iap>', '/peer/cwd', seam)
|
|
134
|
+
expect(r.ok).toBe(false)
|
|
135
|
+
if (!r.ok) expect(r.error.message).toContain('deliver failed')
|
|
136
|
+
})
|
|
137
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -564,6 +564,13 @@ async function deliverViaHost(
|
|
|
564
564
|
`message NOT delivered`,
|
|
565
565
|
)
|
|
566
566
|
}
|
|
567
|
+
// Router (telegram/notifier): no transcript proxy (adapter.newestActivityMtime=null) → the
|
|
568
|
+
// mtime-advance confirm below can NEVER be satisfied and would false-FAIL every router delivery.
|
|
569
|
+
// Confirm by the socket-ack just obtained — PARITY with deliverViaTmux's router path (C-j success =
|
|
570
|
+
// delivery-level confirm; a router's liveness is structural via launchd KeepAlive, not a model
|
|
571
|
+
// turn). See deliverViaTmux router branch (paste-buffer -r + C-j, no transcript confirm) + its
|
|
572
|
+
// header note "router … liveness is structural".
|
|
573
|
+
if (adapter.kind === 'router') return ok(undefined)
|
|
567
574
|
// socket-ack ≠ landed. With no activity proxy (direct callers/tests without a cwd) we can only
|
|
568
575
|
// trust the flushed submit — confirmed-only, mirroring deliverViaTmux's legacy-caller path.
|
|
569
576
|
if (!cwd) return ok(undefined)
|