@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,329 @@
1
+ // codex RuntimeAdapter — the "HOW to launch / observe ONE codex session" half of
2
+ // the launch contract (src/launch/types.ts). Runtime-agnostic launch.launch
3
+ // drives it identically to claude: argv → boot dialogs → ready marker →
4
+ // activity-proxy ready-gate → permission autopilot → resume preflight. Codex is
5
+ // a tui that usesDoctrine, but the doctrine is delivered through a config field
6
+ // (`-c model_instructions_file=<f>`) rather than a dedicated flag, and resume is
7
+ // session-less (`resume --last`, codex matches the newest session for cwd
8
+ // itself). Consolidated from three frozen sources:
9
+ //
10
+ // - Persistent-Peer/bin/codex-start.sh — argv (224: -c
11
+ // model_instructions_file="<merged>", --dangerously-bypass-approvals-and-
12
+ // sandbox), and the three startup-dialog auto-accepts (255-275: dir-trust
13
+ // Enter, hooks-review Down+Enter). The update-available case is the
14
+ // Spawned-Peer superset (codexUpdatePromptActive).
15
+ // - Spawned-Peer/src/spawner.ts — buildCodexArgv (731-757: [resume --last]
16
+ // --no-alt-screen -C <cwd> --dangerously-bypass-approvals-and-sandbox) and
17
+ // hasCodexSessionForCwd (545-569: a session_meta.payload.cwd realpath-match
18
+ // under ~/.codex/sessions/ is the resume preflight).
19
+ // - Spawned-Peer/src/watcher.ts — codexInputReady (263-269), the boot-dialog
20
+ // predicates (codexUpdatePromptActive/codexDirTrustActive/codexHooksReview
21
+ // Active 253-261) + the keys the boot loop sends (382-400),
22
+ // newestCodexSessionMtime + codexSessionCwd (200-234), and
23
+ // answerPermissionDialog's codex branch (302-306: Down then Enter) over
24
+ // dialogs.ts codexApprovalActive (39-45).
25
+ //
26
+ // The doctrine -c value is the bare `model_instructions_file=<f>` token: the
27
+ // TOML value-side quotes in codex-start.sh:224 exist only because that string is
28
+ // re-parsed by a shell (`printf %q`) before codex sees it; here launch.launch
29
+ // shell-quotes each argv element exactly once, so the unquoted path is correct
30
+ // and a quoted path would arrive as a literal `"..."`-wrapped (broken) TOML
31
+ // value. Order follows the FROZEN contract (types.ts:107), not the bash's
32
+ // flag order.
33
+ //
34
+ // NO currency on this path: no marketplace check, no plugin install/update — that
35
+ // is install-time (blueprint §0.6 fast-wake), not session bring-up. (codex-start
36
+ // .sh:94-122 runs the currency gate OUTSIDE the tmux launch; it is not ported.)
37
+
38
+ import { homedir } from 'os'
39
+ import { join } from 'path'
40
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
41
+ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // Boot dialog markers (watcher.codexUpdate/DirTrust/HooksReview, 253-261)
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * The known codex startup dialogs and the tmux send-keys the boot loop answers
49
+ * each with (watcher.ts:382-400, codex-start.sh:255-275). Order matters: the
50
+ * update screen can stack in front of the trust/hooks modals, so it is matched
51
+ * first. Each option below is verified default-highlighted, so the listed keys
52
+ * land on the proceed path:
53
+ * - 'Update available!' + 'Press enter to continue' — the self-update offer;
54
+ * keys ['2','Enter'] decline (option 2 = "not now") so a headless peer never
55
+ * blocks on an update it cannot drive (watcher.ts:382-384).
56
+ * - 'Do you trust the contents of this directory' — first-run folder-trust;
57
+ * option 1 ("Yes, continue") is default-highlighted → ['Enter']
58
+ * (watcher.ts:386-390, codex-start.sh:261-264).
59
+ * - 'Hooks need review' — a new/changed plugin hooks.json; "Trust all and
60
+ * continue" is option 2, default highlight is option 1 → ['Down','Enter']
61
+ * (watcher.ts:392-399, codex-start.sh:266-272).
62
+ */
63
+ function codexUpdatePromptActive(pane: string): boolean {
64
+ return pane.includes('Update available!') && pane.includes('Press enter to continue')
65
+ }
66
+ function codexDirTrustActive(pane: string): boolean {
67
+ return pane.includes('Do you trust the contents of this directory')
68
+ }
69
+ function codexHooksReviewActive(pane: string): boolean {
70
+ return pane.includes('Hooks need review')
71
+ }
72
+
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+ // Session activity proxy + resume preflight (watcher / spawner port)
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * The cwd a codex session was opened in, read from the first jsonl line's
79
+ * `session_meta.payload.cwd` (watcher.codexSessionCwd, 200-212). null when the
80
+ * file is missing, unreadable, not a session_meta record, or carries no cwd.
81
+ */
82
+ function codexSessionCwd(file: string): string | null {
83
+ try {
84
+ const firstLine = readFileSync(file, 'utf8').split(/\r?\n/, 1)[0]
85
+ if (!firstLine) return null
86
+ const entry = JSON.parse(firstLine) as {
87
+ type?: unknown
88
+ payload?: { cwd?: unknown }
89
+ }
90
+ return entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string'
91
+ ? entry.payload.cwd
92
+ : null
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+ /** realpath a path so a symlinked cwd compares equal to the dir codex recorded;
99
+ * a stale/missing path falls through to the original string (the canonicalPath
100
+ * helper behind watcher.newestCodexSessionMtime / spawner.hasCodexSessionForCwd). */
101
+ function canonicalPath(p: string): string {
102
+ try {
103
+ return realpathSync(p)
104
+ } catch {
105
+ return p
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Newest ~/.codex/sessions/**\/*.jsonl mtimeMs whose recorded session cwd
111
+ * realpath-matches `cwd`, or null when none (watcher.newestCodexSessionMtime,
112
+ * 214-234). Codex files its session logs in a date-nested tree, so the scan
113
+ * recurses. The ready-gate waits for this to strictly advance past baseline
114
+ * (the model produced its first turn); idle accounting reads the same proxy.
115
+ */
116
+ function newestCodexSessionMtime(cwd: string): number | null {
117
+ const root = join(homedir(), '.codex', 'sessions')
118
+ const target = canonicalPath(cwd)
119
+ let newest = 0
120
+ function visit(dir: string): void {
121
+ let entries
122
+ try {
123
+ entries = readdirSync(dir, { withFileTypes: true })
124
+ } catch {
125
+ return
126
+ }
127
+ for (const entry of entries) {
128
+ const path = join(dir, entry.name)
129
+ if (entry.isDirectory()) {
130
+ visit(path)
131
+ continue
132
+ }
133
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
134
+ if (canonicalPath(codexSessionCwd(path) ?? '') !== target) continue
135
+ try {
136
+ const mt = statSync(path).mtimeMs
137
+ if (mt > newest) newest = mt
138
+ } catch {
139
+ /* race — entry vanished between readdir and stat */
140
+ }
141
+ }
142
+ }
143
+ visit(root)
144
+ return newest > 0 ? newest : null
145
+ }
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // codexAdapter
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ export const codexAdapter: RuntimeAdapter = {
152
+ runtime: 'codex',
153
+ kind: 'tui',
154
+ usesDoctrine: true,
155
+
156
+ /**
157
+ * Delivery markers for submitIntoTui (07.06 refactor; docs/Рантайм codex
158
+ * "Доставка"):
159
+ * - promptGlyphs ['›'] — codex's input-row arrow (the same stable invariant as
160
+ * isInputReady's '› ' check; the exact glyph the prompt row starts with).
161
+ * codex-only, so a stray claude '❯' no longer false-matches.
162
+ * - pastePatterns '[Pasted text' / '[Image #' — kept identical to claude (the
163
+ * old transport union applied them to codex too, so this preserves behaviour;
164
+ * codex's exact paste-land glyph is a live-verify item — the envelope tail-
165
+ * marker remains the primary landed-signal regardless).
166
+ */
167
+ deliveryMarkers: {
168
+ promptGlyphs: ['›'],
169
+ pastePatterns: [/\[Pasted text/, /\[Image #/],
170
+ },
171
+
172
+ /**
173
+ * argv = codexBin + (resume --last when spec.resume) + the TUI args +
174
+ * (doctrine via -c when systemPromptFile set) + bypass + extraArgs. Order is
175
+ * the FROZEN contract (types.ts:107); the bash's flag order differs but the
176
+ * resulting flag SET is identical.
177
+ *
178
+ * - 'resume','--last' spawner.ts:756 — ONLY when spec.resume; codex picks
179
+ * the newest session matching cwd itself (resolveResume merely verified one
180
+ * exists, so this is never a silent fresh fallback). No uuid — codex has no
181
+ * per-session resume ref the way claude does.
182
+ * - '--no-alt-screen' spawner.ts:742 — keep codex on the main screen buffer
183
+ * so capture-pane sees the live TUI (the alt screen is not captured).
184
+ * - '-C', spec.cwd spawner.ts:743 — codex's working-dir flag (the launch
185
+ * primitive also new-sessions with -c <cwd>; codex needs its own -C too).
186
+ * - '-c', `model_instructions_file=${spec.systemPromptFile}` codex-start.sh
187
+ * :224 — ONLY when set (a tui runtime that usesDoctrine composes one).
188
+ * Swaps codex's compile-time BASE_INSTRUCTIONS for the merged peer
189
+ * doctrine; MCP/plugin/AGENTS.md layers stay intact. The path is the bare
190
+ * unquoted token — launch.launch shell-quotes the whole argv element once.
191
+ * - '--dangerously-bypass-approvals-and-sandbox' codex-start.sh:224,
192
+ * spawner.ts:754 — codex YOLO; a headless peer has no owner to answer
193
+ * approval prompts and no reason to sandbox below its peer-cwd.
194
+ * - ...extraArgs PEER_START_ARGS passthrough (LaunchSpec.extraArgs).
195
+ * NO currency — no marketplace/install/update on this path.
196
+ */
197
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
198
+ return [
199
+ cfg.codexBin,
200
+ ...(spec.resume ? ['resume', '--last'] : []),
201
+ '--no-alt-screen',
202
+ '-C',
203
+ spec.cwd,
204
+ ...(spec.systemPromptFile
205
+ ? ['-c', `model_instructions_file=${spec.systemPromptFile}`]
206
+ : []),
207
+ '--dangerously-bypass-approvals-and-sandbox',
208
+ ...(spec.extraArgs ?? []),
209
+ ]
210
+ },
211
+
212
+ /**
213
+ * A visible startup dialog → the keys that clear it (watcher.ts:382-400), else
214
+ * null. Update offer is matched first (it can stack ahead of the trust/hooks
215
+ * modals): decline with ['2','Enter']; dir-trust accepts with ['Enter']; hooks
216
+ * -review needs ['Down','Enter'] to reach "Trust all and continue".
217
+ */
218
+ bootDialogKeys(pane: string): string[] | null {
219
+ if (codexUpdatePromptActive(pane)) return ['2', 'Enter']
220
+ if (codexDirTrustActive(pane)) return ['Enter']
221
+ if (codexHooksReviewActive(pane)) return ['Down', 'Enter']
222
+ return null
223
+ },
224
+
225
+ /**
226
+ * Ready for the first message iff the codex TUI input surface is rendered and
227
+ * no startup screen is still up (watcher.codexInputReady, 263-269):
228
+ * - pane includes 'OpenAI Codex' — the TUI splash/header is present.
229
+ * - pane does NOT include 'Press enter to continue' — the update screen (and
230
+ * other "press enter" startup prompts) is gone.
231
+ * - some line, trimStart, startsWith '› ' — the rendered input arrow at the
232
+ * start of the input row. (Codex rotates its top-of-pane tip line, so the
233
+ * input arrow is the stable ready invariant — not any single tip.)
234
+ */
235
+ isInputReady(pane: string): boolean {
236
+ if (!pane.includes('OpenAI Codex')) return false
237
+ if (pane.includes('Press enter to continue')) return false
238
+ return pane.split(/\r?\n/).some(line => line.trimStart().startsWith('› '))
239
+ },
240
+
241
+ /**
242
+ * Newest ~/.codex/sessions/**\/*.jsonl mtimeMs whose session_meta cwd realpath
243
+ * -matches cwd, or null when none (watcher.newestCodexSessionMtime, 214-234).
244
+ * The ready-gate waits for this to strictly advance past baseline; idle
245
+ * accounting reads the same proxy.
246
+ */
247
+ newestActivityMtime(cwd: string): number | null {
248
+ return newestCodexSessionMtime(cwd)
249
+ },
250
+
251
+ /**
252
+ * A codex approval modal is open iff the pane carries the 'enter to submit'
253
+ * footer AND an Allow-to-run title — the MCP tool-approval modal ("Allow the
254
+ * <server> MCP server to run tool …?") or the generic command-exec approval
255
+ * (dialogs.ts codexApprovalActive, 39-45). The footer is the discriminator
256
+ * against boot-phase startup screens (those say 'Press enter to continue'), so
257
+ * a dir-trust/update screen never matches here. Required even under YOLO:
258
+ * connector / consequential MCP-tool approvals are NOT suppressed by
259
+ * --dangerously-bypass-approvals-and-sandbox and would block a headless peer
260
+ * forever (codex-start.sh:229-237).
261
+ */
262
+ permissionDialogActive(pane: string): boolean {
263
+ if (!pane.includes('enter to submit')) return false
264
+ if (/Allow the .+ MCP server to run tool/.test(pane)) return true
265
+ return /\bAllow\b[\s\S]*\bto run\b/.test(pane)
266
+ },
267
+
268
+ /**
269
+ * Affirm a codex approval with ['Down','Enter'] (watcher.answerPermissionDialog
270
+ * codex branch, 302-306): the modal default-highlights option 1 ("Allow",
271
+ * one-shot); one Down selects option 2 ("Allow for this session") so later tool
272
+ * calls in this same headless session don't re-prompt, then Enter submits. Even
273
+ * partial key delivery is safe — Down-then-nothing leaves option 1 (still an
274
+ * affirmative), and the only destructive option (Cancel) is three Downs away.
275
+ */
276
+ permissionDialogKeys(): string[] {
277
+ return ['Down', 'Enter']
278
+ },
279
+
280
+ /**
281
+ * Resume preflight (fail-loud — never a silent fresh fallback). Codex resumes
282
+ * via 'resume --last' with NO ref: it matches the newest session for cwd
283
+ * itself, so buildArgv keys on spec.resume alone and there is nothing to
284
+ * resolve. We only verify a candidate session exists — a session_meta.payload
285
+ * .cwd that realpath-matches cwd under ~/.codex/sessions/ (spawner.hasCodex
286
+ * SessionForCwd, 545-569). {ok:true} when one exists (no ref), else {ok:false,
287
+ * reason} so the caller surfaces a real failure instead of a context-less
288
+ * session.
289
+ */
290
+ resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string } {
291
+ const target = canonicalPath(cwd)
292
+ const root = join(homedir(), '.codex', 'sessions')
293
+ function visit(dir: string): boolean {
294
+ let entries
295
+ try {
296
+ entries = readdirSync(dir, { withFileTypes: true })
297
+ } catch {
298
+ return false
299
+ }
300
+ for (const entry of entries) {
301
+ const path = join(dir, entry.name)
302
+ if (entry.isDirectory()) {
303
+ if (visit(path)) return true
304
+ continue
305
+ }
306
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
307
+ if (canonicalPath(codexSessionCwd(path) ?? '') === target) return true
308
+ }
309
+ return false
310
+ }
311
+ return visit(root)
312
+ ? { ok: true }
313
+ : { ok: false, reason: 'no codex session to resume' }
314
+ },
315
+
316
+ /**
317
+ * Map a control command to codex's in-session mechanism (Ф-E, docs/Control-команды).
318
+ * - interrupt → ['Escape']. SNAPPED LIVE (codex-cli 0.136.0): a SINGLE Escape
319
+ * interrupts the turn — NOT a double. (Codex's own footer says "esc to
320
+ * interrupt"; one Escape yields "■ Conversation interrupted". The contract's
321
+ * open "×1/×2?" is resolved to ×1, same as claude.) Session + context intact.
322
+ * - compact → null (codex has no '/compact'; context management is its own).
323
+ * - anything else → null.
324
+ */
325
+ executeControl(command: ControlCommand): ControlPlan | null {
326
+ if (command.name === 'interrupt') return { sequence: [['Escape']] }
327
+ return null
328
+ },
329
+ }
@@ -0,0 +1,90 @@
1
+ // notifier RuntimeAdapter — the "HOW to launch / observe ONE notifier session"
2
+ // half of the launch contract (src/launch/types.ts). Like telegram, the notifier
3
+ // runtime is a long-running ROUTER (notifier-runtime run: a Scheduler/Supervisor
4
+ // loop + an IAP envelope pump on stdin, ported from telegram-runtime), NOT an LLM
5
+ // TUI. So kind:'router' tells launch.launch to SKIP every TUI phase — no pane boot
6
+ // dialog, no ready-marker, no first-user-turn delivery, no activity-proxy ready-
7
+ // gate, no permission modal, no transcript to resume. The pane predicates below
8
+ // are trivial constants, present only to satisfy the frozen interface.
9
+ //
10
+ // ONE adapter for the whole runtime: notifier carries TWO peer-roles, `timer`
11
+ // (primitive TIME → Scheduler) and `watcher` (primitive EVENT → Supervisor). The
12
+ // role is NOT an adapter concern — it is dispatched INSIDE `notifier-runtime run`
13
+ // from PEER_PERSONALITY (resolvePersonality: watcher → Supervisor, else timer →
14
+ // Scheduler), exactly as personality reaches every other runtime: via env +
15
+ // `tmux new-session -c "$PEER_CWD"`, i.e. launch.launch's job, not buildArgv's.
16
+ //
17
+ // notifier is an INFRA runtime (always-on): launchd KeepAlive holds the session so
18
+ // the daemon can deliver send_to_peer(timer|watcher, …) into its tmux pane (the
19
+ // stdin reader picks up the registration/live-reload envelope). That always-on
20
+ // bring-up is the plist path (src/launch/launchd.ts); THIS adapter only describes
21
+ // how to launch one notifier session, identical in shape to telegram.
22
+ //
23
+ // NO currency on this path: no marketplace check, no plugin install/update.
24
+
25
+ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
26
+
27
+ export const notifierAdapter: RuntimeAdapter = {
28
+ runtime: 'notifier',
29
+ kind: 'router',
30
+ usesDoctrine: false,
31
+
32
+ /** No submit surface — a router uses the deliverViaTmux C-j path, not submitIntoTui.
33
+ * Empty glyphs. (No intelligence gate: notifier peers are intelligence='absent'
34
+ * programmatic sources, which is exactly their expected nature — nothing to refuse.) */
35
+ deliveryMarkers: { promptGlyphs: [] },
36
+
37
+ /**
38
+ * argv = notifierBin + 'run' + extraArgs — symmetric with telegram-runtime
39
+ * (`$BIN run ${PEER_START_ARGS}`).
40
+ * - cfg.notifierBin ?? 'notifier-runtime' the launch binary; the literal is
41
+ * left for launch.launch's PATH to resolve when cfg does not pin it.
42
+ * - 'run' the Scheduler/Supervisor subcommand: reads the peer's triggers,
43
+ * runs the TIME grid (timer) or EVENT supervisor (watcher) by PEER_PERSONALITY,
44
+ * and pumps IAP envelopes in via the session's stdin (registration/live-reload).
45
+ * - ...extraArgs PEER_START_ARGS passthrough (LaunchSpec.extraArgs).
46
+ *
47
+ * NO --system-prompt-file (usesDoctrine:false — a router is not an LLM). NO
48
+ * --resume (a router has no transcript). NO currency on this path.
49
+ */
50
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
51
+ return [cfg.notifierBin ?? 'notifier-runtime', 'run', ...(spec.extraArgs ?? [])]
52
+ },
53
+
54
+ /** No startup dialogs — a router has no pane TUI to answer; launch.launch skips
55
+ * the boot phase, so this is never consulted. */
56
+ bootDialogKeys(_pane: string): string[] | null {
57
+ return null
58
+ },
59
+
60
+ /** Always ready: a router is "up" the instant `notifier-runtime run` is launched
61
+ * into the tmux session; launch.launch skips the ready-gate for kind:'router'. */
62
+ isInputReady(_pane: string): boolean {
63
+ return true
64
+ },
65
+
66
+ /** No activity proxy: a router writes no transcript/session jsonl to mtime. */
67
+ newestActivityMtime(_cwd: string): number | null {
68
+ return null
69
+ },
70
+
71
+ /** No permission/approval modal: a router is not an LLM driving tools. */
72
+ permissionDialogActive(_pane: string): boolean {
73
+ return false
74
+ },
75
+
76
+ /** No permission modal to affirm — empty send-keys args. */
77
+ permissionDialogKeys(): string[] {
78
+ return []
79
+ },
80
+
81
+ /** Nothing to resume: a router does not replay a transcript. */
82
+ resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
83
+ return { ok: true }
84
+ },
85
+
86
+ /** No in-session control: a router has no TUI turn. null → explicit refusal upstream. */
87
+ executeControl(_command: ControlCommand): ControlPlan | null {
88
+ return null
89
+ },
90
+ }
@@ -0,0 +1,130 @@
1
+ // telegram RuntimeAdapter — the "HOW to launch / observe ONE telegram session"
2
+ // half of the launch contract (src/launch/types.ts). Unlike claude/codex, the
3
+ // telegram runtime is a long-running ROUTER (telegram-runtime run: grammy
4
+ // long-polling + IAP envelope pump), NOT an LLM TUI. So kind:'router' tells
5
+ // launch.launch to SKIP every TUI phase — there is no pane boot dialog, no
6
+ // ready-marker, no first-user-turn delivery, no activity-proxy ready-gate, no
7
+ // permission modal, no transcript to resume. The pane predicates below are
8
+ // therefore trivial constants, present only to satisfy the frozen interface.
9
+ //
10
+ // Ported from one frozen source:
11
+ //
12
+ // - Persistent-Peer/bin/telegram-start.sh — the launch command (line 119:
13
+ // `CMD="$TELEGRAM_RUNTIME_BIN run ${PEER_START_ARGS}"`), and the explicit
14
+ // facts that this adapter carries NO doctrine/system-prompt (it is not an
15
+ // LLM that consumes one — header lines 6-16) and is gated to human peers
16
+ // (intelligence='human', lines 24-28/63-71). The binary defaults to the
17
+ // PATH-resolved literal `telegram-runtime` (line 107); the run subcommand
18
+ // takes only PEER_START_ARGS as extra flags — no personality/cwd positional
19
+ // (those are env + `tmux new-session -c "$PEER_CWD"`, i.e. launch.launch's
20
+ // job, not buildArgv's).
21
+ //
22
+ // NO currency on this path: no marketplace check, no plugin install/update —
23
+ // that is install-time (blueprint §0.6 fast-wake), not session bring-up.
24
+
25
+ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // telegramAdapter
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ export const telegramAdapter: RuntimeAdapter = {
32
+ runtime: 'telegram',
33
+ kind: 'router',
34
+ usesDoctrine: false,
35
+
36
+ /** No submit surface — a router takes no bracketed-paste-then-Enter turn
37
+ * (deliverViaTmux uses the router C-j path, not submitIntoTui). Empty glyphs. */
38
+ deliveryMarkers: { promptGlyphs: [] },
39
+
40
+ /**
41
+ * telegram is a HUMAN channel — launch REFUSES a non-natural peer (fail-loud).
42
+ * Porting the persistent-peer FATAL guard (it stood in two places): a peer with
43
+ * intelligence artificial/absent must never be brought up on telegram (it would
44
+ * route a human's bot to an agent/script). The launch primitive enforces this
45
+ * against LaunchSpec.intelligence; source of intelligence → docs/Идентичность.
46
+ */
47
+ requiresIntelligence: 'natural',
48
+
49
+ /**
50
+ * argv = telegramBin + 'run' + extraArgs (telegram-start.sh:119,
51
+ * `$TELEGRAM_RUNTIME_BIN run ${PEER_START_ARGS}`).
52
+ *
53
+ * - cfg.telegramBin ?? 'telegram-runtime' the launch binary. The bash
54
+ * resolves it via `command -v telegram-runtime` (line 107) against a PATH
55
+ * that prefers ~/.iapeer/runtimes/telegram/bin then ~/.local/bin; here we
56
+ * defer that resolution to cfg.telegramBin when set, else emit the literal
57
+ * 'telegram-runtime' for launch.launch's PATH to resolve.
58
+ * - 'run' the router subcommand: reads peer-profile + global bots registry,
59
+ * brings up grammy long-polling for every linked bot, and pumps IAP
60
+ * envelopes both directions through the session's stdio (DECISIONS §12.1).
61
+ * - ...extraArgs PEER_START_ARGS passthrough (LaunchSpec.extraArgs) — the
62
+ * only extra flags telegram-start.sh forwards to `run`.
63
+ *
64
+ * NO --system-prompt-file: usesDoctrine:false — telegram-runtime is a router,
65
+ * not an LLM, so there is no doctrine to merge (telegram-start.sh:6-12). NO
66
+ * --resume: a router has no transcript. NO currency on this path.
67
+ */
68
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
69
+ return [cfg.telegramBin ?? 'telegram-runtime', 'run', ...(spec.extraArgs ?? [])]
70
+ },
71
+
72
+ /**
73
+ * No startup dialogs — the router has no pane TUI to answer (kind:'router',
74
+ * telegram-start.sh:14-16). launch.launch skips the boot phase, so this is
75
+ * never consulted; null is the contractual "no dialog to clear".
76
+ */
77
+ bootDialogKeys(_pane: string): string[] | null {
78
+ return null
79
+ },
80
+
81
+ /**
82
+ * Always ready: a router has no input surface waiting for a first message —
83
+ * it is "up" the instant `telegram-runtime run` is launched into the tmux
84
+ * session (telegram-start.sh has no ready-marker polling, lines 14-16).
85
+ * launch.launch skips the ready-gate for kind:'router'; true is the
86
+ * contractual "nothing to wait for".
87
+ */
88
+ isInputReady(_pane: string): boolean {
89
+ return true
90
+ },
91
+
92
+ /**
93
+ * No activity proxy: a router writes no transcript/session jsonl to mtime as a
94
+ * ready-gate or idle signal (contract: "null for a router (no proxy)").
95
+ * launch.launch skips the activity-proxy ready-gate for kind:'router'.
96
+ */
97
+ newestActivityMtime(_cwd: string): number | null {
98
+ return null
99
+ },
100
+
101
+ /**
102
+ * No permission/approval modal: a router is not an LLM driving tools behind an
103
+ * autopilot, so there is never a dialog to detect (telegram-start.sh:14, "no
104
+ * trust-dialog auto-accept, no permissions banner").
105
+ */
106
+ permissionDialogActive(_pane: string): boolean {
107
+ return false
108
+ },
109
+
110
+ /** No permission modal to affirm — empty send-keys args (see above). */
111
+ permissionDialogKeys(): string[] {
112
+ return []
113
+ },
114
+
115
+ /**
116
+ * Nothing to resume: a router does not replay a transcript, so resume cannot
117
+ * fail-loud the way claude/codex do — there is no prior session ref to resolve
118
+ * or miss. Always {ok:true} (the contract: "a router does not resume a
119
+ * transcript; nothing to fail on").
120
+ */
121
+ resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
122
+ return { ok: true }
123
+ },
124
+
125
+ /** No in-session control: a router has no TUI turn to interrupt/compact. null for
126
+ * every command → the daemon/CLI surfaces an explicit "unsupported" refusal. */
127
+ executeControl(_command: ControlCommand): ControlPlan | null {
128
+ return null
129
+ },
130
+ }
@@ -0,0 +1,56 @@
1
+ // launchctlBootstrap — the AUTO-load primitive guards. The happy `loaded` path is
2
+ // proven LIVE (it calls real launchctl); the unit tests cover the three guards that
3
+ // must hold WITHOUT touching launchd: foreign-plist refusal, sandbox skip, and the
4
+ // foundation-owned gate. (The test script sets IAPEER_TEST_SANDBOX=1, so the skip
5
+ // branch is the default; the foreign-refusal branch is checked first regardless.)
6
+
7
+ import { afterEach, describe, expect, test } from 'bun:test'
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs'
9
+ import { tmpdir } from 'os'
10
+ import { join } from 'path'
11
+ import { launchctlBootstrap, installAlwaysOnPlist } from './index.ts'
12
+
13
+ const dirs: string[] = []
14
+ function mkTmp(): string {
15
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-boot-'))
16
+ dirs.push(d)
17
+ return d
18
+ }
19
+ afterEach(() => {
20
+ while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
21
+ })
22
+
23
+ describe('launchctlBootstrap guards', () => {
24
+ test('refuses a NON-foundation plist (no ownership sentinel) — fleet guard, checked first', () => {
25
+ const dir = mkTmp()
26
+ const foreign = join(dir, 'com.iapeer.boris.plist')
27
+ writeFileSync(foreign, '<?xml version="1.0"?><plist><dict></dict></plist>')
28
+ // refused-foreign wins even with IAPEER_TEST_SANDBOX=1 (the guard is checked before sandbox)
29
+ const r = launchctlBootstrap('boris', foreign, { IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv)
30
+ expect(r.state).toBe('refused-foreign')
31
+ expect(r.label).toBe('com.iapeer.boris')
32
+ })
33
+
34
+ test('a foundation-owned plist under IAPEER_TEST_SANDBOX=1 → skipped-sandbox (no launchctl)', () => {
35
+ const root = mkTmp()
36
+ const bindir = mkTmp()
37
+ const bin = join(bindir, 'notifier-runtime')
38
+ writeFileSync(bin, '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
39
+ const env = {
40
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
41
+ HOME: root,
42
+ PATH: bindir,
43
+ IAPEER_TEST_SANDBOX: '1',
44
+ } as NodeJS.ProcessEnv
45
+ const cwd = join(root, 'timer')
46
+ const plist = installAlwaysOnPlist({ personality: 'timer', runtime: 'notifier', cwd, runtimeBin: bin, env })
47
+ const r = launchctlBootstrap('timer', plist, env)
48
+ expect(r.state).toBe('skipped-sandbox')
49
+ expect(r.label).toBe('com.iapeer.timer')
50
+ })
51
+
52
+ test('absent/unreadable plist → refused-foreign (not provably ours)', () => {
53
+ const r = launchctlBootstrap('ghost', join(mkTmp(), 'nope.plist'), { IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv)
54
+ expect(r.state).toBe('refused-foreign')
55
+ })
56
+ })