@agfpd/iapeer 0.2.63 → 0.2.65
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/cli.test.ts +2 -2
- package/src/cli/index.ts +16 -3
- package/src/cli/listTui.ts +7 -0
- package/src/daemon/main.ts +4 -0
- package/src/launch/hostComposerOccupied.test.ts +63 -0
- package/src/launch/index.ts +81 -25
- package/src/launch/invocation.test.ts +134 -0
- package/src/launch/invocation.ts +56 -0
- package/src/launch/ptyHost.test.ts +33 -0
- package/src/launch/ptyHost.ts +148 -0
- package/src/launch/readyGateModel.test.ts +72 -0
- package/src/launch/readyGateModel.ts +140 -0
- package/src/lifecycle/index.ts +11 -1
- package/src/shadow/enumerate-skip.test.ts +65 -0
- package/src/shadow/index.ts +13 -37
- package/src/shadow/render.ts +60 -0
- package/src/supervisor/attached-marker.test.ts +84 -0
- package/src/supervisor/boot.test.ts +66 -0
- package/src/supervisor/boot.ts +34 -0
- package/src/supervisor/daemon.test.ts +66 -1
- package/src/supervisor/daemon.ts +146 -7
- package/src/supervisor/deliver.test.ts +58 -0
- package/src/supervisor/deliver.ts +120 -0
- package/src/supervisor/index.ts +76 -6
- package/src/supervisor/panelog.test.ts +95 -0
- package/src/supervisor/paths.ts +9 -0
- package/src/supervisor/protocol.test.ts +91 -0
- package/src/supervisor/protocol.ts +49 -0
- package/src/transport/composerQueue.test.ts +30 -0
- package/src/transport/hostControl.test.ts +87 -0
- package/src/transport/hostDeliver.test.ts +102 -0
- package/src/transport/index.ts +186 -24
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Supervisor DELIVER client (cutover Block 2 spawn-flip, Ф0a) — hands a message to a supervisor-
|
|
2
|
+
// HOSTED session over its unix socket: the host equivalent of tmux load-buffer → paste-buffer -p →
|
|
3
|
+
// send-keys Enter. @xterm-FREE BY CONSTRUCTION — imports ONLY protocol.ts (pure frames + the proven
|
|
4
|
+
// BackpressureWriter) + paths.ts (no @xterm) — so launch can deliver to a hosted session WITHOUT
|
|
5
|
+
// pulling the @xterm daemon into the hot path. Bracketed-paste wraps the message (same intent as
|
|
6
|
+
// tmux paste-buffer -p: the TUI takes it as one atomic paste, not retyped keystrokes), then a CR
|
|
7
|
+
// submits — the SAME two-step the cold/warm tmux delivery uses. Backpressure-safe: a multi-KB
|
|
8
|
+
// envelope past the socket buffer is queued + flushed on drain, never truncated (the same failure
|
|
9
|
+
// mode that staled heavy reattach — protocol.ts port-dep #2).
|
|
10
|
+
import { BackpressureWriter, FRAME_DATA, frame } from './protocol.ts'
|
|
11
|
+
import { sockPath } from './paths.ts'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
15
|
+
|
|
16
|
+
export interface DeliverResult {
|
|
17
|
+
ok: boolean
|
|
18
|
+
error?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Deliver `message` to a hosted session over its supervisor socket: connect → bracketed-paste →
|
|
22
|
+
* (settle) → CR → close. submitDelayMs mirrors the tmux boot delivery's 300 ms paste→Enter settle. */
|
|
23
|
+
export async function deliverToHost(
|
|
24
|
+
runDir: string,
|
|
25
|
+
session: string,
|
|
26
|
+
message: string,
|
|
27
|
+
opts: { submitDelayMs?: number } = {},
|
|
28
|
+
): Promise<DeliverResult> {
|
|
29
|
+
const sock = sockPath(runDir, session)
|
|
30
|
+
if (!existsSync(sock)) return { ok: false, error: `no supervisor socket for "${session}"` }
|
|
31
|
+
let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
|
|
32
|
+
let writer: BackpressureWriter | undefined
|
|
33
|
+
try {
|
|
34
|
+
conn = await Bun.connect({
|
|
35
|
+
unix: sock,
|
|
36
|
+
socket: {
|
|
37
|
+
drain() {
|
|
38
|
+
writer?.flush()
|
|
39
|
+
},
|
|
40
|
+
data() {
|
|
41
|
+
/* the daemon forwards screen output; the deliver client ignores it */
|
|
42
|
+
},
|
|
43
|
+
error() {
|
|
44
|
+
/* surfaced via the writer's dead state */
|
|
45
|
+
},
|
|
46
|
+
close() {
|
|
47
|
+
/* */
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
const c = conn
|
|
52
|
+
writer = new BackpressureWriter(b => c.write(b))
|
|
53
|
+
const paste = `\x1b[200~${message}\x1b[201~` // bracketed paste — atomic, like paste-buffer -p
|
|
54
|
+
if (!writer.send(frame(FRAME_DATA, Buffer.from(paste, 'utf8')))) return { ok: false, error: 'socket dead during paste' }
|
|
55
|
+
if (!(await drainFully(writer))) return { ok: false, error: 'paste not fully flushed (socket stalled)' }
|
|
56
|
+
await sleep(opts.submitDelayMs ?? 300)
|
|
57
|
+
if (!writer.send(frame(FRAME_DATA, Buffer.from('\r', 'utf8')))) return { ok: false, error: 'socket dead during submit' }
|
|
58
|
+
if (!(await drainFully(writer))) return { ok: false, error: 'submit not fully flushed (socket stalled)' }
|
|
59
|
+
await sleep(50) // let the CR land before we close the connection
|
|
60
|
+
return { ok: true }
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return { ok: false, error: `deliver to host failed: ${(e as Error).message}` }
|
|
63
|
+
} finally {
|
|
64
|
+
try {
|
|
65
|
+
conn?.end()
|
|
66
|
+
} catch {
|
|
67
|
+
/* */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Wait until the writer's queued tail has flushed (drain-driven), or time out. */
|
|
73
|
+
async function drainFully(writer: BackpressureWriter, timeoutMs = 4000): Promise<boolean> {
|
|
74
|
+
const end = Date.now() + timeoutMs
|
|
75
|
+
while (writer.queuedBytes > 0 && !writer.isDead && Date.now() < end) await sleep(20)
|
|
76
|
+
return writer.queuedBytes === 0 && !writer.isDead
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Send raw CONTROL bytes to a hosted session over its supervisor socket — the host equivalent of
|
|
81
|
+
* `tmux send-keys` for the control channel (interrupt / compact). Each chunk is ALREADY the exact
|
|
82
|
+
* keystrokes (translated from the send-keys vocabulary by keysToBytes upstream), sent as a FRAME_DATA
|
|
83
|
+
* write — NOT bracketed-pasted and NOT auto-submitted: an Escape must arrive as a bare ESC, never a
|
|
84
|
+
* pasted literal. `stepDelayMs` paces a multi-step plan after each step, mirroring executeControl's
|
|
85
|
+
* tmux loop (e.g. compact's '/compact' then a 300 ms settle then Enter). Backpressure-safe per step.
|
|
86
|
+
*/
|
|
87
|
+
export async function sendControlToHost(
|
|
88
|
+
runDir: string,
|
|
89
|
+
session: string,
|
|
90
|
+
chunks: Buffer[],
|
|
91
|
+
opts: { stepDelayMs?: number } = {},
|
|
92
|
+
): Promise<DeliverResult> {
|
|
93
|
+
const sock = sockPath(runDir, session)
|
|
94
|
+
if (!existsSync(sock)) return { ok: false, error: `no supervisor socket for "${session}"` }
|
|
95
|
+
let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
|
|
96
|
+
let writer: BackpressureWriter | undefined
|
|
97
|
+
try {
|
|
98
|
+
conn = await Bun.connect({
|
|
99
|
+
unix: sock,
|
|
100
|
+
socket: { drain() { writer?.flush() }, data() { /* */ }, error() { /* */ }, close() { /* */ } },
|
|
101
|
+
})
|
|
102
|
+
const c = conn
|
|
103
|
+
writer = new BackpressureWriter(b => c.write(b))
|
|
104
|
+
for (const chunk of chunks) {
|
|
105
|
+
if (!writer.send(frame(FRAME_DATA, chunk))) return { ok: false, error: 'socket dead during control send' }
|
|
106
|
+
if (!(await drainFully(writer))) return { ok: false, error: 'control not fully flushed (socket stalled)' }
|
|
107
|
+
if (opts.stepDelayMs) await sleep(opts.stepDelayMs)
|
|
108
|
+
}
|
|
109
|
+
await sleep(50) // let the last chunk land before we close the connection
|
|
110
|
+
return { ok: true }
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return { ok: false, error: `control to host failed: ${(e as Error).message}` }
|
|
113
|
+
} finally {
|
|
114
|
+
try {
|
|
115
|
+
conn?.end()
|
|
116
|
+
} catch {
|
|
117
|
+
/* */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/supervisor/index.ts
CHANGED
|
@@ -5,16 +5,35 @@
|
|
|
5
5
|
// serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
|
|
6
6
|
// binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
|
|
7
7
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
8
|
-
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
8
|
+
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
|
-
import { runSupervisorDaemon } from './daemon.ts'
|
|
11
10
|
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
12
|
-
import { defaultRunDir, logPath, pidPath, sockPath } from './paths.ts'
|
|
11
|
+
import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
// daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
|
|
14
|
+
// daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
|
|
15
|
+
// startSupervisorDaemon to host a flag-on peer — never pulls @xterm into the launch hot path.
|
|
15
16
|
export { runSupervisorClient, sessionAlive } from './client.ts'
|
|
16
17
|
export { defaultRunDir, sockPath, pidPath } from './paths.ts'
|
|
17
18
|
|
|
19
|
+
/** The composed invocation a detached daemon serves (slice b): buildLaunchInvocation output + the
|
|
20
|
+
* resolved cwd. Serialized to `<runDir>/<session>.serve.json` (0600) so the detached daemon process
|
|
21
|
+
* picks it up — the seam by which launch hands the supervisor a real session at the spawn-flip. */
|
|
22
|
+
export interface ServeSpec {
|
|
23
|
+
argv: string[]
|
|
24
|
+
env: Record<string, string>
|
|
25
|
+
cwd: string
|
|
26
|
+
/** Pane-log path (slice c): `<logDir>/<identity>.log` — where the daemon appends the child's raw
|
|
27
|
+
* output, byte-identical to tmux pipe-pane, so the observer/shadow read a served session's log. */
|
|
28
|
+
paneLogPath?: string
|
|
29
|
+
/** Exit-cause log (spawn-flip Ф0b): exits.log — the daemon appends the child's death cause here. */
|
|
30
|
+
exitLogPath?: string
|
|
31
|
+
/** Pty geometry (spawn-flip Ф0a): match the tmux launch's 220x50 so the served child + the pane-log
|
|
32
|
+
* model render at the SAME size launch reads readiness at. Unset → daemon default (80x30). */
|
|
33
|
+
cols?: number
|
|
34
|
+
rows?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
export interface StartDaemonOptions {
|
|
19
38
|
session: string
|
|
20
39
|
runtime: string
|
|
@@ -23,10 +42,24 @@ export interface StartDaemonOptions {
|
|
|
23
42
|
* `daemon <session> <runtime> --run-dir <runDir>` is appended. Serving-prod injects the
|
|
24
43
|
* installed-binary argv here. */
|
|
25
44
|
daemonArgv?: string[]
|
|
45
|
+
/** Composed invocation to SERVE (slice b). When set, written to the serve-spec file before spawn so
|
|
46
|
+
* the detached daemon serves it; absent → a bare conduit (throwaway/tick). */
|
|
47
|
+
serve?: ServeSpec
|
|
26
48
|
/** Max ms to wait for the daemon socket+pid to appear (default 5000). */
|
|
27
49
|
timeoutMs?: number
|
|
28
50
|
}
|
|
29
51
|
|
|
52
|
+
/** Read the serve-spec for a detached daemon, or undefined for a bare session. */
|
|
53
|
+
function readServeSpec(runDir: string, session: string): ServeSpec | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const s = JSON.parse(readFileSync(servePath(runDir, session), 'utf8')) as ServeSpec
|
|
56
|
+
if (Array.isArray(s.argv) && s.env && typeof s.cwd === 'string') return s
|
|
57
|
+
} catch {
|
|
58
|
+
/* no / invalid serve spec → bare session */
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
export interface StartDaemonResult {
|
|
31
64
|
state: 'started' | 'already-running' | 'failed'
|
|
32
65
|
pid?: number
|
|
@@ -45,6 +78,15 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
|
|
|
45
78
|
} catch {
|
|
46
79
|
/* */
|
|
47
80
|
}
|
|
81
|
+
// Serving seam (slice b): persist the composed invocation BEFORE spawn so the detached daemon serves
|
|
82
|
+
// it. 0600 — env may carry launch.env secrets. A stale spec from a prior run is overwritten or cleared.
|
|
83
|
+
if (opts.serve) writeFileSync(servePath(runDir, session), JSON.stringify(opts.serve), { mode: 0o600 })
|
|
84
|
+
else
|
|
85
|
+
try {
|
|
86
|
+
unlinkSync(servePath(runDir, session))
|
|
87
|
+
} catch {
|
|
88
|
+
/* no stale spec */
|
|
89
|
+
}
|
|
48
90
|
const out = openSync(logPath(runDir, session), 'a')
|
|
49
91
|
const argv = opts.daemonArgv ?? selfArgv()
|
|
50
92
|
const child = nodeSpawn(argv[0]!, [...argv.slice(1), 'daemon', session, runtime, '--run-dir', runDir], {
|
|
@@ -59,6 +101,13 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
|
|
|
59
101
|
}
|
|
60
102
|
Bun.sleepSync(80)
|
|
61
103
|
}
|
|
104
|
+
// Failed to come up — drop the serve-spec we wrote so it can't mislead a later read.
|
|
105
|
+
if (opts.serve)
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(servePath(runDir, session))
|
|
108
|
+
} catch {
|
|
109
|
+
/* */
|
|
110
|
+
}
|
|
62
111
|
return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
|
|
63
112
|
}
|
|
64
113
|
|
|
@@ -96,13 +145,34 @@ export async function runSupervisorCli(argv: string[]): Promise<number> {
|
|
|
96
145
|
const [cmd, a, b] = argv
|
|
97
146
|
const runDir = runDirFrom(argv)
|
|
98
147
|
switch (cmd) {
|
|
99
|
-
case 'daemon':
|
|
100
|
-
|
|
148
|
+
case 'daemon': {
|
|
149
|
+
// Serving seam (slice b): a serve-spec → serve the launch-composed argv/env/cwd; else bare.
|
|
150
|
+
const serve = readServeSpec(runDir, a!)
|
|
151
|
+
// @xterm loads HERE — in the detached daemon process only (dynamic import keeps it out of any
|
|
152
|
+
// caller that merely resolves startSupervisorDaemon, e.g. the launch hot path).
|
|
153
|
+
const { runSupervisorDaemon } = await import('./daemon.ts')
|
|
154
|
+
runSupervisorDaemon({
|
|
155
|
+
session: a!,
|
|
156
|
+
runtime: b || 'claude',
|
|
157
|
+
runDir,
|
|
158
|
+
...(serve
|
|
159
|
+
? {
|
|
160
|
+
argv: serve.argv,
|
|
161
|
+
env: serve.env,
|
|
162
|
+
cwd: serve.cwd,
|
|
163
|
+
paneLogPath: serve.paneLogPath,
|
|
164
|
+
exitLogPath: serve.exitLogPath,
|
|
165
|
+
cols: serve.cols,
|
|
166
|
+
rows: serve.rows,
|
|
167
|
+
}
|
|
168
|
+
: {}),
|
|
169
|
+
})
|
|
101
170
|
// The daemon is event-driven: runSupervisorDaemon RETURNS after wiring the pty + socket
|
|
102
171
|
// server, and the event loop keeps the process alive. Await a never-resolving promise so the
|
|
103
172
|
// caller's `process.exit` does NOT fire — shutdown() (child-exit / SIGTERM) owns the exit.
|
|
104
173
|
await new Promise<never>(() => {})
|
|
105
174
|
return 0 // unreachable
|
|
175
|
+
}
|
|
106
176
|
case 'start': {
|
|
107
177
|
const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
108
178
|
console.log(`${r.state}${r.pid ? ` pid=${r.pid}` : ''}${r.detail ? ` — ${r.detail}` : ''}: ${a}`)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { Terminal } from '@xterm/headless'
|
|
7
|
+
import { modelToPlainText } from '../shadow/render.ts'
|
|
8
|
+
import { killSession, startSupervisorDaemon } from './index.ts'
|
|
9
|
+
|
|
10
|
+
// Slice c — the supervisor pane-log must be a drop-in for tmux pipe-pane: the SAME raw child bytes to
|
|
11
|
+
// <identity>.log (so observer/shadow/readyGateViewport read a served session identically), AND a model
|
|
12
|
+
// built from it must render what tmux's capture-pane shows. Live tmux + Bun-native pty → guarded.
|
|
13
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
14
|
+
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
15
|
+
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
16
|
+
const d = tmuxAvailable && bunPty ? describe : describe.skip
|
|
17
|
+
|
|
18
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
19
|
+
const readBuf = (p: string): Buffer => (existsSync(p) ? readFileSync(p) : Buffer.alloc(0))
|
|
20
|
+
const modelOf = (bytes: Buffer, cols: number, rows: number): Promise<string> => {
|
|
21
|
+
const t = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 1000 })
|
|
22
|
+
return new Promise<string>(res => t.write(bytes, () => res(modelToPlainText(t, cols, rows))))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
d('supervisor pane-log ≡ tmux pipe-pane', () => {
|
|
26
|
+
test('RAW-BYTE-IDENTITY: the same child yields byte-identical logs (LF/CRLF + multibyte + ANSI)', async () => {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'panelog-bp-'))
|
|
28
|
+
const tmuxSock = join(dir, 't.sock')
|
|
29
|
+
const logTmux = join(dir, 'tmux.log')
|
|
30
|
+
const logSup = join(dir, 'sup.log')
|
|
31
|
+
const runDir = join(dir, 'run')
|
|
32
|
+
// bare \n, \r\n, multibyte ❯ › … é (the latin1 grabli), ANSI SGR — the byte classes that diverge
|
|
33
|
+
const child = ['sh', '-c', `sleep 0.5; printf 'L1\\nL2\\r\\ng:❯›…é\\n\\033[31mRED\\033[0m\\nTAB\\tEND\\n'; sleep 1`]
|
|
34
|
+
try {
|
|
35
|
+
spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'p', '-x', '80', '-y', '24', ...child], { stdio: 'ignore' })
|
|
36
|
+
spawnSync('tmux', ['-S', tmuxSock, 'pipe-pane', '-o', '-t', 'p', `cat >> '${logTmux}'`], { stdio: 'ignore' })
|
|
37
|
+
startSupervisorDaemon({ session: 'bp', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
|
|
38
|
+
await sleep(2300) // both children: sleep .5 + emit + sleep 1 ≈ exit ~1.5s; tmux's cat EOF-flushes
|
|
39
|
+
const a = readBuf(logTmux)
|
|
40
|
+
const b = readBuf(logSup)
|
|
41
|
+
expect(b.length).toBeGreaterThan(0)
|
|
42
|
+
expect(b.equals(a)).toBe(true) // byte-for-byte — raw Buffer passthrough, shared onlcr default
|
|
43
|
+
expect(b.includes(Buffer.from('❯›…é', 'utf8'))).toBe(true) // multibyte survived (no latin1 re-encode)
|
|
44
|
+
} finally {
|
|
45
|
+
spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
|
|
46
|
+
try {
|
|
47
|
+
killSession(runDir, 'bp')
|
|
48
|
+
} catch {
|
|
49
|
+
/* */
|
|
50
|
+
}
|
|
51
|
+
await sleep(300)
|
|
52
|
+
rmSync(dir, { recursive: true, force: true })
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('BINDING: a model from the supervisor pane-log renders what tmux capture-pane shows', async () => {
|
|
57
|
+
const dir = mkdtempSync(join(tmpdir(), 'panelog-bind-'))
|
|
58
|
+
const tmuxSock = join(dir, 't.sock')
|
|
59
|
+
const logSup = join(dir, 'sup.log')
|
|
60
|
+
const runDir = join(dir, 'run')
|
|
61
|
+
const COLS = 80,
|
|
62
|
+
ROWS = 24
|
|
63
|
+
// a static frame (clear+home, three lines incl. the ❯ ready glyph), then idle so capture-pane sees it
|
|
64
|
+
const child = ['sh', '-c', `printf '\\033[2J\\033[HLine one\\r\\n ❯ ready prompt\\r\\nthird line ok\\r\\n'; sleep 30`]
|
|
65
|
+
try {
|
|
66
|
+
spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'b', '-x', String(COLS), '-y', String(ROWS), ...child], { stdio: 'ignore' })
|
|
67
|
+
startSupervisorDaemon({ session: 'bind', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
|
|
68
|
+
// let the frame render in both
|
|
69
|
+
let cap = ''
|
|
70
|
+
for (let i = 0; i < 30 && !/❯ ready prompt/.test(cap); i++) {
|
|
71
|
+
await sleep(200)
|
|
72
|
+
cap = spawnSync('tmux', ['-S', tmuxSock, 'capture-pane', '-p', '-t', 'b'], { encoding: 'utf8' }).stdout || ''
|
|
73
|
+
}
|
|
74
|
+
const modelSup = await modelOf(readBuf(logSup), COLS, ROWS)
|
|
75
|
+
// tmux capture-pane render (its own emulator) == @xterm model of the SUPERVISOR's pane-log
|
|
76
|
+
const norm = (s: string): string =>
|
|
77
|
+
s
|
|
78
|
+
.split('\n')
|
|
79
|
+
.map(l => l.replace(/\s+$/, ''))
|
|
80
|
+
.join('\n')
|
|
81
|
+
.replace(/\n+$/, '')
|
|
82
|
+
expect(norm(modelSup)).toBe(norm(cap))
|
|
83
|
+
expect(modelSup).toContain('❯ ready prompt') // the ready glyph survived into the model
|
|
84
|
+
} finally {
|
|
85
|
+
spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
|
|
86
|
+
try {
|
|
87
|
+
killSession(runDir, 'bind')
|
|
88
|
+
} catch {
|
|
89
|
+
/* */
|
|
90
|
+
}
|
|
91
|
+
await sleep(300)
|
|
92
|
+
rmSync(dir, { recursive: true, force: true })
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
})
|
package/src/supervisor/paths.ts
CHANGED
|
@@ -12,3 +12,12 @@ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
12
12
|
export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
|
|
13
13
|
export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
|
|
14
14
|
export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
|
|
15
|
+
/** Serving spec (cutover Block 2 slice b): the composed {argv, env, cwd} a detached daemon serves.
|
|
16
|
+
* Written 0600 by startSupervisorDaemon BEFORE spawn (env can carry launch.env secrets), read by
|
|
17
|
+
* the daemon CLI entry, removed on shutdown. Absent → a bare throwaway/tick session. */
|
|
18
|
+
export const servePath = (runDir: string, session: string): string => join(runDir, `${session}.serve.json`)
|
|
19
|
+
/** Attached-client marker (spawn-flip Ф0b-3 slice 3c): present iff ≥1 supervisor client is attached.
|
|
20
|
+
* The daemon maintains it on every client add/delete; the warm-deliver path (a DIFFERENT process)
|
|
21
|
+
* reads it to gate the hosted busy-composer hold — never paste over a HUMAN's unfinished input, but
|
|
22
|
+
* never hold for the AI's own composer output when no operator is attached. */
|
|
23
|
+
export const attachedPath = (runDir: string, session: string): string => join(runDir, `${session}.attached`)
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
2
6
|
import {
|
|
3
7
|
BackpressureWriter,
|
|
4
8
|
FRAME_DATA,
|
|
@@ -6,12 +10,15 @@ import {
|
|
|
6
10
|
TERM_RESET,
|
|
7
11
|
capabilityResponses,
|
|
8
12
|
frame,
|
|
13
|
+
keysToBytes,
|
|
9
14
|
makeFramer,
|
|
10
15
|
parseSize,
|
|
11
16
|
sizePayload,
|
|
12
17
|
stripQueries,
|
|
13
18
|
} from './protocol.ts'
|
|
14
19
|
|
|
20
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
21
|
+
|
|
15
22
|
describe('frame protocol', () => {
|
|
16
23
|
test('frame() lays out [type][len:u32be][payload]', () => {
|
|
17
24
|
const f = frame(FRAME_DATA, 'hi')
|
|
@@ -129,3 +136,87 @@ describe('BackpressureWriter (port-dep #2: the ~90 KB-over-8 KB truncation that
|
|
|
129
136
|
expect(w.isDead).toBe(true)
|
|
130
137
|
})
|
|
131
138
|
})
|
|
139
|
+
|
|
140
|
+
describe('keysToBytes (tmux send-keys vocabulary → pty bytes, the boot key-set)', () => {
|
|
141
|
+
test('Enter → CR (the trust/proceed dialogs)', () => {
|
|
142
|
+
expect(keysToBytes(['Enter'])).toEqual(Buffer.from('\r'))
|
|
143
|
+
})
|
|
144
|
+
test('codex update-decline [2,Enter] → "2" + CR', () => {
|
|
145
|
+
expect(keysToBytes(['2', 'Enter'])).toEqual(Buffer.from('2\r'))
|
|
146
|
+
})
|
|
147
|
+
test('codex hooks-review [Down,Enter] in NORMAL cursor mode → CSI-B + CR', () => {
|
|
148
|
+
expect(keysToBytes(['Down', 'Enter'])).toEqual(Buffer.from('\x1b[B\r'))
|
|
149
|
+
})
|
|
150
|
+
test('[Down,Enter] under DECCKM application-cursor mode → SS3-B + CR (the mode-sensitivity boris flagged)', () => {
|
|
151
|
+
expect(keysToBytes(['Down', 'Enter'], { appCursorKeys: true })).toEqual(Buffer.from('\x1bOB\r'))
|
|
152
|
+
})
|
|
153
|
+
test('claude resume-picker [Down] → cursor-down only', () => {
|
|
154
|
+
expect(keysToBytes(['Down'])).toEqual(Buffer.from('\x1b[B'))
|
|
155
|
+
})
|
|
156
|
+
test('literal mode -l sends the rest verbatim (executeControl /compact)', () => {
|
|
157
|
+
expect(keysToBytes(['-l', '/compact'])).toEqual(Buffer.from('/compact'))
|
|
158
|
+
})
|
|
159
|
+
test('Escape (interrupt) → ESC', () => {
|
|
160
|
+
expect(keysToBytes(['Escape'])).toEqual(Buffer.from('\x1b'))
|
|
161
|
+
})
|
|
162
|
+
test('an unknown token falls through to literal text', () => {
|
|
163
|
+
expect(keysToBytes(['hello'])).toEqual(Buffer.from('hello'))
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// boris's acceptance bar: the translator must be BYTE-IDENTICAL to real `tmux send-keys` for the
|
|
168
|
+
// key-set the adapters actually return — not a "looks right" table. We capture exactly what tmux
|
|
169
|
+
// delivers to a pane (raw mode bypasses the line discipline so no CR↔NL cooking) and compare.
|
|
170
|
+
// Guarded by tmuxAvailable (a clean CI runner has no tmux → these skip, per the live-tmux lesson).
|
|
171
|
+
function tmuxSendKeysBytes(keys: string[], appCursor: boolean, n: number): Buffer {
|
|
172
|
+
const dir = mkdtempSync(join(tmpdir(), 'pts-keys-'))
|
|
173
|
+
const sock = join(dir, 's.sock')
|
|
174
|
+
const out = join(dir, 'out.bin')
|
|
175
|
+
// The pane program: optionally emit DECCKM (CSI ? 1 h) as its OWN output so tmux tracks
|
|
176
|
+
// application-cursor mode; then raw mode (capture send-keys bytes verbatim); then read exactly n
|
|
177
|
+
// bytes and exit (head flushes on exit — a buffered cat would lose the tail on kill).
|
|
178
|
+
const pre = appCursor ? `printf '\\033[?1h'; ` : ''
|
|
179
|
+
const paneCmd = `${pre}stty raw -echo; head -c ${n} > '${out}'`
|
|
180
|
+
try {
|
|
181
|
+
const create = spawnSync(
|
|
182
|
+
'tmux',
|
|
183
|
+
['-S', sock, 'new-session', '-d', '-s', 'k', '-x', '80', '-y', '24', 'sh', '-c', paneCmd],
|
|
184
|
+
{ stdio: 'ignore' },
|
|
185
|
+
)
|
|
186
|
+
if (create.status !== 0) throw new Error('tmux new-session failed')
|
|
187
|
+
Bun.sleepSync(400) // let the pane reach raw mode + tmux process the DECCKM output
|
|
188
|
+
spawnSync('tmux', ['-S', sock, 'send-keys', '-t', 'k', ...keys], { stdio: 'ignore' })
|
|
189
|
+
const deadline = Date.now() + 3000
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
if (existsSync(out) && statSync(out).size >= n) break
|
|
192
|
+
Bun.sleepSync(50)
|
|
193
|
+
}
|
|
194
|
+
return existsSync(out) ? readFileSync(out) : Buffer.alloc(0)
|
|
195
|
+
} finally {
|
|
196
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
197
|
+
try {
|
|
198
|
+
rmSync(dir, { recursive: true, force: true })
|
|
199
|
+
} catch {
|
|
200
|
+
/* */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe.if(tmuxAvailable)('keysToBytes byte-identity vs real tmux send-keys (live)', () => {
|
|
206
|
+
test('NORMAL cursor mode: [Down,Enter]', () => {
|
|
207
|
+
const ours = keysToBytes(['Down', 'Enter'])
|
|
208
|
+
expect(tmuxSendKeysBytes(['Down', 'Enter'], false, ours.length)).toEqual(ours)
|
|
209
|
+
})
|
|
210
|
+
test('DECCKM application-cursor mode: [Down,Enter] → SS3', () => {
|
|
211
|
+
const ours = keysToBytes(['Down', 'Enter'], { appCursorKeys: true })
|
|
212
|
+
expect(tmuxSendKeysBytes(['Down', 'Enter'], true, ours.length)).toEqual(ours)
|
|
213
|
+
})
|
|
214
|
+
test('literal digit + Enter: [2,Enter]', () => {
|
|
215
|
+
const ours = keysToBytes(['2', 'Enter'])
|
|
216
|
+
expect(tmuxSendKeysBytes(['2', 'Enter'], false, ours.length)).toEqual(ours)
|
|
217
|
+
})
|
|
218
|
+
test('bare Enter', () => {
|
|
219
|
+
const ours = keysToBytes(['Enter'])
|
|
220
|
+
expect(tmuxSendKeysBytes(['Enter'], false, ours.length)).toEqual(ours)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -70,6 +70,55 @@ export function stripQueries(s: string): string {
|
|
|
70
70
|
return s.replace(QUERY_RE, '')
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ── key-name → pty bytes (boot-driver, serving slice a) ─────────────────────────
|
|
74
|
+
// Translate a tmux send-keys-style key list into the raw bytes a pty expects, so the supervisor
|
|
75
|
+
// boot-driver can answer startup dialogs (codex trust/update/hooks, claude trust/resume-picker) off
|
|
76
|
+
// the MODEL exactly as the launch primitive answers them via `tmux send-keys`. PURE — the byte
|
|
77
|
+
// output is unit-comparable to real `tmux send-keys` (the acceptance bar: byte-identity, not a
|
|
78
|
+
// "looks right" table).
|
|
79
|
+
//
|
|
80
|
+
// tmux semantics mirrored for the vocabulary our RuntimeAdapters actually emit (bootDialogKeys /
|
|
81
|
+
// permissionDialogKeys / executeControl): a leading `-l` switches to LITERAL mode (every following
|
|
82
|
+
// token sent verbatim, no key-name lookup); otherwise each token is a KEY NAME when known, else
|
|
83
|
+
// literal text. Cursor keys depend on DECCKM application-cursor-keys mode — `\x1b[B` (normal) vs
|
|
84
|
+
// `\x1bOB` (application) — which tmux keys off the terminal mode and the caller reads from the
|
|
85
|
+
// @xterm model (term.modes.applicationCursorKeysMode), passing it in `enc`. Both codex hooks-review
|
|
86
|
+
// (`['Down','Enter']`) and the claude resume-picker (`['Down']`) put a cursor key in the boot set,
|
|
87
|
+
// so the mode is load-bearing — a hardcoded `\x1b[B` would corrupt the key under DECCKM.
|
|
88
|
+
|
|
89
|
+
export interface KeyEncoding {
|
|
90
|
+
/** DECCKM application-cursor-keys mode (CSI ? 1 h). When set, arrow keys use SS3 (ESC O x)
|
|
91
|
+
* instead of CSI (ESC [ x), matching what tmux send-keys emits in that mode. */
|
|
92
|
+
appCursorKeys?: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const NAMED_KEYS: Record<string, string> = {
|
|
96
|
+
Enter: '\r',
|
|
97
|
+
Escape: '\x1b',
|
|
98
|
+
Tab: '\t',
|
|
99
|
+
Space: ' ',
|
|
100
|
+
BSpace: '\x7f',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Translate `keys` (tmux send-keys vocabulary) to the pty byte stream. See section header. */
|
|
104
|
+
export function keysToBytes(keys: string[], enc: KeyEncoding = {}): Buffer {
|
|
105
|
+
const cur: Record<string, string> = enc.appCursorKeys
|
|
106
|
+
? { Up: '\x1bOA', Down: '\x1bOB', Right: '\x1bOC', Left: '\x1bOD' }
|
|
107
|
+
: { Up: '\x1b[A', Down: '\x1b[B', Right: '\x1b[C', Left: '\x1b[D' }
|
|
108
|
+
let literal = false
|
|
109
|
+
let out = ''
|
|
110
|
+
for (const k of keys) {
|
|
111
|
+
if (!literal && k === '-l') {
|
|
112
|
+
literal = true
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (!literal && k in NAMED_KEYS) out += NAMED_KEYS[k]
|
|
116
|
+
else if (!literal && k in cur) out += cur[k]
|
|
117
|
+
else out += k
|
|
118
|
+
}
|
|
119
|
+
return Buffer.from(out, 'utf8')
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
// ── port-dep #1: terminal reset on every client exit path ───────────────────────
|
|
74
123
|
// The child sets a STACK of app-modes on the live terminal (alt-screen, bracketed-paste,
|
|
75
124
|
// focus-events, theme-update, ALL mouse tracking, kitty-keyboard, scroll-region, hidden cursor).
|
|
@@ -144,6 +144,36 @@ describe('createComposerDeliveryQueue', () => {
|
|
|
144
144
|
expect(failed.join('\n')).toMatch(/vanished|replaced/)
|
|
145
145
|
})
|
|
146
146
|
|
|
147
|
+
test('async-correctness: concurrent enqueue with ASYNC predicates → each delivered exactly once, no dup/loss (Ф0b-3 3c-2)', async () => {
|
|
148
|
+
// The host-aware refactor makes shouldQueue/hasHumanInput/deliver awaitable. Inject ASYNC seams
|
|
149
|
+
// (a real yield window) and fire concurrent enqueues — the FIFO + per-identity drain guard must
|
|
150
|
+
// still deliver each envelope EXACTLY once (no race-doubled, no race-lost).
|
|
151
|
+
let held = true
|
|
152
|
+
const delivered: string[] = []
|
|
153
|
+
const q = createComposerDeliveryQueue({
|
|
154
|
+
pollMs: 5,
|
|
155
|
+
forceTimeoutMs: 2000,
|
|
156
|
+
shouldQueue: async () => { await sleep(4); return true },
|
|
157
|
+
hasHumanInput: async () => { await sleep(2); return held },
|
|
158
|
+
sessionToken: () => 'session-1:pane-1',
|
|
159
|
+
sessionAlive: () => true,
|
|
160
|
+
deliver: async (_target, envelope) => { await sleep(1); delivered.push(envelope); return ok(undefined) },
|
|
161
|
+
notifyFailed: () => { /* */ },
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const acks = await Promise.all([
|
|
165
|
+
q.tryEnqueue(args('msg-A')),
|
|
166
|
+
q.tryEnqueue(args('msg-B')),
|
|
167
|
+
q.tryEnqueue(args('msg-C')),
|
|
168
|
+
])
|
|
169
|
+
expect(acks.every(a => a?.ok)).toBe(true)
|
|
170
|
+
await sleep(40)
|
|
171
|
+
expect(delivered).toEqual([]) // all three held while busy
|
|
172
|
+
held = false
|
|
173
|
+
await sleep(80)
|
|
174
|
+
expect(delivered.slice().sort()).toEqual(['msg-A', 'msg-B', 'msg-C']) // each ONCE — no dup, no loss
|
|
175
|
+
})
|
|
176
|
+
|
|
147
177
|
test('daemon close/restart failAll re-fails every queued-but-undelivered envelope', async () => {
|
|
148
178
|
const delivered: string[] = []
|
|
149
179
|
const failed: string[] = []
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Spawn-flip cutover Block 2, Ф0b-2 slice 3 — host-aware in-session control (interrupt / compact).
|
|
2
|
+
//
|
|
3
|
+
// executeControlOnTarget becomes host-aware: a supervisor-HOSTED target has no `tmux send-keys`, so
|
|
4
|
+
// each control plan step is translated to pty bytes by keysToBytes (REUSED from the boot-driver) and
|
|
5
|
+
// sent over the supervisor socket. These suites cover the host branch hermetically (a seam injects the
|
|
6
|
+
// host-detect + the socket control-send) — asserting the BYTE TRANSLATION + dispatch. The live
|
|
7
|
+
// "interrupt really interrupts a hosted turn" effect is proven separately against a real hosted codex.
|
|
8
|
+
import { describe, expect, test } from 'bun:test'
|
|
9
|
+
import { executeControlOnTarget, type ControlHostSeam, type DeliveryTarget } from './index.ts'
|
|
10
|
+
|
|
11
|
+
const hostedCodex: DeliveryTarget = {
|
|
12
|
+
runtime: 'codex',
|
|
13
|
+
personality: 'ptyx',
|
|
14
|
+
address: 'codex-ptyx',
|
|
15
|
+
socketPath: '/tmp/nonexistent-iap-test.sock',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Captured {
|
|
19
|
+
session: string
|
|
20
|
+
chunks: Buffer[]
|
|
21
|
+
stepDelayMs?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function capturingSeam(captured: Captured[]): ControlHostSeam {
|
|
25
|
+
return {
|
|
26
|
+
hostAlive: () => true,
|
|
27
|
+
sendControl: async (_runDir, session, chunks, opts) => {
|
|
28
|
+
captured.push({ session, chunks, stepDelayMs: opts.stepDelayMs })
|
|
29
|
+
return { ok: true }
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('executeControlOnTarget — hosted control over the socket (slice 3)', () => {
|
|
35
|
+
test('interrupt → a single bare ESC byte (\\x1b), no step delay', async () => {
|
|
36
|
+
const cap: Captured[] = []
|
|
37
|
+
const r = await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, capturingSeam(cap))
|
|
38
|
+
expect(r.ok).toBe(true)
|
|
39
|
+
expect(cap).toHaveLength(1)
|
|
40
|
+
expect(cap[0].session).toBe('codex-ptyx')
|
|
41
|
+
expect(cap[0].chunks.map(b => b.toString('latin1'))).toEqual(['\x1b'])
|
|
42
|
+
expect(cap[0].stepDelayMs).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('compact → "/compact" literal then Enter (CR), with the 300ms step delay', async () => {
|
|
46
|
+
const cap: Captured[] = []
|
|
47
|
+
const r = await executeControlOnTarget(hostedCodex, { name: 'compact' }, capturingSeam(cap))
|
|
48
|
+
expect(r.ok).toBe(true)
|
|
49
|
+
expect(cap[0].chunks.map(b => b.toString('latin1'))).toEqual(['/compact', '\r'])
|
|
50
|
+
expect(cap[0].stepDelayMs).toBe(300)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('flag-off (hostAlive=false) → the socket control-send is NEVER touched (tmux path)', async () => {
|
|
54
|
+
let sendCalls = 0
|
|
55
|
+
const seam: ControlHostSeam = {
|
|
56
|
+
hostAlive: () => false,
|
|
57
|
+
sendControl: async () => {
|
|
58
|
+
sendCalls++
|
|
59
|
+
return { ok: true }
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
// tmux send-keys will fail against the bogus socket — incidental; the contract is that flag-off
|
|
63
|
+
// never reaches the host branch.
|
|
64
|
+
await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, seam)
|
|
65
|
+
expect(sendCalls).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('socket control-send fails → loud error, no false ok', async () => {
|
|
69
|
+
const seam: ControlHostSeam = {
|
|
70
|
+
hostAlive: () => true,
|
|
71
|
+
sendControl: async () => ({ ok: false, error: 'socket dead during control send' }),
|
|
72
|
+
}
|
|
73
|
+
const r = await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, seam)
|
|
74
|
+
expect(r.ok).toBe(false)
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
expect(r.error.message).toContain('hosted control "interrupt" failed')
|
|
77
|
+
expect(r.error.message).toContain('socket dead')
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('unsupported command for the runtime → explicit refusal (router runtime, no control plan)', async () => {
|
|
82
|
+
const router: DeliveryTarget = { runtime: 'telegram', personality: 'tg', address: 'telegram-tg', socketPath: '/tmp/x.sock' }
|
|
83
|
+
const r = await executeControlOnTarget(router, { name: 'interrupt' }, capturingSeam([]))
|
|
84
|
+
expect(r.ok).toBe(false)
|
|
85
|
+
if (!r.ok) expect(r.error.message).toContain('does not support control command')
|
|
86
|
+
})
|
|
87
|
+
})
|