@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,230 @@
1
+ // Production daemon main — THE composition point. The library `startDaemon`
2
+ // (daemon/index.ts) is transport-only (route / deliver / liveness) and takes the
3
+ // lifecycle primitives as INJECTED hooks; this module wires them:
4
+ // • wake-on-miss → lifecycle.wakeOrSpawn (a send to a dead, non-launchd peer
5
+ // spawns it and delivers the envelope as its boot first-message). H4 (never
6
+ // wake a launchd-managed peer) lives INSIDE wakeOrSpawn.
7
+ // • supervise → a timer running lifecycle.superviseTick (idle-reap /
8
+ // zombie-sweep), H4-guarded per session inside.
9
+ // This is where Ф1 (transport) meets Ф2 (lifecycle). It also installs the daemon's
10
+ // OWN always-on launchd plist (label com.agfpd.iapeer — a SEPARATE namespace from
11
+ // the com.iapeer.* peer fleet, so it never collides with persistent-peer plists).
12
+ //
13
+ // The CLI at the bottom is the launchd-held process (`bun main.ts`) and the
14
+ // foreground entrypoint for acceptance. Installing the plist is a SEPARATE explicit
15
+ // action (`--install-plist`); it writes the file but does NOT load it — a live
16
+ // `launchctl bootstrap` stays a deliberate operator step.
17
+
18
+ import { existsSync, mkdirSync, writeFileSync } from 'fs'
19
+ import { homedir } from 'os'
20
+ import { join } from 'path'
21
+ import { fileURLToPath } from 'url'
22
+ import { DAEMON_PLIST_LABEL } from '../core/constants.ts'
23
+ import { IapError } from '../core/errors.ts'
24
+ import { pluginLogsDir } from '../storage/index.ts'
25
+ import {
26
+ isFoundationOwnedPlist,
27
+ launchAgentsDir,
28
+ renderLaunchdPlist,
29
+ } from '../launch/launchd.ts'
30
+ import type { LaunchdPlistSpec } from '../launch/launchd.ts'
31
+ import {
32
+ loadLifecycleConfig,
33
+ processEagerRelaunches,
34
+ superviseTick,
35
+ wakeOrSpawn,
36
+ type LifecycleConfig,
37
+ } from '../lifecycle/index.ts'
38
+ import type { WakeFn, WakeOutcome, WakeRequest } from '../transport/index.ts'
39
+ import { iapeerBinPath } from '../install/index.ts'
40
+ import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
41
+
42
+ /** Default TCP loopback port for the always-on router. Real http MCP clients
43
+ * (claude/codex `--transport http <url>`) bind to a fixed URL, so production needs
44
+ * a STABLE port, not an ephemeral one. Override with IAPEER_PORT. */
45
+ export const DEFAULT_DAEMON_PORT = 8765
46
+
47
+ /** Default supervise-tick cadence (idle-reap / zombie-sweep). idleSecs (1h default)
48
+ * is the reap threshold; this is just how often the timer checks. */
49
+ export const DEFAULT_SUPERVISE_INTERVAL_MS = 60_000
50
+
51
+ // This module's own path — the launchd plist runs `bun <this>` as the daemon.
52
+ const DAEMON_MAIN_PATH = fileURLToPath(import.meta.url)
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Composition: wire startDaemon ⇆ lifecycle (wake + supervise)
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Adapt the transport WakeFn contract to lifecycle.wakeOrSpawn. The two shapes are
60
+ * structurally identical (WakeRequest ⊆ WakeArgs, WakeResult ⊆ WakeOutcome); this
61
+ * is the one place transport's injected-wake meets the lifecycle implementation.
62
+ */
63
+ export function makeWakeFn(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): WakeFn {
64
+ return (req: WakeRequest): Promise<WakeOutcome> =>
65
+ wakeOrSpawn(
66
+ { personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task },
67
+ { cfg, env },
68
+ )
69
+ }
70
+
71
+ export interface ConfiguredDaemonOptions {
72
+ /** TCP loopback port (default DEFAULT_DAEMON_PORT). */
73
+ port?: number
74
+ host?: string
75
+ /** Unix-socket path (H8 same-uid base; default: defaultDaemonSocketPath). */
76
+ socketPath?: string
77
+ /** H8 bearer token; falls back to env.IAPEER_BEARER_TOKEN. Off when neither set. */
78
+ bearerToken?: string
79
+ superviseIntervalMs?: number
80
+ /** Write the router.json discovery file (default true for production). */
81
+ discovery?: boolean
82
+ rootDir?: string
83
+ env?: NodeJS.ProcessEnv
84
+ }
85
+
86
+ /**
87
+ * Start the fully-composed production daemon: the router (startDaemon) wired to
88
+ * wake-on-miss (wakeOrSpawn) and the supervise timer (superviseTick). DUAL-LISTEN
89
+ * by default — a 0600 unix socket (local same-uid callers: notifier/telegram/CLI)
90
+ * AND TCP loopback (real http MCP agent clients), both over one MCP handler — and
91
+ * the router.json discovery file written for daemon-aware `iap send`. The bearer
92
+ * layer (H8) engages only when a token is configured.
93
+ */
94
+ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}): Promise<DaemonHandle> {
95
+ const env = opts.env ?? process.env
96
+ const cfg = loadLifecycleConfig(env)
97
+ const bearerToken = opts.bearerToken ?? (env.IAPEER_BEARER_TOKEN?.trim() || undefined)
98
+ return startDaemon({
99
+ wake: makeWakeFn(cfg, env),
100
+ supervise: {
101
+ intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
102
+ // idle-reap / zombie-sweep, THEN C4b eager fresh re-launch for any peer whose
103
+ // session died carrying a /new graceful mark (async, best-effort).
104
+ tick: async () => {
105
+ const outcomes = superviseTick(cfg, { env })
106
+ await processEagerRelaunches(cfg, outcomes, { env })
107
+ },
108
+ },
109
+ bearerToken,
110
+ port: opts.port ?? DEFAULT_DAEMON_PORT,
111
+ host: opts.host ?? '127.0.0.1',
112
+ socketPath: opts.socketPath ?? defaultDaemonSocketPath({ env, rootDir: opts.rootDir }),
113
+ discovery: opts.discovery ?? true,
114
+ env,
115
+ rootDir: opts.rootDir,
116
+ })
117
+ }
118
+
119
+ // ─────────────────────────────────────────────────────────────────────────────
120
+ // The daemon's OWN launchd plist (com.agfpd.iapeer) — distinct from peer plists
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+
123
+ export function daemonPlistPath(env: NodeJS.ProcessEnv = process.env): string {
124
+ return join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`)
125
+ }
126
+
127
+ export interface InstallDaemonPlistOptions {
128
+ /** How to launch the daemon (default [bun, main.ts]). */
129
+ programArgv?: string[]
130
+ /** TCP port baked into the plist env (default DEFAULT_DAEMON_PORT). */
131
+ port?: number
132
+ /** H8 bearer token baked into the plist env (only when provided). */
133
+ bearerToken?: string
134
+ /** PATH for the launchd minimal env. */
135
+ path?: string
136
+ workingDirectory?: string
137
+ env?: NodeJS.ProcessEnv
138
+ throttleIntervalSecs?: number
139
+ }
140
+
141
+ /** Build the daemon's launchd plist spec (PURE — render/lint-testable). */
142
+ export function buildDaemonPlistSpec(opts: InstallDaemonPlistOptions = {}): LaunchdPlistSpec {
143
+ const env = opts.env ?? process.env
144
+ const home = env.HOME?.trim() || homedir()
145
+ const defaultPath = `${home}/.bun/bin:${home}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`
146
+ const logDir = pluginLogsDir('iapeer', { env })
147
+ const environment: Record<string, string> = {
148
+ PATH: opts.path ?? env.PATH ?? defaultPath,
149
+ IAPEER_PORT: String(opts.port ?? DEFAULT_DAEMON_PORT),
150
+ }
151
+ if (opts.bearerToken) environment.IAPEER_BEARER_TOKEN = opts.bearerToken
152
+ // Propagate the FULL set of non-default path overrides so the launchd-held daemon
153
+ // reads the SAME tree the rest of the fleet uses (audit #26: a sandbox daemon that
154
+ // carried only IAPEER_ROOT would scan the wrong tmux socket dir and key its H4
155
+ // launchd-guard on the wrong LaunchAgents dir — the routing + fleet-guard surfaces).
156
+ for (const key of ['IAPEER_ROOT', 'IAPEER_SOCK_DIR', 'IAPEER_LAUNCHAGENTS_DIR'] as const) {
157
+ if (env[key]?.trim()) environment[key] = env[key]!.trim()
158
+ }
159
+ return {
160
+ label: DAEMON_PLIST_LABEL,
161
+ // Ф-F: run the INSTALLED binary (`iapeer daemon`), NOT `bun <src>/daemon/main.ts`
162
+ // — this is the decoupling of prod from the mutable src tree. opts.programArgv
163
+ // overrides for tests / a non-default layout.
164
+ programArguments: opts.programArgv ?? [iapeerBinPath(env), 'daemon'],
165
+ workingDirectory: opts.workingDirectory ?? home, // daemon is cwd-agnostic (per-request identity)
166
+ environment,
167
+ stdoutPath: join(logDir, 'daemon-stdout.log'),
168
+ stderrPath: join(logDir, 'daemon-stderr.log'),
169
+ throttleIntervalSecs: opts.throttleIntervalSecs,
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Generate + install the daemon's always-on launchd plist at
175
+ * ~/Library/LaunchAgents/com.agfpd.iapeer.plist (or IAPEER_LAUNCHAGENTS_DIR),
176
+ * returning the path. Writes the FILE only — does NOT bootstrap it (load is a
177
+ * deliberate operator step). Collision guard: refuses to overwrite an existing
178
+ * com.agfpd.iapeer.plist that is not foundation-owned (lacks the sentinel) — the
179
+ * same ownership proof the peer-plist installer uses. com.agfpd.* is the
180
+ * foundation-exclusive daemon namespace, so this never touches the com.iapeer.*
181
+ * persistent-peer fleet.
182
+ */
183
+ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): string {
184
+ const env = opts.env ?? process.env
185
+ const path = daemonPlistPath(env)
186
+ if (existsSync(path) && !isFoundationOwnedPlist(path)) {
187
+ throw new IapError(
188
+ `refusing to overwrite ${path}: ${DAEMON_PLIST_LABEL} exists but is not foundation-managed ` +
189
+ `(no ownership sentinel) — another manager owns it`,
190
+ )
191
+ }
192
+ const spec = buildDaemonPlistSpec(opts)
193
+ mkdirSync(launchAgentsDir(env), { recursive: true })
194
+ mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
195
+ writeFileSync(path, renderLaunchdPlist(spec), { mode: 0o644 })
196
+ return path
197
+ }
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ // CLI — launchd-held process / foreground acceptance entrypoint
201
+ // ─────────────────────────────────────────────────────────────────────────────
202
+
203
+ if (import.meta.main) {
204
+ const arg = process.argv[2]
205
+ if (arg === '--print-plist') {
206
+ process.stdout.write(renderLaunchdPlist(buildDaemonPlistSpec()))
207
+ process.exit(0)
208
+ } else if (arg === '--install-plist') {
209
+ const p = installDaemonPlist()
210
+ process.stdout.write(
211
+ `installed daemon plist: ${p}\n` +
212
+ `NOT loaded. To start it live: launchctl bootstrap gui/$(id -u) ${p}\n`,
213
+ )
214
+ process.exit(0)
215
+ } else {
216
+ const env = process.env
217
+ const handle = await startConfiguredDaemon({
218
+ port: Number(env.IAPEER_PORT ?? DEFAULT_DAEMON_PORT),
219
+ socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
220
+ env,
221
+ })
222
+ process.stderr.write(`[iapeer-daemon] READY tcp=${handle.url} sock=${handle.socketPath}\n`)
223
+ const shutdown = () => {
224
+ void handle.close().then(() => process.exit(0))
225
+ }
226
+ process.on('SIGTERM', shutdown)
227
+ process.on('SIGINT', shutdown)
228
+ await new Promise(() => {}) // launchd KeepAlive holds this process; block forever
229
+ }
230
+ }
@@ -0,0 +1,92 @@
1
+ // enable — the PURE fleet-safety + idempotency logic: parse `claude plugin list
2
+ // --json`, match the entry for THIS peer's projectPath (realpath), and detect a
3
+ // plugin's `setup` from iapeer.json (SIMPLE vs СЛОЖНЫЙ). The spawn/install paths are
4
+ // live-verified on a throwaway test-peer (project-scope is keyed by projectPath, so a
5
+ // test install never touches a live peer's entry).
6
+
7
+ import { 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 { findPeerScopedEntry, parseCodexPluginStatus, parseInstalledPlugins, readSetupDescriptor } from './index.ts'
12
+
13
+ // a realistic `claude plugin list --json` array (the live shape: id/scope/enabled/
14
+ // projectPath/installPath), two live peers + the same plugin user-scope.
15
+ const LIST = JSON.stringify([
16
+ { id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus', installPath: '/c/iap/0.7.11' },
17
+ { id: 'peer-voice@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/boris', installPath: '/c/pv/0.1.8' },
18
+ { id: 'peer-voice@agfpd', scope: 'user', enabled: false, installPath: '/c/pv/0.1.8' },
19
+ ])
20
+
21
+ describe('parseInstalledPlugins', () => {
22
+ test('parses the flat array into typed entries; tolerates junk', () => {
23
+ const e = parseInstalledPlugins(LIST)
24
+ expect(e.length).toBe(3)
25
+ expect(e[0]).toMatchObject({ id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus' })
26
+ expect(parseInstalledPlugins('not json')).toEqual([])
27
+ expect(parseInstalledPlugins('{"not":"array"}')).toEqual([])
28
+ expect(parseInstalledPlugins('[1, null, {"no":"id"}]')).toEqual([]) // entries without a string id are dropped
29
+ })
30
+ })
31
+
32
+ describe('findPeerScopedEntry (per-peer, fleet-safe)', () => {
33
+ test('matches ONLY the project-scope entry for this peer cwd', () => {
34
+ const e = parseInstalledPlugins(LIST)
35
+ expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.enabled).toBe(true)
36
+ // a DIFFERENT peer cwd → no match (never reports another peer's install as ours)
37
+ expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/darwin')).toBeNull()
38
+ // user-scope is not a per-peer match
39
+ expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.scope).toBe('project')
40
+ })
41
+ test('absent plugin for this peer → null (drives install, not false-skip)', () => {
42
+ const e = parseInstalledPlugins(LIST)
43
+ expect(findPeerScopedEntry(e, 'MergeMind', 'agfpd', '/Users/x/agents/linus')).toBeNull()
44
+ })
45
+ })
46
+
47
+ // the real `codex plugin list` table shape (whitespace-aligned columns)
48
+ const CODEX_LIST = `Marketplace \`agfpd\`
49
+
50
+ PLUGIN STATUS VERSION PATH
51
+ spawned-peer@agfpd not installed https://github.com/agfpd/Spawned-Peer.git
52
+ peer-voice@agfpd installed, enabled 0.1.8 https://github.com/agfpd/Peer-Voice.git
53
+ MergeMind@agfpd installed, disabled 0.14.70 https://github.com/agfpd/MergeMind.git`
54
+
55
+ describe('parseCodexPluginStatus', () => {
56
+ test('reads the STATUS column per plugin id', () => {
57
+ expect(parseCodexPluginStatus(CODEX_LIST, 'peer-voice@agfpd')).toBe('enabled')
58
+ expect(parseCodexPluginStatus(CODEX_LIST, 'spawned-peer@agfpd')).toBe('absent') // "not installed"
59
+ expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled')
60
+ expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in list
61
+ expect(parseCodexPluginStatus('', 'x@agfpd')).toBe('absent')
62
+ })
63
+ })
64
+
65
+ describe('readSetupDescriptor (SIMPLE vs СЛОЖНЫЙ)', () => {
66
+ test('no installPath / no iapeer.json → null (SIMPLE: install+enable only)', () => {
67
+ expect(readSetupDescriptor(undefined)).toBeNull()
68
+ const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-simple-'))
69
+ try {
70
+ expect(readSetupDescriptor(dir)).toBeNull() // peer-voice-like: no manifest
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true })
73
+ }
74
+ })
75
+ test('iapeer.json with setup string → returns it; with {command,args} → object', () => {
76
+ const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-complex-'))
77
+ try {
78
+ writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: 'bin/setup' }))
79
+ expect(readSetupDescriptor(dir)).toBe('bin/setup')
80
+ writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: { command: 'node', args: ['setup.js'] } }))
81
+ expect(readSetupDescriptor(dir)).toEqual({ command: 'node', args: ['setup.js'] })
82
+ // requires present but NO setup → still SIMPLE (iapeer does not resolve requires)
83
+ writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ requires: ['telegram'] }))
84
+ expect(readSetupDescriptor(dir)).toBeNull()
85
+ // malformed manifest → treated as SIMPLE, never throws
86
+ writeFileSync(join(dir, 'iapeer.json'), '{ broken')
87
+ expect(readSetupDescriptor(dir)).toBeNull()
88
+ } finally {
89
+ rmSync(dir, { recursive: true, force: true })
90
+ }
91
+ })
92
+ })