@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.
Files changed (63) hide show
  1. package/bin/iapeer +25 -0
  2. package/package.json +37 -0
  3. package/src/cli/cli.test.ts +130 -0
  4. package/src/cli/index.ts +608 -0
  5. package/src/cli/listTui.test.ts +70 -0
  6. package/src/cli/listTui.ts +165 -0
  7. package/src/codec/codec.test.ts +271 -0
  8. package/src/codec/index.ts +217 -0
  9. package/src/core/constants.test.ts +21 -0
  10. package/src/core/constants.ts +180 -0
  11. package/src/core/errors.ts +20 -0
  12. package/src/core/index.ts +3 -0
  13. package/src/core/normalize.test.ts +98 -0
  14. package/src/core/normalize.ts +89 -0
  15. package/src/core/socket.ts +63 -0
  16. package/src/create/create.test.ts +143 -0
  17. package/src/create/index.ts +178 -0
  18. package/src/daemon/daemon-http.test.ts +114 -0
  19. package/src/daemon/daemon.test.ts +103 -0
  20. package/src/daemon/index.ts +439 -0
  21. package/src/daemon/main.test.ts +194 -0
  22. package/src/daemon/main.ts +230 -0
  23. package/src/enable/enable.test.ts +92 -0
  24. package/src/enable/index.ts +381 -0
  25. package/src/identity/identity.test.ts +262 -0
  26. package/src/identity/index.ts +603 -0
  27. package/src/index.ts +27 -0
  28. package/src/init/index.ts +408 -0
  29. package/src/init/init.test.ts +171 -0
  30. package/src/init/runtime-resolve.test.ts +49 -0
  31. package/src/install/index.ts +84 -0
  32. package/src/install/install.test.ts +31 -0
  33. package/src/launch/adapters/claude.ts +250 -0
  34. package/src/launch/adapters/codex.ts +329 -0
  35. package/src/launch/adapters/notifier.ts +90 -0
  36. package/src/launch/adapters/telegram.ts +130 -0
  37. package/src/launch/bootstrap.test.ts +56 -0
  38. package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
  39. package/src/launch/composeSystemPrompt.test.ts +98 -0
  40. package/src/launch/composeSystemPrompt.ts +261 -0
  41. package/src/launch/index.ts +253 -0
  42. package/src/launch/launch.test.ts +233 -0
  43. package/src/launch/launchd.test.ts +363 -0
  44. package/src/launch/launchd.ts +375 -0
  45. package/src/launch/launchdRun.ts +168 -0
  46. package/src/launch/sockdir.test.ts +70 -0
  47. package/src/launch/types.ts +300 -0
  48. package/src/lifecycle/index.ts +840 -0
  49. package/src/lifecycle/lifecycle.test.ts +496 -0
  50. package/src/onboard/index.ts +135 -0
  51. package/src/onboard/onboard.test.ts +39 -0
  52. package/src/provision/index.ts +170 -0
  53. package/src/provision/provision.test.ts +104 -0
  54. package/src/registry/index.ts +453 -0
  55. package/src/registry/registry.test.ts +400 -0
  56. package/src/runtime/deploy.ts +230 -0
  57. package/src/runtime/index.ts +191 -0
  58. package/src/runtime/runtime.test.ts +226 -0
  59. package/src/storage/index.ts +331 -0
  60. package/src/storage/peers-home.test.ts +34 -0
  61. package/src/storage/storage.test.ts +65 -0
  62. package/src/transport/index.ts +522 -0
  63. 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
+ }