@agfpd/iapeer 0.1.0
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/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// Transport — liveness scan + delivery target resolution + tmux delivery, plus
|
|
2
|
+
// the Ф1 route-and-deliver orchestration (NO wake yet — a miss returns an
|
|
3
|
+
// explicit "offline" signal; wake-on-miss lands in Ф2). Consolidated from
|
|
4
|
+
// inter-agent-protocol/src/lib/transport.ts (wins) + send.ts (rewritten so the
|
|
5
|
+
// caller identity comes FROM THE REQUEST, not a per-process Identity).
|
|
6
|
+
//
|
|
7
|
+
// The TUI submit TIMING (inputHoldsPaste / submitIntoTui poll+re-press / deliver
|
|
8
|
+
// ViaTmux byte layout) is ported BYTE-FOR-BYTE — it is the validated 0.7.6
|
|
9
|
+
// deterministic submit and must not be refactored (anti-regression of the
|
|
10
|
+
// Enter-swallow flap; blueprint §5 "submit-надёжность"). The ONLY contract-
|
|
11
|
+
// sanctioned change (07.06 refactor, docs/Рантайм-адаптеры): the prompt glyphs /
|
|
12
|
+
// paste patterns are no longer a hardcoded union here — they come from the target
|
|
13
|
+
// runtime's adapter.deliveryMarkers. The poll/re-press logic is untouched; only the
|
|
14
|
+
// marker SOURCE moved, so the timing behaviour is identical.
|
|
15
|
+
|
|
16
|
+
import { readdirSync } from 'fs'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import { spawnSync } from 'child_process'
|
|
19
|
+
import {
|
|
20
|
+
MAX_ATTACHMENTS,
|
|
21
|
+
MAX_MESSAGE_LEN,
|
|
22
|
+
MAX_TOPIC_LEN,
|
|
23
|
+
isRuntime,
|
|
24
|
+
isSupportedLocalRuntime,
|
|
25
|
+
isValidName,
|
|
26
|
+
resolveSockDir,
|
|
27
|
+
type TmuxRuntime,
|
|
28
|
+
} from '../core/constants.ts'
|
|
29
|
+
import { err, ok, type Result } from '../core/errors.ts'
|
|
30
|
+
import {
|
|
31
|
+
buildProcessAddress,
|
|
32
|
+
buildSocketPath,
|
|
33
|
+
parseSessionName,
|
|
34
|
+
parseSocketPath,
|
|
35
|
+
type ProcessAddress,
|
|
36
|
+
} from '../core/socket.ts'
|
|
37
|
+
import { buildEnvelope } from '../codec/index.ts'
|
|
38
|
+
import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
|
|
39
|
+
import type { ResolvedCaller } from '../identity/index.ts'
|
|
40
|
+
// Delivery markers are OWNED by the runtime adapter (07.06 refactor). transport
|
|
41
|
+
// reads them from getAdapter(target.runtime) for the tui submit path. One-way
|
|
42
|
+
// dependency: launch does NOT import transport, so no cycle.
|
|
43
|
+
import { getAdapter } from '../launch/index.ts'
|
|
44
|
+
import type { ControlCommand, DeliveryMarkers } from '../launch/types.ts'
|
|
45
|
+
|
|
46
|
+
export interface OnlinePeer {
|
|
47
|
+
personality: string
|
|
48
|
+
runtime: TmuxRuntime
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DeliveryTarget extends ProcessAddress {
|
|
52
|
+
socketPath: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TmuxResult {
|
|
56
|
+
ok: boolean
|
|
57
|
+
out: string
|
|
58
|
+
err: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function tmux(sock: string, ...args: string[]): TmuxResult {
|
|
62
|
+
const r = spawnSync('tmux', ['-S', sock, ...args], { encoding: 'utf8' })
|
|
63
|
+
return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Settle delay between bracketed paste and the submit Enter — non-TUI path only.
|
|
67
|
+
const PASTE_SETTLE_MS = (() => {
|
|
68
|
+
const raw = process.env.IAP_TMUX_PASTE_SETTLE_MS
|
|
69
|
+
const n = raw === undefined ? NaN : Number(raw)
|
|
70
|
+
return Number.isFinite(n) && n >= 0 ? n : 250
|
|
71
|
+
})()
|
|
72
|
+
|
|
73
|
+
const SUBMIT_POLL_MS = 40
|
|
74
|
+
const SUBMIT_LANDED_TIMEOUT_MS = 1500
|
|
75
|
+
const SUBMIT_CONFIRM_WINDOW_MS = 500
|
|
76
|
+
function submitTotalTimeoutMs(): number {
|
|
77
|
+
const raw = process.env.IAP_TMUX_SUBMIT_TIMEOUT_MS
|
|
78
|
+
const n = raw === undefined ? NaN : Number(raw)
|
|
79
|
+
return Number.isFinite(n) && n >= 0 ? n : 4000
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sleepSync(ms: number): void {
|
|
83
|
+
if (ms <= 0) return
|
|
84
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function monotonicMs(): number {
|
|
88
|
+
return Number(process.hrtime.bigint() / 1_000_000n)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listSessions(sock: string): string[] {
|
|
92
|
+
const r = tmux(sock, 'list-sessions', '-F', '#{session_name}')
|
|
93
|
+
if (!r.ok) return []
|
|
94
|
+
return r.out.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sessionAlive(sock: string, address: string): boolean {
|
|
98
|
+
return listSessions(sock).includes(address)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
|
|
102
|
+
export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir()): boolean {
|
|
103
|
+
const socketPath = buildSocketPath(runtime, personality, sockDir)
|
|
104
|
+
return sessionAlive(socketPath, buildProcessAddress(runtime, personality))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function listOnlinePeers(sockDir = resolveSockDir()): OnlinePeer[] {
|
|
108
|
+
let entries: string[]
|
|
109
|
+
try {
|
|
110
|
+
entries = readdirSync(sockDir)
|
|
111
|
+
} catch {
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
const out = new Map<string, OnlinePeer>()
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const parsedSocket = parseSocketPath(join(sockDir, entry))
|
|
117
|
+
if (!parsedSocket) continue
|
|
118
|
+
for (const sessionName of listSessions(join(sockDir, entry))) {
|
|
119
|
+
const parsedSession = parseSessionName(sessionName)
|
|
120
|
+
if (!parsedSession) continue
|
|
121
|
+
if (parsedSession.address !== parsedSocket.address) continue
|
|
122
|
+
out.set(parsedSession.address, {
|
|
123
|
+
personality: parsedSession.personality,
|
|
124
|
+
runtime: parsedSession.runtime,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return Array.from(out.values()).sort(
|
|
129
|
+
(a, b) =>
|
|
130
|
+
a.personality.localeCompare(b.personality) || a.runtime.localeCompare(b.runtime),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resolveDeliveryTarget(args: {
|
|
135
|
+
personality: string
|
|
136
|
+
runtime?: string
|
|
137
|
+
sockDir?: string
|
|
138
|
+
}): Result<DeliveryTarget> {
|
|
139
|
+
const sockDir = args.sockDir ?? resolveSockDir()
|
|
140
|
+
if (!isValidName(args.personality)) {
|
|
141
|
+
return err(`invalid personality "${args.personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`)
|
|
142
|
+
}
|
|
143
|
+
if (args.runtime) {
|
|
144
|
+
if (!isRuntime(args.runtime)) return err(`invalid runtime "${args.runtime}"`)
|
|
145
|
+
const runtime = args.runtime
|
|
146
|
+
const socketPath = buildSocketPath(runtime, args.personality, sockDir)
|
|
147
|
+
const address = buildProcessAddress(runtime, args.personality)
|
|
148
|
+
if (!sessionAlive(socketPath, address)) {
|
|
149
|
+
return err(`peer offline: ${args.personality} (${args.runtime})`)
|
|
150
|
+
}
|
|
151
|
+
return ok({ runtime, personality: args.personality, address, socketPath })
|
|
152
|
+
}
|
|
153
|
+
const matches = listOnlinePeers(sockDir).filter(peer => peer.personality === args.personality)
|
|
154
|
+
if (matches.length === 0) return err(`peer offline: ${args.personality}`)
|
|
155
|
+
if (matches.length > 1) {
|
|
156
|
+
return err(
|
|
157
|
+
`${args.personality} is online in multiple runtimes (${matches
|
|
158
|
+
.map(peer => peer.runtime)
|
|
159
|
+
.join(', ')}) — specify runtime`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
const match = matches[0]
|
|
163
|
+
const socketPath = buildSocketPath(match.runtime, match.personality, sockDir)
|
|
164
|
+
return ok({
|
|
165
|
+
runtime: match.runtime,
|
|
166
|
+
personality: match.personality,
|
|
167
|
+
address: buildProcessAddress(match.runtime, match.personality),
|
|
168
|
+
socketPath,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resolvePeerDeliveryTarget(
|
|
173
|
+
personality: string,
|
|
174
|
+
runtime: string | undefined,
|
|
175
|
+
peer: PeerRecord,
|
|
176
|
+
): Result<DeliveryTarget> {
|
|
177
|
+
if (runtime) return resolveDeliveryTarget({ personality, runtime })
|
|
178
|
+
if (peer.runtime) {
|
|
179
|
+
const exactDefault = resolveDeliveryTarget({ personality, runtime: peer.runtime })
|
|
180
|
+
if (exactDefault.ok) return exactDefault
|
|
181
|
+
}
|
|
182
|
+
return resolveDeliveryTarget({ personality })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── TUI submit (ported byte-for-byte from transport.ts) ────────────────────
|
|
186
|
+
|
|
187
|
+
function inputHoldsPaste(
|
|
188
|
+
sock: string,
|
|
189
|
+
address: string,
|
|
190
|
+
tailMarker: string,
|
|
191
|
+
markers: DeliveryMarkers,
|
|
192
|
+
): boolean {
|
|
193
|
+
const cap = tmux(sock, 'capture-pane', '-p', '-t', address)
|
|
194
|
+
if (!cap.ok) return true
|
|
195
|
+
const lines = cap.out.split('\n')
|
|
196
|
+
let promptRow = -1
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
const ch = lines[i]?.[0] ?? ''
|
|
199
|
+
if (markers.promptGlyphs.includes(ch)) promptRow = i
|
|
200
|
+
}
|
|
201
|
+
if (promptRow < 0) return true
|
|
202
|
+
const band = lines.slice(promptRow).join('\n')
|
|
203
|
+
if (band.includes(tailMarker)) return true
|
|
204
|
+
return markers.pastePatterns?.some(re => re.test(band)) ?? false
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Returns TRUE iff the input cleared after Enter within the budget — i.e. an
|
|
208
|
+
// (idle, responsive) TUI accepted the submit. FALSE on timeout (the input never
|
|
209
|
+
// cleared): either a DEAD/hung session, OR a BUSY session mid-turn whose input row
|
|
210
|
+
// is not even rendered (then liveness is confirmed by the transcript-mtime advance
|
|
211
|
+
// in deliverViaTmux, not here). The poll/re-press TIMING is the byte-for-byte 0.7.6
|
|
212
|
+
// port; only the return value is new (Ф-B: feed the delivery-liveness decision).
|
|
213
|
+
function submitIntoTui(sock: string, address: string, envelope: string, markers: DeliveryMarkers): boolean {
|
|
214
|
+
const tailMarker =
|
|
215
|
+
envelope.split('\n').map(line => line.trim()).filter(Boolean).pop() ?? envelope.trim()
|
|
216
|
+
const start = monotonicMs()
|
|
217
|
+
while (monotonicMs() - start < SUBMIT_LANDED_TIMEOUT_MS) {
|
|
218
|
+
if (inputHoldsPaste(sock, address, tailMarker, markers)) break
|
|
219
|
+
sleepSync(SUBMIT_POLL_MS)
|
|
220
|
+
}
|
|
221
|
+
while (monotonicMs() - start < submitTotalTimeoutMs()) {
|
|
222
|
+
tmux(sock, 'send-keys', '-t', address, 'Enter')
|
|
223
|
+
const windowStart = monotonicMs()
|
|
224
|
+
while (
|
|
225
|
+
monotonicMs() - windowStart < SUBMIT_CONFIRM_WINDOW_MS &&
|
|
226
|
+
monotonicMs() - start < submitTotalTimeoutMs()
|
|
227
|
+
) {
|
|
228
|
+
sleepSync(SUBMIT_POLL_MS)
|
|
229
|
+
if (!inputHoldsPaste(sock, address, tailMarker, markers)) return true
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Ф-B liveness: how long to keep polling the activity proxy for a transcript
|
|
236
|
+
// advance when the submit did NOT clear the input (a busy session whose input row
|
|
237
|
+
// is not rendered). Env-tunable for LIVE calibration (busy/idle/cold). Conservative
|
|
238
|
+
// default — long enough that a busy-but-alive session reliably shows an advance,
|
|
239
|
+
// short enough that a genuinely dead session is failed promptly. Prefer a false-
|
|
240
|
+
// FAIL (sender retries) over a false-OK (silent loss, which the contract forbids).
|
|
241
|
+
const LIVENESS_POLL_MS = 100
|
|
242
|
+
function livenessGraceMs(): number {
|
|
243
|
+
const raw = process.env.IAP_LIVENESS_GRACE_MS
|
|
244
|
+
const n = raw === undefined ? NaN : Number(raw)
|
|
245
|
+
return Number.isFinite(n) && n >= 0 ? n : 3000
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Deliver `envelope` into the target's tmux session and CONFIRM it landed in a LIVE
|
|
250
|
+
* session (Ф-B delivery guarantee, contract Демон §Гарантия доставки). `cwd` (the
|
|
251
|
+
* target peer's working dir) enables the transcript-mtime liveness probe; omit it and
|
|
252
|
+
* the tui path falls back to submit-confirmation only (existing direct callers/tests).
|
|
253
|
+
*
|
|
254
|
+
* tui liveness — ok ⟺ message landed in a live session, by EITHER strong signal:
|
|
255
|
+
* (a) submit confirmed — the input cleared after Enter (an idle session accepted it);
|
|
256
|
+
* (b) transcript-mtime advanced past the pre-submit baseline (a BUSY session is
|
|
257
|
+
* mid-turn — our message is queued and surfaces on its next tool call).
|
|
258
|
+
* Neither within the grace budget → the session is listed live but UNRESPONSIVE
|
|
259
|
+
* (dead/hung) → fail LOUDLY. Silent loss is forbidden; when in doubt prefer a false-
|
|
260
|
+
* FAIL (sender retries) over a false-OK. router (telegram/notifier) is always-on by
|
|
261
|
+
* launchd → its liveness is structural, the C-j path keeps its prior semantics.
|
|
262
|
+
*/
|
|
263
|
+
export function deliverViaTmux(target: DeliveryTarget, envelope: string, cwd?: string): Result<void> {
|
|
264
|
+
const bufferName = `iap-${process.pid}-${Date.now()}`
|
|
265
|
+
const load = spawnSync(
|
|
266
|
+
'tmux',
|
|
267
|
+
['-S', target.socketPath, 'load-buffer', '-b', bufferName, '-'],
|
|
268
|
+
{ input: envelope, encoding: 'utf8' },
|
|
269
|
+
)
|
|
270
|
+
if (load.status !== 0) {
|
|
271
|
+
return err(`tmux load-buffer failed: ${(load.stderr ?? '').trim() || `exit ${load.status}`}`)
|
|
272
|
+
}
|
|
273
|
+
const isTui = isSupportedLocalRuntime(target.runtime)
|
|
274
|
+
const pasteArgs = isTui
|
|
275
|
+
? ['paste-buffer', '-p', '-b', bufferName, '-t', target.address]
|
|
276
|
+
: ['paste-buffer', '-p', '-r', '-b', bufferName, '-t', target.address]
|
|
277
|
+
const paste = tmux(target.socketPath, ...pasteArgs)
|
|
278
|
+
if (!paste.ok) {
|
|
279
|
+
tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
|
|
280
|
+
return err(`tmux paste-buffer failed: ${paste.err.trim() || 'unknown error'}`)
|
|
281
|
+
}
|
|
282
|
+
if (isTui) {
|
|
283
|
+
const adapter = getAdapter(target.runtime)
|
|
284
|
+
// Baseline the activity proxy BEFORE the submit (a paste does not move the
|
|
285
|
+
// transcript — only a model turn does), so an advance afterwards proves a busy
|
|
286
|
+
// session is alive. null proxy / no cwd → 0 (the advance check is skipped below).
|
|
287
|
+
const baselineMtime = cwd ? adapter.newestActivityMtime(cwd) ?? 0 : 0
|
|
288
|
+
// Submit markers come from the target runtime's adapter (07.06 refactor).
|
|
289
|
+
const confirmed = submitIntoTui(target.socketPath, target.address, envelope, adapter.deliveryMarkers)
|
|
290
|
+
tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
|
|
291
|
+
if (confirmed) return ok(undefined) // idle session accepted the submit
|
|
292
|
+
if (!cwd) return ok(undefined) // no activity proxy available → confirmed-only (legacy callers)
|
|
293
|
+
// Busy session: the input never cleared, so confirm liveness by a transcript
|
|
294
|
+
// advance. Grace-poll the proxy (the model may take a beat to write its turn).
|
|
295
|
+
const graceDeadline = monotonicMs() + livenessGraceMs()
|
|
296
|
+
do {
|
|
297
|
+
if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ok(undefined)
|
|
298
|
+
sleepSync(LIVENESS_POLL_MS)
|
|
299
|
+
} while (monotonicMs() < graceDeadline)
|
|
300
|
+
return err(
|
|
301
|
+
`peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
302
|
+
`(no input-clear, no transcript advance within ${livenessGraceMs()}ms) — treated as dead; message NOT delivered`,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
sleepSync(PASTE_SETTLE_MS)
|
|
306
|
+
const enter = tmux(target.socketPath, 'send-keys', '-t', target.address, 'C-j')
|
|
307
|
+
tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
|
|
308
|
+
if (!enter.ok) return err(`tmux send-keys failed: ${enter.err.trim() || 'unknown error'}`)
|
|
309
|
+
return ok(undefined)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Control channel (Ф-E) — in-session control via the adapter, UNCONDITIONAL ──
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Perform an in-session control command on a LIVE target (Ф-E, docs/Control-команды).
|
|
316
|
+
* The target's adapter maps the abstract command to a tmux send-keys sequence
|
|
317
|
+
* (ControlPlan); each step is sent IN ORDER, UNCONDITIONALLY — NO ready-gate, NO
|
|
318
|
+
* submit-confirm (the point of `interrupt` is to break a stuck/raving turn exactly
|
|
319
|
+
* when normal delivery would not land). An unsupported command (router runtimes,
|
|
320
|
+
* unknown name) → explicit refusal, not a silent no-op.
|
|
321
|
+
*/
|
|
322
|
+
export function executeControlOnTarget(target: DeliveryTarget, command: ControlCommand): Result<void> {
|
|
323
|
+
const plan = getAdapter(target.runtime).executeControl(command)
|
|
324
|
+
if (!plan) {
|
|
325
|
+
return err(`runtime "${target.runtime}" does not support control command "${command.name}"`)
|
|
326
|
+
}
|
|
327
|
+
for (const keys of plan.sequence) {
|
|
328
|
+
const r = tmux(target.socketPath, 'send-keys', '-t', target.address, ...keys)
|
|
329
|
+
if (!r.ok) return err(`tmux send-keys failed for control "${command.name}": ${r.err.trim() || 'unknown error'}`)
|
|
330
|
+
if (plan.stepDelayMs) sleepSync(plan.stepDelayMs)
|
|
331
|
+
}
|
|
332
|
+
return ok(undefined)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface ControlResult {
|
|
336
|
+
ok: true
|
|
337
|
+
controlled: { personality: string; runtime: string }
|
|
338
|
+
command: string
|
|
339
|
+
ts: string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Route an in-session control command to a peer: resolve the LIVE target (control
|
|
344
|
+
* acts on a running session — a non-live peer has nothing to interrupt) then
|
|
345
|
+
* executeControlOnTarget. Without a runtime, resolves the single live runtime (2+ →
|
|
346
|
+
* "specify runtime"). The clean-slash control namespace (interrupt/compact/…) is the
|
|
347
|
+
* caller's; /alias-* expansions are message, not control (docs/Control §namespace).
|
|
348
|
+
*/
|
|
349
|
+
export function routeControl(personality: string, runtime: string | undefined, command: ControlCommand): Result<ControlResult> {
|
|
350
|
+
if (!isValidName(personality)) {
|
|
351
|
+
return err(`invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`)
|
|
352
|
+
}
|
|
353
|
+
if (runtime && !isRuntime(runtime)) return err(`invalid runtime "${runtime}"`)
|
|
354
|
+
const target = resolveDeliveryTarget({ personality, runtime })
|
|
355
|
+
if (!target.ok) return err(`cannot control "${personality}": ${target.error.message}`)
|
|
356
|
+
const done = executeControlOnTarget(target.value, command)
|
|
357
|
+
if (!done.ok) return done
|
|
358
|
+
return ok({
|
|
359
|
+
ok: true,
|
|
360
|
+
controlled: { personality: target.value.personality, runtime: target.value.runtime },
|
|
361
|
+
command: command.name,
|
|
362
|
+
ts: new Date().toISOString(),
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── Route + deliver orchestration (Ф1: no wake; miss → explicit offline) ────
|
|
367
|
+
|
|
368
|
+
export interface SendToPeerInput {
|
|
369
|
+
personality: string
|
|
370
|
+
runtime?: string
|
|
371
|
+
message: string
|
|
372
|
+
topic?: string
|
|
373
|
+
attachments?: readonly string[]
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface RouteResult {
|
|
377
|
+
ok: true
|
|
378
|
+
delivered_to: { personality: string; runtime: string }
|
|
379
|
+
woke: boolean
|
|
380
|
+
ts: string
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
|
|
384
|
+
// lifecycle; the daemon wires lifecycle.wakeOrSpawn as this contract — §2). H4
|
|
385
|
+
// (don't wake launchd peers) and the wake-runtime choice live inside the impl.
|
|
386
|
+
export interface WakeRequest {
|
|
387
|
+
personality: string
|
|
388
|
+
runtime?: string
|
|
389
|
+
topic?: string
|
|
390
|
+
/** First message delivered to the woken session (the routed envelope). */
|
|
391
|
+
task: string
|
|
392
|
+
}
|
|
393
|
+
export interface WakeOutcome {
|
|
394
|
+
status: 'READY' | 'FAILED'
|
|
395
|
+
woke: boolean
|
|
396
|
+
runtime?: string
|
|
397
|
+
process_address?: string
|
|
398
|
+
reason?: string
|
|
399
|
+
/** C1: the peer is durably STOPPED (a deliberate operator halt) — the wake was
|
|
400
|
+
* refused, not failed. routeSend surfaces an explicit "stopped" error to the
|
|
401
|
+
* sender (contract Демон §stopped: stopped → no wake, no queue, clear error). */
|
|
402
|
+
stopped?: boolean
|
|
403
|
+
}
|
|
404
|
+
export type WakeFn = (req: WakeRequest) => Promise<WakeOutcome>
|
|
405
|
+
|
|
406
|
+
export interface RouteDeps {
|
|
407
|
+
/** On a miss, wake the dead peer instead of returning offline (Ф2). */
|
|
408
|
+
wake?: WakeFn
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function truncateTopic(raw: string | undefined): string | undefined {
|
|
412
|
+
if (!raw) return undefined
|
|
413
|
+
return raw.slice(0, MAX_TOPIC_LEN)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function validateAttachments(value: readonly string[] = []): Result<string[]> {
|
|
417
|
+
if (value.length > MAX_ATTACHMENTS) return err(`attachments exceeds ${MAX_ATTACHMENTS} item limit`)
|
|
418
|
+
const out: string[] = []
|
|
419
|
+
for (const item of value) {
|
|
420
|
+
if (typeof item !== 'string' || !item.startsWith('/')) {
|
|
421
|
+
return err('attachments must contain only absolute local paths')
|
|
422
|
+
}
|
|
423
|
+
// Audit #17: a newline in a path corrupts the envelope's attachments framing (paths
|
|
424
|
+
// are joined by '\n') — reject it rather than emit a malformed envelope.
|
|
425
|
+
if (/[\n\r]/.test(item)) {
|
|
426
|
+
return err('attachment paths must not contain newline characters')
|
|
427
|
+
}
|
|
428
|
+
out.push(item)
|
|
429
|
+
}
|
|
430
|
+
return ok(out)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Route a send from a request-resolved caller to a target peer and deliver it.
|
|
435
|
+
* hit (live) → build envelope from the CALLER → deliverViaTmux → {woke:false}
|
|
436
|
+
* miss (dead) → if deps.wake: wake the peer (envelope = boot first-message) →
|
|
437
|
+
* verify-before-act re-resolve → {woke:true}; else explicit
|
|
438
|
+
* "offline" (Ф1 behaviour). H4 (don't wake launchd peers) and the
|
|
439
|
+
* wake-runtime choice live INSIDE the injected WakeFn.
|
|
440
|
+
* "cannot send to self" compares the resolved target address with the CALLER's
|
|
441
|
+
* address from the request, not a per-process identity.
|
|
442
|
+
*/
|
|
443
|
+
export async function routeSend(
|
|
444
|
+
caller: ResolvedCaller,
|
|
445
|
+
input: SendToPeerInput,
|
|
446
|
+
deps: RouteDeps = {},
|
|
447
|
+
): Promise<Result<RouteResult>> {
|
|
448
|
+
const { personality, runtime, message } = input
|
|
449
|
+
const topic = truncateTopic(input.topic)
|
|
450
|
+
const attachmentsResult = validateAttachments(input.attachments ?? [])
|
|
451
|
+
if (!attachmentsResult.ok) return attachmentsResult
|
|
452
|
+
|
|
453
|
+
if (!personality || !message) {
|
|
454
|
+
return err('send_to_peer requires non-empty "personality" and "message"')
|
|
455
|
+
}
|
|
456
|
+
if (!isValidName(personality)) {
|
|
457
|
+
return err(`invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`)
|
|
458
|
+
}
|
|
459
|
+
if (runtime && !isRuntime(runtime)) {
|
|
460
|
+
return err(`invalid runtime "${runtime}"`)
|
|
461
|
+
}
|
|
462
|
+
if (input.topic && input.topic.length > MAX_TOPIC_LEN) {
|
|
463
|
+
return err(`topic exceeds ${MAX_TOPIC_LEN} char limit (got ${input.topic.length})`)
|
|
464
|
+
}
|
|
465
|
+
if (message.length > MAX_MESSAGE_LEN) {
|
|
466
|
+
return err(`message exceeds ${MAX_MESSAGE_LEN} char limit (got ${message.length})`)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const index = readPeersIndex()
|
|
470
|
+
const peer = findPeer(index, personality)
|
|
471
|
+
if (!peer) {
|
|
472
|
+
return err(`peer "${personality}" is not in the IAPeer peers index; message NOT delivered`)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Built once — it is both the live-delivery payload and, on a miss, the wake
|
|
476
|
+
// first-message (the woken session receives it as its boot task).
|
|
477
|
+
const envelope = buildEnvelope({
|
|
478
|
+
fromPersonality: caller.personality,
|
|
479
|
+
fromRuntime: caller.runtime,
|
|
480
|
+
fromIntelligence: caller.intelligence,
|
|
481
|
+
topic,
|
|
482
|
+
attachments: attachmentsResult.value,
|
|
483
|
+
message,
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const target = resolvePeerDeliveryTarget(personality, runtime, peer)
|
|
487
|
+
if (target.ok) {
|
|
488
|
+
if (target.value.address === caller.address) return err('cannot send to self')
|
|
489
|
+
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
490
|
+
const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
|
|
491
|
+
if (!delivered.ok) return delivered
|
|
492
|
+
return ok({
|
|
493
|
+
ok: true,
|
|
494
|
+
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
495
|
+
woke: false,
|
|
496
|
+
ts: new Date().toISOString(),
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// MISS — peer offline.
|
|
501
|
+
if (!deps.wake) return target // Ф1: explicit offline, no wake
|
|
502
|
+
const woke = await deps.wake({ personality, runtime, topic, task: envelope })
|
|
503
|
+
if (woke.status === 'FAILED') {
|
|
504
|
+
// C1 — a durably STOPPED peer is a deliberate halt, not a transient miss:
|
|
505
|
+
// surface the explicit "stopped" reason (no "offline and wake failed" wrapping).
|
|
506
|
+
if (woke.stopped) return err(woke.reason ?? `peer "${personality}" is stopped and not accepting messages`)
|
|
507
|
+
return err(`peer "${personality}" offline and wake failed: ${woke.reason ?? 'unknown'}`)
|
|
508
|
+
}
|
|
509
|
+
// verify-before-act: re-resolve the now-live target before declaring success.
|
|
510
|
+
const live = resolvePeerDeliveryTarget(personality, woke.runtime, peer)
|
|
511
|
+
if (!live.ok) {
|
|
512
|
+
return err(`woke "${personality}" but the session is not live (verify-before-act): ${live.error.message}`)
|
|
513
|
+
}
|
|
514
|
+
if (live.value.address === caller.address) return err('cannot send to self')
|
|
515
|
+
// The envelope was delivered as the boot first-message during wake.
|
|
516
|
+
return ok({
|
|
517
|
+
ok: true,
|
|
518
|
+
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
519
|
+
woke: true,
|
|
520
|
+
ts: new Date().toISOString(),
|
|
521
|
+
})
|
|
522
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"verbatimModuleSyntax": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"types": ["bun", "node"],
|
|
14
|
+
"lib": ["ESNext"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts"]
|
|
17
|
+
}
|