@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,608 @@
1
+ // iapeer CLI — the unified operator/agent entrypoint (`iapeer <verb> …`, contract
2
+ // Примитивы §Карта verbs). Thin verbs over the foundation primitives: list (registry
3
+ // + liveness + C1 stopped), stop/start (C1 durable flag for warm; launchctl for
4
+ // always-on), send (routeSend fallback). init delegates to src/init; launch (folder)
5
+ // and attach (last-active resume) land in the next increment.
6
+ //
7
+ // FLEET SAFETY (H4): the live persistent-peer fleet is launchd-managed (com.iapeer.<p>
8
+ // plists the foundation does NOT own). stop/start REFUSE such a peer — the foundation
9
+ // is read-only for it; stopping it would fight PP's KeepAlive / tear a live telegram
10
+ // bridge off launchd. Only foundation-owned peers (warm no-plist, or our own
11
+ // sentinel-marked always-on plist) are stop/start-able.
12
+
13
+ import { spawnSync } from 'child_process'
14
+ import { fileURLToPath } from 'url'
15
+ import {
16
+ isInfraRuntime,
17
+ isRuntime,
18
+ type Intelligence,
19
+ type Runtime,
20
+ } from '../core/constants.ts'
21
+ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
22
+ import { ensureGlobalIapScaffold } from '../storage/index.ts'
23
+ import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
24
+ import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
25
+ import {
26
+ attachPeer,
27
+ clearStopped,
28
+ folderLaunch,
29
+ isLaunchdManaged,
30
+ isStopped,
31
+ killSession,
32
+ loadLifecycleConfig,
33
+ setStopped,
34
+ wakeOrSpawn,
35
+ } from '../lifecycle/index.ts'
36
+ import { getAdapter } from '../launch/index.ts'
37
+ import { isFoundationOwnedPlist, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
38
+ import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
39
+ import { runAlwaysOn } from '../launch/launchdRun.ts'
40
+ import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
41
+ import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // list — registry + per-runtime liveness (contract Примитивы §list)
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ export type RuntimeLiveness = 'live' | 'asleep' | 'stopped'
48
+ export interface RuntimeStatus {
49
+ runtime: Runtime
50
+ status: RuntimeLiveness
51
+ }
52
+ export interface PeerListing {
53
+ personality: string
54
+ default_runtime: Runtime
55
+ /** Runtime with the freshest activity (what `attach` resumes); undefined if none. */
56
+ last_active_runtime?: Runtime
57
+ intelligence: Intelligence
58
+ description: string
59
+ runtimes: RuntimeStatus[]
60
+ }
61
+
62
+ export interface CliEnvOptions {
63
+ env?: NodeJS.ProcessEnv
64
+ }
65
+
66
+ /**
67
+ * Gather the peer listing: one row per registered peer, per-runtime liveness (live
68
+ * via tmux has-session / stopped via the C1 durable flag / else asleep) and the
69
+ * last-active runtime by transcript-mtime (the same proxy `attach` keys on).
70
+ */
71
+ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
72
+ const env = opts.env ?? process.env
73
+ const cfg = loadLifecycleConfig(env)
74
+ const index = readPeersIndex({ env })
75
+ return index.peers.map(peer => {
76
+ const runtimes: RuntimeStatus[] = peer.runtimes.map(rt => ({
77
+ runtime: rt,
78
+ status: isPeerLive(rt, peer.personality, cfg.sockDir)
79
+ ? 'live'
80
+ : isStopped(cfg, buildProcessAddress(rt, peer.personality))
81
+ ? 'stopped'
82
+ : 'asleep',
83
+ }))
84
+ let lastActive: Runtime | undefined
85
+ let bestMt = -1
86
+ for (const rt of peer.runtimes) {
87
+ try {
88
+ const mt = getAdapter(rt).newestActivityMtime(peer.cwd)
89
+ if (mt !== null && mt > bestMt) {
90
+ bestMt = mt
91
+ lastActive = rt
92
+ }
93
+ } catch {
94
+ /* no adapter / no proxy for this runtime */
95
+ }
96
+ }
97
+ return {
98
+ personality: peer.personality,
99
+ default_runtime: peer.runtime,
100
+ last_active_runtime: lastActive,
101
+ intelligence: peer.intelligence,
102
+ description: peer.description,
103
+ runtimes,
104
+ }
105
+ })
106
+ }
107
+
108
+ const GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
109
+
110
+ /** Render the scriptable list table (non-tty default). */
111
+ export function formatListTable(rows: PeerListing[]): string {
112
+ if (rows.length === 0) return 'no peers registered\n'
113
+ const lines = rows.map(r => {
114
+ const status = r.runtimes.map(s => `${GLYPH[s.status]} ${s.runtime}`).join(' ')
115
+ const la = r.last_active_runtime ? ` last-active:${r.last_active_runtime}` : ''
116
+ return `${r.personality} [${r.default_runtime}] ${r.intelligence} ${status}${la}`
117
+ })
118
+ return lines.join('\n') + '\n'
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+ // stop / start — dispatch by runtime class, with the FLEET GUARD (H4)
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+
125
+ export interface StopStartOutcome {
126
+ personality: string
127
+ runtime: Runtime
128
+ action: 'stopped' | 'started' | 'bootout' | 'bootstrap' | 'refused-foreign-launchd'
129
+ reason?: string
130
+ }
131
+
132
+ function uid(): string {
133
+ const r = spawnSync('id', ['-u'], { encoding: 'utf8' })
134
+ const u = (r.stdout ?? '').trim()
135
+ // Audit #29: NEVER fall back to '0' — that would aim launchctl bootout/bootstrap at
136
+ // the ROOT gui domain. A non-numeric/empty result means `id -u` failed; refuse.
137
+ if (!/^\d+$/.test(u)) {
138
+ throw new Error('cannot resolve the current uid (id -u failed) — refusing to target launchctl at an unknown domain')
139
+ }
140
+ return u
141
+ }
142
+
143
+ /** FLEET GUARD: a peer launchd-managed by a NON-foundation plist (persistent-peer)
144
+ * is off-limits to stop/start — the foundation is read-only for it (H4). */
145
+ function isForeignLaunchd(personality: string, env: NodeJS.ProcessEnv): boolean {
146
+ return isLaunchdManaged(personality, env) && !isFoundationOwnedPlist(launchdPlistPath(personality, env))
147
+ }
148
+
149
+ function targetRuntimes(peer: PeerRecord, runtime: string | undefined): Runtime[] {
150
+ if (runtime) {
151
+ // Audit #28: an explicit runtime the peer does not declare would act on a PHANTOM
152
+ // identity — a spurious durable stop-flag or a no-op bootout on a label that isn't
153
+ // this peer's. Refuse instead of silently targeting a non-existent runtime.
154
+ if (peer.runtime !== runtime && !peer.runtimes.includes(runtime as Runtime)) {
155
+ throw new Error(
156
+ `peer "${peer.personality}" does not declare runtime "${runtime}" (declared: ${peer.runtimes.join(', ')})`,
157
+ )
158
+ }
159
+ return [runtime as Runtime]
160
+ }
161
+ return peer.runtimes
162
+ }
163
+
164
+ /**
165
+ * stop <peer> [runtime]: warm runtime → durable C1 stop flag + kill the session (the
166
+ * daemon will not wake it until `start`); always-on (infra, foundation-owned) → launchctl
167
+ * bootout + kill. REFUSES a foreign-launchd peer (live PP fleet) — fleet guard.
168
+ */
169
+ export function stopPeer(personality: string, runtime: string | undefined, opts: CliEnvOptions = {}): StopStartOutcome[] {
170
+ const env = opts.env ?? process.env
171
+ const cfg = loadLifecycleConfig(env)
172
+ const peer = findPeer(readPeersIndex({ env }), personality)
173
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
174
+ if (isForeignLaunchd(personality, env)) {
175
+ return [{ personality, runtime: peer.runtime, action: 'refused-foreign-launchd', reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not stop it` }]
176
+ }
177
+ const out: StopStartOutcome[] = []
178
+ for (const rt of targetRuntimes(peer, runtime)) {
179
+ const identity = buildProcessAddress(rt, personality)
180
+ const sock = buildSocketPath(rt, personality, cfg.sockDir)
181
+ if (isInfraRuntime(rt)) {
182
+ // Audit #13: do NOT swallow the launchctl result. (bootout returns non-zero when
183
+ // the service was already not loaded — benign for stop — but surface the detail in
184
+ // the reason rather than silently claiming success.)
185
+ const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
186
+ killSession(sock, identity)
187
+ out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
188
+ } else {
189
+ setStopped(cfg, identity)
190
+ killSession(sock, identity)
191
+ out.push({ personality, runtime: rt, action: 'stopped' })
192
+ }
193
+ }
194
+ return out
195
+ }
196
+
197
+ /**
198
+ * start <peer> [runtime]: warm runtime → clear the C1 stop flag (wakeable again on
199
+ * the next message); always-on → launchctl bootstrap the plist. REFUSES a foreign-
200
+ * launchd peer (fleet guard).
201
+ */
202
+ export function startPeer(personality: string, runtime: string | undefined, opts: CliEnvOptions = {}): StopStartOutcome[] {
203
+ const env = opts.env ?? process.env
204
+ const cfg = loadLifecycleConfig(env)
205
+ const peer = findPeer(readPeersIndex({ env }), personality)
206
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
207
+ if (isForeignLaunchd(personality, env)) {
208
+ return [{ personality, runtime: peer.runtime, action: 'refused-foreign-launchd', reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not start it` }]
209
+ }
210
+ const out: StopStartOutcome[] = []
211
+ for (const rt of targetRuntimes(peer, runtime)) {
212
+ const identity = buildProcessAddress(rt, personality)
213
+ if (isInfraRuntime(rt)) {
214
+ const plist = launchdPlistPath(personality, env)
215
+ // Audit #13: a failed bootstrap means the peer did NOT start — surface it instead
216
+ // of reporting success silently.
217
+ const r = spawnSync('launchctl', ['bootstrap', `gui/${uid()}`, plist], { encoding: 'utf8' })
218
+ out.push({ personality, runtime: rt, action: 'bootstrap', reason: r.status === 0 ? undefined : `launchctl bootstrap FAILED (exit ${r.status})${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''} — peer not started` })
219
+ } else {
220
+ clearStopped(cfg, identity)
221
+ out.push({ personality, runtime: rt, action: 'started' })
222
+ }
223
+ }
224
+ return out
225
+ }
226
+
227
+ // ─────────────────────────────────────────────────────────────────────────────
228
+ // send — manual IAP send fallback (contract Примитивы §send). Goes through the
229
+ // same router path as send_to_peer (resolve → deliver / wake), in-process so it
230
+ // works even when the daemon HTTP listener is down. --from sets the sender.
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+
233
+ export interface SendOptions extends CliEnvOptions {
234
+ /** Sender identity `<runtime>-<personality>`; default = the cwd peer's identity. */
235
+ from: string
236
+ target: string
237
+ runtime?: string
238
+ message: string
239
+ topic?: string
240
+ attachments?: string[]
241
+ }
242
+
243
+ const cliWake: WakeFn = req =>
244
+ wakeOrSpawn({ personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task })
245
+
246
+ export async function sendMessage(opts: SendOptions): Promise<{ ok: true; delivered_to: { personality: string; runtime: string } }> {
247
+ const env = opts.env ?? process.env
248
+ const caller = resolveCallerIdentity(parseIdentity(opts.from), readPeersIndex({ env }))
249
+ const result = await routeSend(
250
+ caller,
251
+ {
252
+ personality: opts.target,
253
+ runtime: opts.runtime,
254
+ message: opts.message,
255
+ topic: opts.topic,
256
+ attachments: opts.attachments,
257
+ },
258
+ { wake: cliWake },
259
+ )
260
+ if (!result.ok) throw new Error(result.error.message)
261
+ return { ok: true, delivered_to: result.value.delivered_to }
262
+ }
263
+
264
+ function parseIdentity(identity: string): { personality: string; runtime: Runtime } {
265
+ const dash = identity.indexOf('-')
266
+ if (dash <= 0) throw new Error(`invalid --from identity "${identity}" — expected <runtime>-<personality>`)
267
+ const runtime = identity.slice(0, dash)
268
+ if (!isRuntime(runtime)) throw new Error(`invalid runtime in --from "${identity}"`)
269
+ return { runtime, personality: identity.slice(dash + 1) }
270
+ }
271
+
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+ // CLI dispatch — `iapeer <verb> …`
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ export function parseArgs(argv: string[]): { positionals: string[]; flags: Record<string, string | true> } {
277
+ const positionals: string[] = []
278
+ const flags: Record<string, string | true> = {}
279
+ for (let i = 0; i < argv.length; i++) {
280
+ const a = argv[i]
281
+ if (a.startsWith('--')) {
282
+ // Audit #27: support `--key=value` so a value that itself starts with '--' (e.g.
283
+ // `send --message=--look-at-this`) is not silently dropped by the look-ahead form.
284
+ const eq = a.indexOf('=')
285
+ if (eq > 2) {
286
+ flags[a.slice(2, eq)] = a.slice(eq + 1)
287
+ continue
288
+ }
289
+ const key = a.slice(2)
290
+ const next = argv[i + 1]
291
+ if (next === undefined || next.startsWith('--')) flags[key] = true
292
+ else flags[key] = argv[++i]
293
+ } else {
294
+ positionals.push(a)
295
+ }
296
+ }
297
+ return { positionals, flags }
298
+ }
299
+
300
+ const USAGE = `usage: iapeer <verb> [args]
301
+ install build binary + global scaffold + daemon plist (one bootstrap)
302
+ daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
303
+ onboard [--dry-run] [--infra <csv>] register the agfpd marketplace (+ npx-install & deploy infra runtimes)
304
+ install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
305
+ init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
306
+ create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
307
+ list [--json] registered peers + per-runtime liveness
308
+ stop <peer> [runtime] | --all durable-stop a warm peer / bootout an always-on one
309
+ start <peer> [runtime] re-enable a stopped peer / bootstrap an always-on one
310
+ send <target> --message <text> [--from <id>] [--topic <t>] manual IAP send (fallback)
311
+ <runtime> launch the cwd's peer (ALWAYS fresh)
312
+ enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
313
+ attach <peer> [runtime] ensure-live + resume, then tmux attach
314
+ interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
315
+ compact <peer> [runtime] compact the peer's context (/compact)
316
+ `
317
+
318
+ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
319
+ const [verb, ...rest] = argv
320
+ const { positionals, flags } = parseArgs(rest)
321
+ const out = (s: string) => process.stdout.write(s)
322
+ const errOut = (s: string) => process.stderr.write(s)
323
+
324
+ try {
325
+ switch (verb) {
326
+ case 'onboard': {
327
+ // Host-phase: register OUR marketplace in claude + codex (IDEMPOTENT — detect
328
+ // → skip when present; an already-configured host is a no-op). --dry-run
329
+ // reports the would-be actions without touching anything. --infra <csv> ALSO
330
+ // onboards infra runtimes (§6): npx-install each package (auto-resolved) + deploy
331
+ // its declared set. notifier → timer+watcher auto; telegram → operator-add after.
332
+ const r = onboardHost({ dryRun: flags['dry-run'] === true, env })
333
+ for (const m of r.marketplaces) {
334
+ out(`marketplace ${MARKETPLACE_NAME} @ ${m.runtime}: ${m.state}${m.detail ? ` — ${m.detail}` : ''}\n`)
335
+ }
336
+ out(r.noop ? 'onboard: no marketplace changes (already configured / dry-run)\n' : 'onboard: marketplace(s) registered\n')
337
+ let infraFailed = false
338
+ const infra = typeof flags.infra === 'string' ? flags.infra.split(',').map(s => s.trim()).filter(Boolean) : []
339
+ if (infra.length && flags['dry-run'] !== true) {
340
+ const { onboardRuntime } = await import('../runtime/deploy.ts')
341
+ for (const rt of infra) {
342
+ try {
343
+ const or = await onboardRuntime({ runtime: rt as Runtime, env, warn: m => errOut(`warn: ${m}\n`) })
344
+ out(`infra ${rt}: package ${or.install.package ?? '(none)'} ${or.install.state}; ` +
345
+ (or.deploy!.operatorAddOnly ? `operator-add (use \`iapeer create <peer> --runtime ${rt}\`)` : `${or.deploy!.peers.length} peer(s) deployed`) + '\n')
346
+ if (or.deploy!.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed')) infraFailed = true
347
+ } catch (e) {
348
+ infraFailed = true
349
+ errOut(`infra ${rt}: ${e instanceof Error ? e.message : String(e)}\n`)
350
+ }
351
+ }
352
+ } else if (infra.length) {
353
+ out(`onboard --dry-run: would onboard infra runtimes: ${infra.join(', ')}\n`)
354
+ }
355
+ return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
356
+ }
357
+ case 'install-runtime': {
358
+ // §6 onboard a runtime END-TO-END: npx-install the package (auto-resolved from
359
+ // the built-in runtime→package registry, or --package; self-deploys bin +
360
+ // manifest), THEN deploy its declared peer-set (each → provision + per-peer
361
+ // self-config + auto-bootstrap). A runtime whose manifest declares no peers
362
+ // (telegram) is operator-add — use `iapeer create <human> --runtime telegram`.
363
+ if (!positionals[0]) return usage(errOut)
364
+ const { onboardRuntime } = await import('../runtime/deploy.ts')
365
+ const r = await onboardRuntime({
366
+ runtime: positionals[0] as Runtime,
367
+ package: typeof flags.package === 'string' ? flags.package : undefined,
368
+ npx: flags.npx === true,
369
+ bootstrap: flags['no-bootstrap'] === true ? false : undefined,
370
+ env,
371
+ warn: m => errOut(`warn: ${m}\n`),
372
+ })
373
+ out(`package ${r.install.package ?? '(none)'}: ${r.install.state}${r.install.detail ? ` — ${r.install.detail}` : ''}\n`)
374
+ const d = r.deploy!
375
+ if (d.operatorAddOnly) {
376
+ out(`runtime "${d.runtime}": no declared peer-set (operator-add — use \`iapeer create <peer> --runtime ${d.runtime}\`)\n`)
377
+ return 0
378
+ }
379
+ for (const p of d.peers) {
380
+ out(` ${p.personality} @ ${p.location}: self-config ${p.selfConfig ?? 'n/a'}; bootstrap ${p.bootstrap ?? 'n/a'}\n`)
381
+ }
382
+ out(`deployed runtime "${d.runtime}" (${d.peers.length} peer(s))\n`)
383
+ return d.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed') ? 1 : 0
384
+ }
385
+ case 'init': {
386
+ // cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
387
+ // identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
388
+ // when not explicit. Auto-bootstraps an infra plist unless --no-bootstrap.
389
+ const { initPeer } = await import('../init/index.ts')
390
+ const r = await initPeer({
391
+ cwd: positionals[0] ?? process.cwd(),
392
+ runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
393
+ personality: typeof flags.personality === 'string' ? flags.personality : undefined,
394
+ description: typeof flags.description === 'string' ? flags.description : undefined,
395
+ runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
396
+ bootstrap: flags['no-bootstrap'] === true ? false : undefined,
397
+ env,
398
+ warn: m => errOut(`warn: ${m}\n`),
399
+ })
400
+ out(
401
+ `initialized "${r.personality}" (${r.runtime}); mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
402
+ `${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
403
+ )
404
+ return 0
405
+ }
406
+ case 'create': {
407
+ // cwd-INDEPENDENT: resolve a location (default ~/.iapeer/peers/<p> or --path),
408
+ // scaffold the folder (no-clobber), then init it. Operator-add for an infra
409
+ // human (telegram) or any agentic peer; provisions + auto-bootstraps infra.
410
+ if (!positionals[0]) return usage(errOut)
411
+ const { createPeer } = await import('../create/index.ts')
412
+ const r = await createPeer({
413
+ personality: positionals[0],
414
+ runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
415
+ path: typeof flags.path === 'string' ? flags.path : undefined,
416
+ description: typeof flags.description === 'string' ? flags.description : undefined,
417
+ intelligence: typeof flags.intelligence === 'string' ? (flags.intelligence as Intelligence) : undefined,
418
+ runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
419
+ bootstrap: flags['no-bootstrap'] === true ? false : undefined,
420
+ env,
421
+ warn: m => errOut(`warn: ${m}\n`),
422
+ })
423
+ out(
424
+ `created "${r.personality}" (${r.runtime}) at ${r.location}; mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
425
+ `${r.plistPath ? `; plist: ${r.plistPath}` : ''}${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
426
+ )
427
+ return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
428
+ }
429
+ case 'list': {
430
+ // tty + no --json → the interactive control-panel (↑/↓ · Enter=attach · / · q);
431
+ // non-tty / --json → the scriptable table (machine-parsable).
432
+ if (flags.json !== true && process.stdout.isTTY && process.stdin.isTTY) {
433
+ const { runListTui } = await import('./listTui.ts')
434
+ return await runListTui(env)
435
+ }
436
+ const rows = listPeers({ env })
437
+ out(flags.json ? JSON.stringify(rows, null, 2) + '\n' : formatListTable(rows))
438
+ return 0
439
+ }
440
+ case 'stop': {
441
+ // --all stops every registered peer (the fleet guard still refuses foreign
442
+ // persistent-peer plists, so the live fleet stays untouched).
443
+ const peers = flags.all === true
444
+ ? readPeersIndex({ env }).peers.map(p => p.personality)
445
+ : positionals[0]
446
+ ? [positionals[0]]
447
+ : null
448
+ if (!peers) return usage(errOut)
449
+ const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
450
+ for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
451
+ return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
452
+ }
453
+ case 'start': {
454
+ if (!positionals[0]) return usage(errOut)
455
+ const outcomes = startPeer(positionals[0], positionals[1], { env })
456
+ for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
457
+ return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
458
+ }
459
+ case 'send': {
460
+ if (!positionals[0] || typeof flags.message !== 'string') return usage(errOut)
461
+ const r = await sendMessage({
462
+ target: positionals[0],
463
+ from: typeof flags.from === 'string' ? flags.from : defaultFromIdentity(env),
464
+ message: flags.message,
465
+ runtime: typeof flags.runtime === 'string' ? flags.runtime : undefined,
466
+ topic: typeof flags.topic === 'string' ? flags.topic : undefined,
467
+ env,
468
+ })
469
+ out(`delivered to ${r.delivered_to.personality} (${r.delivered_to.runtime})\n`)
470
+ return 0
471
+ }
472
+ case 'install': {
473
+ // UNIFIED foundation install (contract Установка §1 — "один npx ставит
474
+ // фундамент"): ONE command does all three install-phase steps that used to be
475
+ // split across `install` + `daemon --install-plist`:
476
+ // (1) global scaffold ~/.iapeer/ (+ peers/, state/logs/cache, runtime scopes)
477
+ // (2) build + place the stable ~/.local/bin/iapeer binary (atomic)
478
+ // (3) WRITE the daemon's com.agfpd.iapeer plist (NOT bootstrapped — a live
479
+ // daemon already runs; migrating it onto the installed binary is a
480
+ // separate coordinated wave, contract Установка §1).
481
+ // Bootstrap path — run from the src tree (`bun src/cli/index.ts install`) or
482
+ // npx; the compiled binary cannot rebuild itself from source (its
483
+ // import.meta.url is the binary → build fails with a clear error).
484
+ const { installIapeer } = await import('../install/index.ts')
485
+ ensureGlobalIapScaffold({ env })
486
+ const r = installIapeer(fileURLToPath(import.meta.url), env)
487
+ const plist = installDaemonPlist({ env })
488
+ out(
489
+ `installed iapeer → ${r.binPath}` +
490
+ `${r.prevPath ? ` (previous kept: ${r.prevPath})` : ''}` +
491
+ `${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
492
+ ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
493
+ ` daemon plist written: ${plist}\n` +
494
+ ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
495
+ )
496
+ return 0
497
+ }
498
+ case 'daemon': {
499
+ // Ф-F: the prod daemon entrypoint. The launchd plist runs `iapeer daemon`
500
+ // (the INSTALLED binary), decoupling prod from the mutable src tree.
501
+ if (flags['install-plist'] === true) {
502
+ const p = installDaemonPlist({ env })
503
+ out(`installed daemon plist: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
504
+ return 0
505
+ }
506
+ const handle = await startConfiguredDaemon({
507
+ port: env.IAPEER_PORT?.trim() ? Number(env.IAPEER_PORT) : undefined,
508
+ socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
509
+ env,
510
+ })
511
+ errOut(`[iapeer] daemon READY tcp=${handle.url} sock=${handle.socketPath}\n`)
512
+ const shutdown = () => void handle.close().then(() => process.exit(0))
513
+ process.on('SIGTERM', shutdown)
514
+ process.on('SIGINT', shutdown)
515
+ await new Promise(() => {}) // launchd KeepAlive holds this process; block forever
516
+ return 0
517
+ }
518
+ case 'run-infra': {
519
+ // Ф-F: the always-on infra entrypoint (telegram/notifier), held by launchd.
520
+ // The infra plist runs `iapeer run-infra <personality> <runtime>` (installed
521
+ // binary) instead of `bun launchdRun.ts`. cwd = the launchd WorkingDirectory.
522
+ if (!positionals[0] || !positionals[1]) return usage(errOut)
523
+ return await runAlwaysOn(positionals[0], positionals[1], process.cwd())
524
+ }
525
+ case 'interrupt':
526
+ case 'compact': {
527
+ // In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
528
+ // turn (Escape) / compact context. UNCONDITIONAL — acts on the live session.
529
+ if (!positionals[0]) return usage(errOut)
530
+ const r = routeControl(positionals[0], positionals[1], { name: verb })
531
+ if (!r.ok) {
532
+ errOut(`${verb}: ${r.error.message}\n`)
533
+ return 1
534
+ }
535
+ out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
536
+ return 0
537
+ }
538
+ case 'enable': {
539
+ // Per-peer capability install (contract Установка §3): install <plugin>@agfpd
540
+ // per-runtime (claude project-scope IN the peer cwd / codex global) + enable +
541
+ // call the plugin's `setup` ONLY if its iapeer.json declares it. Idempotent and
542
+ // fleet-safe — claude is keyed by the peer's projectPath. `enable <plugin> [peer]`.
543
+ if (!positionals[0]) return usage(errOut)
544
+ const { enableCapability } = await import('../enable/index.ts')
545
+ const r = enableCapability({
546
+ plugin: positionals[0],
547
+ peer: positionals[1],
548
+ noSetup: flags['no-setup'] === true,
549
+ env,
550
+ })
551
+ for (const rt of r.runtimes) {
552
+ out(` ${rt.runtime}: ${rt.state}${rt.detail ? ` — ${rt.detail}` : ''}\n`)
553
+ }
554
+ out(`enable ${r.plugin} @ ${r.personality}: setup ${r.setup}${r.setupDetail ? ` — ${r.setupDetail}` : ''}\n`)
555
+ return r.runtimes.some(rt => rt.state === 'failed') || r.setup === 'failed' ? 1 : 0
556
+ }
557
+ case 'attach': {
558
+ if (!positionals[0]) return usage(errOut)
559
+ const r = await attachPeer({ personality: positionals[0], runtime: positionals[1], env })
560
+ if (!r.ok) {
561
+ errOut(`attach: ${r.reason}\n`)
562
+ return 1
563
+ }
564
+ out(`${r.woke ? 'woke + ' : ''}attaching ${r.identity}…\n`)
565
+ // Drop the operator into the session. `env -u TMUX` so a nested attach from
566
+ // inside tmux does not error ("sessions should be nested with care").
567
+ const attachEnv = { ...env }
568
+ delete attachEnv.TMUX
569
+ const a = spawnSync('tmux', ['-S', r.socketPath, 'attach', '-t', r.identity], {
570
+ stdio: 'inherit',
571
+ env: attachEnv as Record<string, string>,
572
+ })
573
+ return a.status ?? 0
574
+ }
575
+ default: {
576
+ // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
577
+ if (verb && isRuntime(verb)) {
578
+ const r = await folderLaunch({ cwd: process.cwd(), runtime: verb, env })
579
+ if (r.status === 'FAILED') {
580
+ errOut(`launch: ${r.reason}\n`)
581
+ return 1
582
+ }
583
+ out(`launched ${r.process_address} (fresh)\n`)
584
+ return 0
585
+ }
586
+ return usage(errOut)
587
+ }
588
+ }
589
+ } catch (e) {
590
+ errOut(`iapeer ${verb ?? ''}: ${e instanceof Error ? e.message : String(e)}\n`)
591
+ return 1
592
+ }
593
+ }
594
+
595
+ function usage(errOut: (s: string) => void): number {
596
+ errOut(USAGE)
597
+ return 2
598
+ }
599
+
600
+ /** Default --from for `send`: the identity of the peer in the current cwd (contract:
601
+ * "по умолчанию — identity пира текущей папки"). Requires running from a peer cwd. */
602
+ function defaultFromIdentity(env: NodeJS.ProcessEnv): string {
603
+ return resolveIdentity({ env }).address
604
+ }
605
+
606
+ if (import.meta.main) {
607
+ runCli(process.argv.slice(2)).then(code => process.exit(code))
608
+ }