@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,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
|
+
})
|