@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,261 @@
1
+ // composeSystemPrompt — GOLDEN-equivalent TS reimplementation of the jq
2
+ // doctrine-merge in Persistent-Peer/bin/claude-start.sh:264-290. Produces the
3
+ // merged peer system prompt that --system-prompt-file / model_instructions_file
4
+ // consume: a YAML identity block (dynamic facts) + global doctrine (optional) +
5
+ // per-peer doctrine. Byte-for-byte equivalent to the bash/jq output — see the
6
+ // byte-layout notes below.
7
+ //
8
+ // Ownership: launch (HOW to bring up ONE session); NO currency on this path.
9
+ // Contract is FROZEN in ./types.ts (ComposePromptInput / ComposeSystemPrompt).
10
+
11
+ import { readdirSync, readFileSync, statSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { IAPEER_DIR } from '../core/constants.ts'
14
+ import { publicPeerSummary, readPeersIndex, type PublicPeerSummary } from '../registry/index.ts'
15
+ import { resolveGlobalRoot } from '../storage/index.ts'
16
+ import type { ComposePromptInput, PromptDomainBlock } from './types.ts'
17
+
18
+ const IAPEER_DOCTRINE_FILE = 'IAPEER.md'
19
+
20
+ // The bash builds $MERGED as the concatenation of two streams in a `{ … }` group:
21
+ //
22
+ // 1. jq --raw-output over a 10-element string array. --raw-output prints each
23
+ // array element on its own line, every line terminated by '\n' (including
24
+ // the LAST element). The array is:
25
+ // "---", 8×"<key>: <value | @json>", "---", ""
26
+ // so the final "" element emits a line that is just '\n' → the blank line
27
+ // after the closing '---'. Net jq output (each line '\n'-terminated):
28
+ // "---\n" + 8 yaml lines + "---\n" + "\n"
29
+ // i.e. the YAML block ALWAYS ends with "---\n\n".
30
+ //
31
+ // 2a. if the global doctrine file exists: cat "$GLOBAL" then printf "\n".
32
+ // `cat` emits the file verbatim; `printf "\n"` appends exactly ONE '\n'.
33
+ // In TS the file content is `input.globalDoctrine`, so this contributes
34
+ // `globalDoctrine + "\n"` — the trailing "\n" is added UNCONDITIONALLY,
35
+ // whether or not globalDoctrine already ends in a newline (it mirrors the
36
+ // always-on `printf "\n"`). Omitted entirely when global is absent/empty.
37
+ //
38
+ // 2b. cat "$DOCTRINE": the per-peer doctrine verbatim, NO added newline.
39
+ //
40
+ // jq @json renders each value as a JSON string literal. It equals JSON.stringify
41
+ // byte-for-byte across the realistic field domain (empty string, tab/CR/LF/FF/BS,
42
+ // C0 control chars, unicode passthrough, '"'/'\\' escaping, '/' left unescaped) —
43
+ // verified by enumerating every codepoint U+0001..U+0100. The ONE divergence: jq
44
+ // escapes U+007F (DEL) as a \u007f sequence, whereas JSON.stringify emits the raw
45
+ // byte (the JSON spec only mandates escaping U+0000..U+001F, so DEL passes
46
+ // through). jqJson() reproduces jq exactly by post-escaping DEL (and NUL — the
47
+ // same class; unreachable through the bash `--arg` origin, but escaped for total
48
+ // faithfulness). A JSON string literal is also a valid YAML double-quoted scalar,
49
+ // so the YAML stays safe against colons/quotes/newlines in description / paths.
50
+ function jqJson(value: string): string {
51
+ // JSON.stringify, then bring the two control chars jq escapes but the
52
+ // spec-minimal JSON.stringify leaves literal (DEL, NUL) into line with jq.
53
+ return JSON.stringify(value)
54
+ .replace(new RegExp(String.fromCharCode(0x7f), 'g'), '\\u007f')
55
+ .replace(new RegExp(String.fromCharCode(0x00), 'g'), '\\u0000')
56
+ }
57
+
58
+ /**
59
+ * Compose the merged system prompt (YAML identity block + optional global
60
+ * doctrine + per-peer doctrine), GOLDEN byte-for-byte equivalent to the
61
+ * claude-start.sh:264-290 jq pipeline.
62
+ */
63
+ export function composeSystemPrompt(input: ComposePromptInput): string {
64
+ // The eight identity lines, in the bash's exact key order. Keys verbatim:
65
+ // hyphen `peer-cwd`, underscore `os_version`. Value = jqJson (= jq @json).
66
+ const yaml =
67
+ '---\n' +
68
+ `personality: ${jqJson(input.personality)}\n` +
69
+ `description: ${jqJson(input.description)}\n` +
70
+ `peer-cwd: ${jqJson(input.cwd)}\n` +
71
+ `platform: ${jqJson(input.platform)}\n` +
72
+ `os_version: ${jqJson(input.osVersion)}\n` +
73
+ `user: ${jqJson(input.user)}\n` +
74
+ `hostname: ${jqJson(input.hostname)}\n` +
75
+ `today: ${jqJson(input.today)}\n` +
76
+ '---\n' +
77
+ // jq's final "" array element → the bare blank line after the closing '---'.
78
+ '\n'
79
+
80
+ // Global doctrine sits between the YAML block and the per-peer doctrine so
81
+ // per-peer rules override global (specific over general). Bash gates on file
82
+ // EXISTENCE (`[ -f "$GLOBAL_DOCTRINE" ]`), NOT non-emptiness — a present but
83
+ // EMPTY global file still emits `cat "" + printf "\n"` = "\n". So the contract
84
+ // is: a present file → globalDoctrine is the string (possibly ""), absent file
85
+ // → undefined. Guard on `!== undefined` (NOT truthiness) to match bash exactly;
86
+ // an empty "" then yields "" + "\n" = "\n" (the 1-byte golden divergence the
87
+ // adversarial verify caught). The trailing "\n" mirrors the unconditional
88
+ // `printf "\n"` after `cat "$GLOBAL"`.
89
+ const global = input.globalDoctrine !== undefined ? input.globalDoctrine + '\n' : ''
90
+
91
+ // Layers 1+2: YAML block + global doctrine + per-peer doctrine verbatim
92
+ // (`cat "$DOCTRINE"`), no trailing newline added. BYTE-IDENTICAL to the legacy
93
+ // jq output — the golden test pins this exactly.
94
+ const layer12 = yaml + global + input.peerDoctrine
95
+
96
+ // Layer 3 (registry) and Layer 4 (other domains) are appended ONLY when they
97
+ // have content.
98
+ const layer3 = renderRegistry(input.peers ?? [])
99
+ const layer4 = renderDomains(input.pluginDomains ?? [])
100
+ const sections = [layer12, layer3, layer4].filter(section => section.length > 0)
101
+
102
+ // ONE section (no registry, no extra domains) → the legacy bytes, UNTOUCHED —
103
+ // the golden test pins layer12 exactly (peerDoctrine verbatim, incl. its own
104
+ // trailing-newline-or-not). MULTIPLE sections → normalize every seam to exactly
105
+ // one blank line: strip each section's trailing newlines, join with '\n\n', end
106
+ // with a single '\n'. This makes the output robust to whether the source files
107
+ // carried trailing newlines (a present-but-newline-only tail never widens a gap).
108
+ if (sections.length === 1) return sections[0]
109
+ return sections.map(section => section.replace(/\n+$/, '')).join('\n\n') + '\n'
110
+ }
111
+
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ // Layer 3 — normalized peer registry (publicPeerSummary, exactly 5 fields). The
114
+ // behavioral driver for delegation: without a peer list an executor never enters
115
+ // the model's reasoning (contract §"Реестр пиров"). Empty list → empty layer.
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ function renderRegistry(peers: readonly PublicPeerSummary[]): string {
119
+ if (peers.length === 0) return ''
120
+ const lines = ['## Known peers']
121
+ for (const p of peers) {
122
+ const desc = p.description ? ` — ${p.description}` : ''
123
+ lines.push(`- ${p.personality}${desc}`)
124
+ lines.push(` runtime: ${p.runtime}`)
125
+ lines.push(` runtimes: ${p.runtimes.join(', ')}`)
126
+ lines.push(` intelligence: ${p.intelligence}`)
127
+ }
128
+ return lines.join('\n')
129
+ }
130
+
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+ // Layer 4 — every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root, merged
133
+ // general → specific (global then local). The merge is ORGANIC: domain stems are
134
+ // used only for stable ordering (done by the gatherer), never emitted — custom
135
+ // operator files (SPAWNER_INSTRUCTIONS.md, …) flow in as ordinary domains. An
136
+ // empty file is a presence marker (Канал B) and contributes no text here.
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+
139
+ function renderDomains(domains: readonly PromptDomainBlock[]): string {
140
+ const blocks: string[] = []
141
+ for (const d of domains) {
142
+ // Per domain: global then local (general → specific), each trimmed of its
143
+ // trailing newlines so a 0-byte marker (or a newline-only file) drops out and
144
+ // the spacing is uniform. Halves of one domain are kept tight (single '\n');
145
+ // distinct domains are separated by a blank line below.
146
+ const halves = [d.global, d.local]
147
+ .filter((s): s is string => s !== undefined)
148
+ .map(s => s.replace(/\n+$/, ''))
149
+ .filter(s => s.length > 0)
150
+ if (halves.length > 0) blocks.push(halves.join('\n'))
151
+ }
152
+ return blocks.join('\n\n')
153
+ }
154
+
155
+ // ─────────────────────────────────────────────────────────────────────────────
156
+ // gatherPromptInput — the FS-discovery half: scan ~/.iapeer/*.md (global) and
157
+ // <cwd>/.iapeer/*.md (local), split IAPEER.md (Layer 2) from every other domain
158
+ // (Layer 4), and project the live registry through publicPeerSummary (Layer 3),
159
+ // into a ready-to-render ComposePromptInput. Layer-1 identity/host facts are
160
+ // supplied by the caller (lifecycle gathers them via sw_vers/hostname/etc).
161
+ // This is the ONLY FS-touching part; composeSystemPrompt itself stays pure.
162
+ // ─────────────────────────────────────────────────────────────────────────────
163
+
164
+ export interface GatherPromptOptions {
165
+ /** Layer 1 — identity + host facts (caller-gathered). */
166
+ personality: string
167
+ description: string
168
+ cwd: string
169
+ platform: string
170
+ osVersion: string
171
+ user: string
172
+ hostname: string
173
+ today: string
174
+ /** The global `.iapeer` root; defaults to resolveGlobalRoot(env) (= ~/.iapeer). */
175
+ globalRoot?: string
176
+ env?: NodeJS.ProcessEnv
177
+ /** Layer 3 override (tests); defaults to the live-registry projection. */
178
+ peers?: PublicPeerSummary[]
179
+ }
180
+
181
+ function readFileIfPresent(path: string): string | undefined {
182
+ try {
183
+ if (!statSync(path).isFile()) return undefined
184
+ return readFileSync(path, 'utf8')
185
+ } catch {
186
+ return undefined
187
+ }
188
+ }
189
+
190
+ /** `*.md` files directly at a `.iapeer` root (NOT recursing), excluding IAPEER.md
191
+ * (Layer 2) — the domain candidates for Layer 4. Missing dir → [].
192
+ * The `.md` test and the doctrine exclusion are CASE-INSENSITIVE: the Layer-2
193
+ * read (join(root,'IAPEER.md')) resolves a lowercase `iapeer.md` on a case-
194
+ * insensitive FS (macOS APFS), so a byte-exact exclusion here would let that same
195
+ * file ALSO surface as a Layer-4 domain and duplicate the doctrine. Lowercasing
196
+ * both also picks up an uppercase-extension `NOTES.MD` (spec: no root MD ignored). */
197
+ function listDomainFiles(root: string): string[] {
198
+ const doctrineLower = IAPEER_DOCTRINE_FILE.toLowerCase()
199
+ try {
200
+ return readdirSync(root, { withFileTypes: true })
201
+ .filter(e => {
202
+ if (!e.isFile()) return false
203
+ const lower = e.name.toLowerCase()
204
+ return lower.endsWith('.md') && lower !== doctrineLower
205
+ })
206
+ .map(e => e.name)
207
+ } catch {
208
+ return []
209
+ }
210
+ }
211
+
212
+ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput {
213
+ const env = opts.env ?? process.env
214
+ const globalRoot = opts.globalRoot ?? resolveGlobalRoot(env)
215
+ const localRoot = join(opts.cwd, IAPEER_DIR)
216
+
217
+ // Layer 2 — IAPEER.md merge. Global is existence-gated (undefined when absent,
218
+ // '' when present-but-empty — golden parity). Local defaults to '' when absent.
219
+ const globalDoctrine = readFileIfPresent(join(globalRoot, IAPEER_DOCTRINE_FILE))
220
+ const peerDoctrine = readFileIfPresent(join(localRoot, IAPEER_DOCTRINE_FILE)) ?? ''
221
+
222
+ // Layer 4 — union of every non-IAPEER domain present globally and/or locally,
223
+ // sorted by filename for a deterministic prompt (plain codepoint order). Empty
224
+ // (0-byte) files stay as presence markers — readFileIfPresent returns '', and
225
+ // renderDomains drops empty halves, so a marker contributes no text/separator.
226
+ const domainNames = new Set<string>([...listDomainFiles(globalRoot), ...listDomainFiles(localRoot)])
227
+ const pluginDomains: PromptDomainBlock[] = [...domainNames]
228
+ .sort()
229
+ .map(name => {
230
+ const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
231
+ const global = readFileIfPresent(join(globalRoot, name))
232
+ const local = readFileIfPresent(join(localRoot, name))
233
+ if (global !== undefined) block.global = global
234
+ if (local !== undefined) block.local = local
235
+ return block
236
+ })
237
+
238
+ // Layer 3 — peers projected through the shared normalizer, sorted by personality
239
+ // (determinism even if the on-disk file was hand-edited). `opts.peers` lets a
240
+ // caller that already read the registry (lifecycle's wake path) pass it in,
241
+ // avoiding a second read+parse on the hot launch path; the sort applies either
242
+ // way and `.slice()` keeps the caller's array untouched.
243
+ const peers = (opts.peers ?? readPeersIndex({ env, rootDir: globalRoot }).peers.map(publicPeerSummary))
244
+ .slice()
245
+ .sort((a, b) => (a.personality < b.personality ? -1 : a.personality > b.personality ? 1 : 0))
246
+
247
+ return {
248
+ personality: opts.personality,
249
+ description: opts.description,
250
+ cwd: opts.cwd,
251
+ platform: opts.platform,
252
+ osVersion: opts.osVersion,
253
+ user: opts.user,
254
+ hostname: opts.hostname,
255
+ today: opts.today,
256
+ peerDoctrine,
257
+ ...(globalDoctrine !== undefined ? { globalDoctrine } : {}),
258
+ peers,
259
+ pluginDomains,
260
+ }
261
+ }
@@ -0,0 +1,253 @@
1
+ // Launch — the single runtime-AGNOSTIC session bring-up primitive + adapter
2
+ // dispatch. `launch` is the generalized form of the Ф2 wakeOrSpawn inline boot
3
+ // loop + ready-gate (lifecycle/index.ts): pre-clean stale tmux server →
4
+ // new-session -d with adapter.buildArgv → pipe-pane → session self-TTL → (tui)
5
+ // boot dialogs + first-message delivery → ready-gate on adapter.newestActivity
6
+ // Mtime. Every runtime specific (argv flags, boot dialogs, ready marker, activity
7
+ // proxy) comes through the RuntimeAdapter; this file holds NO runtime strings.
8
+ //
9
+ // Ownership split (blueprint §1, §7): launch = HOW to bring up ONE session
10
+ // (runtime-agnostic). It carries NO lifecycle concerns (no wake-lock, no
11
+ // registry/findPeer, no launchd guard, no session-state, no supervise/reap —
12
+ // those stay in lifecycle/index.ts, which calls launch) and NO currency (no
13
+ // marketplace / plugin install / update — that is install-time). lifecycle
14
+ // decides WHEN/HOW-MANY and hands launch a fully-resolved LaunchSpec + adapter.
15
+
16
+ import { mkdirSync } from 'fs'
17
+ import { homedir } from 'os'
18
+ import { dirname } from 'path'
19
+ import { spawnSync } from 'child_process'
20
+ import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, type Runtime } from '../core/constants.ts'
21
+ import { readLaunchEnv } from '../storage/index.ts'
22
+ import { claudeAdapter } from './adapters/claude.ts'
23
+ import { codexAdapter } from './adapters/codex.ts'
24
+ import { telegramAdapter } from './adapters/telegram.ts'
25
+ import { notifierAdapter } from './adapters/notifier.ts'
26
+ import type {
27
+ LaunchConfig,
28
+ LaunchFn,
29
+ LaunchResult,
30
+ LaunchSpec,
31
+ RuntimeAdapter,
32
+ } from './types.ts'
33
+
34
+ // Re-export the launch contract so consumers (lifecycle, cli) import from the
35
+ // module index, not the frozen types file directly.
36
+ export type {
37
+ ComposePromptInput,
38
+ ComposeSystemPrompt,
39
+ PromptDomainBlock,
40
+ PublicPeerSummary,
41
+ LaunchAdapterConfig,
42
+ LaunchConfig,
43
+ LaunchFn,
44
+ LaunchResult,
45
+ LaunchSpec,
46
+ RuntimeAdapter,
47
+ } from './types.ts'
48
+
49
+ // Always-on launchd plist generation (infra runtimes). launchdRun.ts is a CLI
50
+ // entrypoint (referenced by the generated plist via its file path) — deliberately
51
+ // NOT re-exported here, to avoid an index ↔ launchdRun import cycle.
52
+ export {
53
+ launchdLabel,
54
+ launchAgentsDir,
55
+ launchdPlistPath,
56
+ renderLaunchdPlist,
57
+ installAlwaysOnPlist,
58
+ isFoundationOwnedPlist,
59
+ launchctlBootstrap,
60
+ resolveExecutable,
61
+ IAPEER_PLIST_OWNER_KEY,
62
+ } from './launchd.ts'
63
+ export type {
64
+ LaunchdPlistSpec,
65
+ InstallAlwaysOnPlistOptions,
66
+ BootstrapResult,
67
+ BootstrapState,
68
+ } from './launchd.ts'
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // Adapter dispatch — claude/codex (tui) + telegram/notifier (router, infra).
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ export function getAdapter(runtime: Runtime): RuntimeAdapter {
75
+ if (runtime === 'claude') return claudeAdapter
76
+ if (runtime === 'codex') return codexAdapter
77
+ if (runtime === 'telegram') return telegramAdapter
78
+ if (runtime === 'notifier') return notifierAdapter
79
+ throw new Error(`no launch adapter for runtime "${runtime}"`)
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // tmux helpers (direct spawnSync; no lifecycle dependency)
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+
86
+ function tmux(sock: string, ...args: string[]): { ok: boolean; out: string; err: string } {
87
+ const r = spawnSync('tmux', ['-S', sock, ...args], { encoding: 'utf8' })
88
+ return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
89
+ }
90
+ function sessionAlive(sock: string, identity: string): boolean {
91
+ return tmux(sock, 'has-session', '-t', identity).ok
92
+ }
93
+ function capturePane(sock: string, identity: string): string {
94
+ return tmux(sock, 'capture-pane', '-t', identity, '-p').out
95
+ }
96
+ function shellQuote(value: string): string {
97
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
98
+ }
99
+ function sleep(ms: number): Promise<void> {
100
+ return new Promise(r => setTimeout(r, ms))
101
+ }
102
+
103
+ function fail(identity: string, reason: string): LaunchResult {
104
+ return { status: 'FAILED', identity, process_address: identity, reason }
105
+ }
106
+ function ready(identity: string): LaunchResult {
107
+ return { status: 'READY', identity, process_address: identity }
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // launch — bring up ONE session (runtime-agnostic via the adapter)
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+
114
+ export const launch: LaunchFn = async (
115
+ spec: LaunchSpec,
116
+ adapter: RuntimeAdapter,
117
+ firstMessage: string,
118
+ cfg: LaunchConfig,
119
+ ): Promise<LaunchResult> => {
120
+ const { identity, socketPath: sock, cwd } = spec
121
+ const env = cfg.env ?? process.env
122
+
123
+ // (0) Intelligence gate (fail-loud BEFORE any tmux work): an adapter that declares
124
+ // requiresIntelligence (telegram → 'natural') refuses a peer whose nature does
125
+ // not match — and refuses too when the nature is unknown (cannot confirm). Ports
126
+ // the persistent-peer FATAL human-channel guard (docs/Рантайм-адаптеры).
127
+ if (adapter.requiresIntelligence && spec.intelligence !== adapter.requiresIntelligence) {
128
+ return fail(
129
+ identity,
130
+ `runtime "${spec.runtime}" requires intelligence=${adapter.requiresIntelligence}, ` +
131
+ `peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`,
132
+ )
133
+ }
134
+
135
+ // (0.5) Ensure the socket's PARENT dir exists before `tmux new-session -S <sock>` —
136
+ // tmux does NOT create it and fails SILENTLY (the session "dies immediately")
137
+ // when the dir is absent. In prod the sock dir is /tmp (always there) so this
138
+ // never bites; it bites a sandbox/test with an IAPEER_SOCK_DIR override pointing
139
+ // at a not-yet-created dir. Symmetric to the cfg.logDir mkdir before pipe-pane.
140
+ mkdirSync(dirname(sock), { recursive: true })
141
+
142
+ // (1) Pre-clean any stale tmux server on this socket, then launch detached.
143
+ spawnSync('pkill', ['-f', `tmux -S ${sock} `], { stdio: 'ignore' })
144
+ tmux(sock, 'kill-server')
145
+
146
+ // (2) tmux new-session -d with the adapter-built, shell-quoted argv. The per-peer
147
+ // per-runtime launch.env (zone Хранение / Рантайм-адаптеры) contributes
148
+ // PEER_START_ARGS (APPENDED after the adapter's base flags — extraArgs is last
149
+ // in every buildArgv) and extra child env. spec.extraArgs (an explicit caller
150
+ // override) comes first, then launch.env's; both land after the base flags.
151
+ const launchEnv = readLaunchEnv(cwd, spec.runtime)
152
+ const specWithArgs: LaunchSpec =
153
+ launchEnv.startArgs.length > 0
154
+ ? { ...spec, extraArgs: [...(spec.extraArgs ?? []), ...launchEnv.startArgs] }
155
+ : spec
156
+ const runtimeCmd = adapter.buildArgv(specWithArgs, cfg).map(shellQuote).join(' ')
157
+ // Child env: base env + launch.env extras + the identity ABI (identity LAST so a
158
+ // stray PEER_* in launch.env can never override the resolved identity) + PATH.
159
+ const childEnv: NodeJS.ProcessEnv = {
160
+ ...env,
161
+ ...launchEnv.env,
162
+ // codex token-free MCP import: the daemon's bearer env var is set to the PUBLIC,
163
+ // non-secret stub so codex flips authStatus unsupported→bearer_token and imports
164
+ // send_to_peer (the OPEN daemon ignores it, authenticating by PEER_IDENTITY below
165
+ // via env_http_headers). Only for codex — claude carries its identity in .mcp.json.
166
+ ...(spec.runtime === 'codex' ? { [CODEX_BEARER_ENV_VAR]: CODEX_DUMMY_BEARER } : {}),
167
+ PEER_PERSONALITY: spec.personality,
168
+ PEER_RUNTIME: spec.runtime,
169
+ PEER_IDENTITY: identity,
170
+ PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
171
+ }
172
+ const start = spawnSync(
173
+ 'tmux',
174
+ ['-S', sock, 'new-session', '-d', '-s', identity, '-x', '220', '-y', '50', '-c', cwd, runtimeCmd],
175
+ { env: childEnv as Record<string, string>, encoding: 'utf8' },
176
+ )
177
+ if (start.status !== 0) {
178
+ return fail(identity, `tmux new-session failed: ${(start.stderr ?? '').trim() || 'exit ' + start.status}`)
179
+ }
180
+
181
+ // (3) pipe-pane the session output to the per-identity log.
182
+ mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 })
183
+ const paneLog = `${cfg.logDir}/${identity}.log`
184
+ tmux(sock, 'pipe-pane', '-t', identity, `cat >> ${shellQuote(paneLog)}`)
185
+
186
+ // (4) Session self-TTL: a tmux server-side timer kills the session at max-age,
187
+ // independent of any supervisor (bounds a zombie even if no sweep runs).
188
+ // SKIPPED for an always-on (infra) session — launchd KeepAlive owns its
189
+ // lifecycle, so a self-kill timer would fight it and tear down the endpoint.
190
+ if (!cfg.alwaysOn) {
191
+ tmux(sock, 'run-shell', '-b', '-d', String(cfg.maxAgeSecs),
192
+ `tmux -S ${shellQuote(sock)} kill-session -t ${shellQuote(identity)}`)
193
+ }
194
+
195
+ // (5) Router runtime (telegram): no TUI input surface — there is no boot dialog
196
+ // and no ready marker to gate on. The process is up; return READY.
197
+ if (adapter.kind === 'router') {
198
+ return ready(identity)
199
+ }
200
+
201
+ // (6) BOOT phase (tui) — baseline the activity proxy, answer startup dialogs,
202
+ // wait for the input surface, then deliver firstMessage (send-keys -l + Enter).
203
+ // An EMPTY firstMessage is a BARE bring-up (folder-launch with no seed, or an
204
+ // attach-resume that carries no message): reach the input surface and return
205
+ // READY — there is no message to deliver and nothing to ready-gate on (the
206
+ // operator drives the session). A non-empty message takes the wake path
207
+ // (deliver + ready-gate on a model turn).
208
+ const hasMessage = firstMessage.trim().length > 0
209
+ const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
210
+ const bootIters = Math.max(1, Math.ceil(cfg.bootDeadlineSecs / 2))
211
+ let delivered = false
212
+ for (let i = 0; i < bootIters && !delivered; i++) {
213
+ await sleep(2000)
214
+ if (!sessionAlive(sock, identity)) {
215
+ return fail(identity, 'tmux session vanished during boot')
216
+ }
217
+ const pane = capturePane(sock, identity)
218
+ if (!pane) continue
219
+ const dialogKeys = adapter.bootDialogKeys(pane)
220
+ if (dialogKeys) {
221
+ tmux(sock, 'send-keys', '-t', identity, ...dialogKeys)
222
+ continue
223
+ }
224
+ if (adapter.isInputReady(pane)) {
225
+ if (!hasMessage) return ready(identity) // bare bring-up — session up, no message
226
+ // `--` terminates tmux option parsing (audit #6): without it a firstMessage that
227
+ // starts with '-' (e.g. a task opening with a markdown bullet) is mis-parsed as a
228
+ // send-keys flag, losing the boot message and failing the wake with a wrong reason.
229
+ tmux(sock, 'send-keys', '-t', identity, '-l', '--', firstMessage)
230
+ await sleep(300)
231
+ tmux(sock, 'send-keys', '-t', identity, 'Enter')
232
+ delivered = true
233
+ }
234
+ }
235
+ if (!delivered) {
236
+ return fail(identity, 'never-became-ready (stuck at a startup prompt or boot hang)')
237
+ }
238
+
239
+ // (7) READY-GATE — the activity proxy must strictly advance past baseline
240
+ // (the model picked up and processed the first message).
241
+ const readyDeadline = Date.now() + cfg.readyGateSecs * 1000
242
+ while (Date.now() < readyDeadline) {
243
+ await sleep(2000)
244
+ if (!sessionAlive(sock, identity)) {
245
+ return fail(identity, 'tmux session vanished during ready-gate')
246
+ }
247
+ const mt = adapter.newestActivityMtime(cwd) ?? 0
248
+ if (mt > baselineMtime) {
249
+ return ready(identity)
250
+ }
251
+ }
252
+ return fail(identity, 'model-did-not-process-task (no activity advance after delivery)')
253
+ }