@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,84 @@
1
+ // install — the foundation install-phase (contract Установка §INSTALL). The
2
+ // foundation ships as ONE real binary at a STABLE host-wide path
3
+ // (~/.local/bin/iapeer), built standalone from src via `bun build --compile`, so
4
+ // PROD is DECOUPLED from the mutable src working tree: the daemon/infra launchd
5
+ // plists run the INSTALLED binary, and any edit/git-op in the tree no longer hits
6
+ // prod. Update = atomic overwrite in place (build to .tmp → rename over), with ONE
7
+ // .prev for rollback. NO versions/ catalog + resolver-symlink (that pattern is for
8
+ // multi-version toolchains; the foundation is one-latest — and a stable path keeps
9
+ // macOS TCC rights through updates, which a versioned path would re-prompt).
10
+
11
+ import { copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
12
+ import { homedir } from 'os'
13
+ import { join } from 'path'
14
+ import { spawnSync } from 'child_process'
15
+
16
+ /** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
17
+ * admin, not tied to a node/bun version), ON $PATH. The launchd plists reference
18
+ * THIS path, not process.execPath / a src file — that is the decoupling. */
19
+ export function iapeerBinPath(env: NodeJS.ProcessEnv = process.env): string {
20
+ const home = env.HOME?.trim() || homedir()
21
+ // IAPEER_BIN_DIR override is for tests/sandbox only (never write a real ~/.local/bin
22
+ // in a test). Default = ~/.local/bin.
23
+ const binDir = env.IAPEER_BIN_DIR?.trim() || join(home, '.local', 'bin')
24
+ return join(binDir, 'iapeer')
25
+ }
26
+
27
+ export interface InstallResult {
28
+ binPath: string
29
+ /** The previous binary preserved for rollback (when one existed). */
30
+ prevPath?: string
31
+ /** Bytes of the installed binary. */
32
+ size?: number
33
+ }
34
+
35
+ /**
36
+ * Build the standalone `iapeer` binary from the CLI entrypoint and place it at the
37
+ * stable path ATOMICALLY: `bun build --compile <entry> → <bin>.tmp`, then rename over
38
+ * <bin> (atomic on one fs; a running daemon keeps its old inode, new launches take the
39
+ * new one). An existing binary is preserved as <bin>.prev first. Throws on build
40
+ * failure (never leaves a half-written bin). The build runs from the SRC TREE (the
41
+ * dev/npx bootstrap); the resulting binary is self-contained (no tree dependency).
42
+ */
43
+ /** Fail-closed sandbox guard (audit #25, symmetric to the registry's): under
44
+ * IAPEER_TEST_SANDBOX=1 refuse to build over the REAL ~/.local/bin/iapeer (the live
45
+ * prod binary). A test/sandbox MUST set IAPEER_BIN_DIR to an isolated path. */
46
+ function assertInstallSandboxIsolated(binPath: string, env: NodeJS.ProcessEnv): void {
47
+ if (env.IAPEER_TEST_SANDBOX !== '1') return
48
+ const realBin = join(env.HOME?.trim() || homedir(), '.local', 'bin', 'iapeer')
49
+ if (binPath === realBin) {
50
+ throw new Error(
51
+ `refusing to overwrite the REAL prod binary (${realBin}) under IAPEER_TEST_SANDBOX=1 — ` +
52
+ 'set IAPEER_BIN_DIR to an isolated path',
53
+ )
54
+ }
55
+ }
56
+
57
+ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = process.env): InstallResult {
58
+ const binPath = iapeerBinPath(env)
59
+ assertInstallSandboxIsolated(binPath, env)
60
+ mkdirSync(join(binPath, '..'), { recursive: true })
61
+ const tmp = `${binPath}.tmp`
62
+ const build = spawnSync('bun', ['build', '--compile', cliEntrypoint, '--outfile', tmp], {
63
+ encoding: 'utf8',
64
+ })
65
+ if (build.status !== 0 || !existsSync(tmp)) {
66
+ throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
67
+ }
68
+ let prevPath: string | undefined
69
+ if (existsSync(binPath)) {
70
+ prevPath = `${binPath}.prev`
71
+ // COPY (not move) the current binary to .prev so binPath is NEVER absent (audit
72
+ // #7): a move-then-move leaves no binary in the window between the two renames —
73
+ // if the second throws, the prod daemon + infra fleet crash-loop with no bin.
74
+ copyFileSync(binPath, prevPath)
75
+ }
76
+ renameSync(tmp, binPath) // atomic replace in place (POSIX rename over an existing file)
77
+ let size: number | undefined
78
+ try {
79
+ size = statSync(binPath).size
80
+ } catch {
81
+ /* best-effort */
82
+ }
83
+ return { binPath, prevPath, size }
84
+ }
@@ -0,0 +1,31 @@
1
+ // install — the stable binary path + (lightly) the build. iapeerBinPath is the
2
+ // decoupling anchor: the launchd plists reference it, not process.execPath / a src
3
+ // file. The full `bun build --compile` is exercised LIVE (it writes a ~60M binary —
4
+ // too heavy for a unit test); here we pin the path resolution.
5
+
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { join } from 'path'
8
+ import { iapeerBinPath, installIapeer } from './index.ts'
9
+
10
+ describe('iapeerBinPath', () => {
11
+ test('default = <home>/.local/bin/iapeer (stable host-wide path, on $PATH)', () => {
12
+ expect(iapeerBinPath({ HOME: '/Users/x' } as NodeJS.ProcessEnv)).toBe('/Users/x/.local/bin/iapeer')
13
+ })
14
+ test('IAPEER_BIN_DIR override (tests/sandbox — never a real ~/.local/bin)', () => {
15
+ expect(iapeerBinPath({ HOME: '/Users/x', IAPEER_BIN_DIR: '/tmp/sbx/bin' } as NodeJS.ProcessEnv)).toBe(
16
+ join('/tmp/sbx/bin', 'iapeer'),
17
+ )
18
+ })
19
+ })
20
+
21
+ // Audit #25 — fail-closed sandbox guard (symmetric to the registry's). installIapeer
22
+ // overwrites the live prod binary ~/.local/bin/iapeer; a sandbox/test that forgets
23
+ // IAPEER_BIN_DIR must be REFUSED, never clobber prod. The guard fires BEFORE the build,
24
+ // so no `bun build --compile` runs here.
25
+ describe('installIapeer fail-closed sandbox guard', () => {
26
+ test('THROWS under IAPEER_TEST_SANDBOX=1 when binPath falls through to the REAL ~/.local/bin/iapeer', () => {
27
+ const env = { IAPEER_TEST_SANDBOX: '1', HOME: '/Users/fake-home' } as NodeJS.ProcessEnv
28
+ expect(iapeerBinPath(env)).toBe('/Users/fake-home/.local/bin/iapeer')
29
+ expect(() => installIapeer('/x/entry.ts', env)).toThrow(/refusing to overwrite the REAL prod binary/)
30
+ })
31
+ })
@@ -0,0 +1,250 @@
1
+ // claude RuntimeAdapter — the "HOW to launch / observe ONE claude session" half
2
+ // of the launch contract (src/launch/types.ts). Runtime-agnostic launch.launch
3
+ // drives it: argv → boot dialogs → ready marker → activity-proxy ready-gate →
4
+ // permission autopilot → resume preflight. Consolidated from three frozen
5
+ // sources, with the exact slug fix that already lives in lifecycle:
6
+ //
7
+ // - Persistent-Peer/bin/claude-start.sh — argv (292-318: --dangerously-skip-
8
+ // permissions, --disallowedTools AskUserQuestion, --system-prompt-file), the
9
+ // ready-marker (`❯` + dev-channels-gone, 357-371) and the boot dialog answers
10
+ // (the dev-channels "I am using this for local development" Enter, 335-345).
11
+ // - Spawned-Peer/src/spawner.ts — buildClaudeArgv (759-787: same flags +
12
+ // optional --resume <uuid>) and findLatestTranscript (522-538: resume uuid).
13
+ // - IAPeer/src/lifecycle/index.ts — claudeInputReady / claudeBootDialog /
14
+ // newestClaudeTranscriptMtime / findLatestClaudeTranscript. This is the
15
+ // canonical port: the slug is realpath(cwd).replace(/[^a-zA-Z0-9]/g,'-') —
16
+ // claude encodes EVERY non-alphanumeric char (not just '/'); the old
17
+ // Spawned-Peer replace(/\//g,'-') silently broke on a cwd carrying '_' or '.'
18
+ // (a mkdtemp temp dir). REUSE this exact logic — do not reintroduce the bug.
19
+ //
20
+ // NO currency on this path: no marketplace check, no plugin install/update — that
21
+ // is install-time (blueprint §0.6 fast-wake), not session bring-up.
22
+
23
+ import { homedir } from 'os'
24
+ import { join } from 'path'
25
+ import { readdirSync, realpathSync, statSync } from 'fs'
26
+ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Boot dialog + ready markers (lifecycle claudeBootDialog / claudeInputReady)
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * The known claude startup dialogs whose default-highlighted option is the
34
+ * proceed path, so a single Enter clears each:
35
+ * - 'trust this folder' — first-run folder-trust modal.
36
+ * - 'Allow external CLAUDE.md file imports?' — external-import consent.
37
+ * - 'I am using this for local development' — dev-channels accept
38
+ * (claude-start.sh:337), shown when PEER_START_ARGS carries
39
+ * --dangerously-load-development-channels.
40
+ * - 'Resume from summary' / 'Resuming the full session' — the --resume picker.
41
+ * Same set, same order as lifecycle.claudeBootDialog (index.ts:281-289).
42
+ */
43
+ const CLAUDE_BOOT_DIALOG_MARKERS = [
44
+ 'trust this folder',
45
+ 'Allow external CLAUDE.md file imports?',
46
+ 'I am using this for local development',
47
+ 'Resume from summary',
48
+ 'Resuming the full session',
49
+ ] as const
50
+
51
+ function anyBootDialog(pane: string): boolean {
52
+ return CLAUDE_BOOT_DIALOG_MARKERS.some(m => pane.includes(m))
53
+ }
54
+
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+ // Transcript activity proxy + resume uuid (lifecycle port, claude slug fix)
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Claude's project-dir slug: realpath(cwd) with EVERY non-alphanumeric char
61
+ * replaced by '-' (index.ts:232-245). NOT just '/' — a segment with '_' or '.'
62
+ * is slugged too; the Spawned-Peer canon's replace(/\//g,'-') silently worked
63
+ * only because the live fleet's ~/Peers/<name> paths have none, then broke on a
64
+ * mkdtemp temp cwd. realpath first so a symlinked cwd maps to the dir claude
65
+ * actually wrote (a stale path falls through to the original string).
66
+ */
67
+ function transcriptSlug(workDir: string): string {
68
+ let phys = workDir
69
+ try {
70
+ phys = realpathSync(workDir)
71
+ } catch {
72
+ /* stale path — slug the original */
73
+ }
74
+ return phys.replace(/[^a-zA-Z0-9]/g, '-')
75
+ }
76
+
77
+ function transcriptDir(workDir: string): string {
78
+ return join(homedir(), '.claude', 'projects', transcriptSlug(workDir))
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // claudeAdapter
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ export const claudeAdapter: RuntimeAdapter = {
86
+ runtime: 'claude',
87
+ kind: 'tui',
88
+ usesDoctrine: true,
89
+
90
+ /**
91
+ * Delivery markers for submitIntoTui (07.06 refactor — moved out of transport's
92
+ * PROMPT_GLYPHS union into the adapter; docs/Рантайм claude "Доставка"):
93
+ * - promptGlyphs ['❯'] — the same U+276F input-prompt glyph as isInputReady;
94
+ * submitIntoTui finds the prompt row by it. claude-only (codex uses '›'), so
95
+ * a stray codex glyph in a claude pane no longer false-matches.
96
+ * - pastePatterns '[Pasted text' / '[Image #' — claude's bracketed-paste land
97
+ * confirmations (claude-start.sh / transport.ts:191), checked alongside the
98
+ * envelope tail-marker.
99
+ */
100
+ deliveryMarkers: {
101
+ promptGlyphs: ['❯'],
102
+ pastePatterns: [/\[Pasted text/, /\[Image #/],
103
+ },
104
+
105
+ /**
106
+ * argv = claudeBin + headless flags + (system-prompt-file when set) +
107
+ * (--resume <ref> when resumeRef set) + extraArgs.
108
+ *
109
+ * - '--dangerously-skip-permissions' claude-start.sh:318, spawner.ts:776 —
110
+ * headless peer has no interactive owner to grant per-tool permission.
111
+ * - '--disallowedTools','AskUserQuestion' claude-start.sh:313/316,
112
+ * spawner.ts:777 — AskUserQuestion would render in a TUI no headless peer
113
+ * owner watches; the question goes "into the void". Default is the literal
114
+ * 'AskUserQuestion'; the per-peer override (PEER_DISALLOWED_TOOLS empty =
115
+ * allow all) is install-time launch.env, not this path.
116
+ * - '--system-prompt-file', spec.systemPromptFile claude-start.sh:318 —
117
+ * ONLY when set (a tui runtime that usesDoctrine composes one). Swaps the
118
+ * CC coding baseline for the merged peer doctrine; plugin/MCP/CLAUDE.md
119
+ * layers stay intact (claude-start.sh:293-303).
120
+ * - '--resume', spec.resumeRef spawner.ts:779-781 — ONLY when the caller
121
+ * pre-resolved a uuid (resolveResume); never a silent fresh fallback.
122
+ * - ...extraArgs PEER_START_ARGS passthrough (LaunchSpec.extraArgs).
123
+ * NO currency — no marketplace/install/update on this path.
124
+ */
125
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
126
+ return [
127
+ cfg.claudeBin,
128
+ '--dangerously-skip-permissions',
129
+ '--disallowedTools',
130
+ 'AskUserQuestion',
131
+ ...(spec.systemPromptFile ? ['--system-prompt-file', spec.systemPromptFile] : []),
132
+ ...(spec.resumeRef ? ['--resume', spec.resumeRef] : []),
133
+ ...(spec.extraArgs ?? []),
134
+ ]
135
+ },
136
+
137
+ /**
138
+ * A visible startup dialog → ['Enter'] to clear it (its default-highlighted
139
+ * option is the proceed path), else null. Same marker set as
140
+ * lifecycle.claudeBootDialog; claude-start.sh:341 (dev-channels) and the
141
+ * folder-trust/import/resume modals all accept a bare Enter.
142
+ */
143
+ bootDialogKeys(pane: string): string[] | null {
144
+ return anyBootDialog(pane) ? ['Enter'] : null
145
+ },
146
+
147
+ /**
148
+ * Ready for the first message iff the input surface is rendered AND no startup
149
+ * dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
150
+ * claude-start.sh:365-366):
151
+ * - '❯' (U+276F) — the TUI input-prompt glyph, present only at the ready
152
+ * input row, never in the splash art (claude-start.sh:357-360).
153
+ * - 'bypass permissions on' — the banner --dangerously-skip-permissions
154
+ * emits once booted past the splash.
155
+ * - none of the boot dialogs present (a dialog row can also carry '❯').
156
+ */
157
+ isInputReady(pane: string): boolean {
158
+ if (anyBootDialog(pane)) return false
159
+ return pane.includes('❯') && pane.includes('bypass permissions on')
160
+ },
161
+
162
+ /**
163
+ * Newest ~/.claude/projects/<slug>/*.jsonl mtimeMs, or null when the dir is
164
+ * absent / empty (index.ts:247-266). The ready-gate waits for this to strictly
165
+ * advance past baseline (model produced its first turn); idle accounting reads
166
+ * the same proxy. slug = transcriptSlug(cwd) (the claude non-alnum encoding).
167
+ */
168
+ newestActivityMtime(cwd: string): number | null {
169
+ let entries: string[]
170
+ try {
171
+ entries = readdirSync(transcriptDir(cwd))
172
+ } catch {
173
+ return null
174
+ }
175
+ let newest = 0
176
+ for (const name of entries) {
177
+ if (!name.endsWith('.jsonl')) continue
178
+ try {
179
+ const mt = statSync(join(transcriptDir(cwd), name)).mtimeMs
180
+ if (mt > newest) newest = mt
181
+ } catch {
182
+ /* race — entry vanished between readdir and stat */
183
+ }
184
+ }
185
+ return newest > 0 ? newest : null
186
+ },
187
+
188
+ /**
189
+ * A claude tool-permission/approval modal is open iff the pane shows
190
+ * 'Do you want to proceed?' (Spawned-Peer dialogs.ts:60-61,
191
+ * approve-watch.sh:78). Defensive only: a --dangerously-skip-permissions peer
192
+ * does not normally reach it, but a stray approval (e.g. a connector) must not
193
+ * stall a headless session.
194
+ */
195
+ permissionDialogActive(pane: string): boolean {
196
+ return pane.includes('Do you want to proceed?')
197
+ },
198
+
199
+ /**
200
+ * Affirm a claude approval with ['Enter']: option 1 ('Yes') is the
201
+ * default-highlighted choice, so a bare Enter accepts (approve-watch.sh:22-25,
202
+ * Spawned-Peer watcher.ts:298).
203
+ */
204
+ permissionDialogKeys(): string[] {
205
+ return ['Enter']
206
+ },
207
+
208
+ /**
209
+ * Resume preflight (fail-loud — never a silent fresh fallback): resolve the
210
+ * newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
211
+ * findLatestClaudeTranscript / spawner.findLatestTranscript). {ok:true, ref}
212
+ * when one exists, else {ok:false, reason:'no transcript to resume'} so the
213
+ * caller surfaces a real failure instead of starting a context-less session.
214
+ */
215
+ resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string } {
216
+ let entries: string[]
217
+ try {
218
+ entries = readdirSync(transcriptDir(cwd))
219
+ } catch {
220
+ return { ok: false, reason: 'no transcript to resume' }
221
+ }
222
+ let best: { name: string; mt: number } | null = null
223
+ for (const name of entries) {
224
+ if (!name.endsWith('.jsonl')) continue
225
+ try {
226
+ const mt = statSync(join(transcriptDir(cwd), name)).mtimeMs
227
+ if (!best || mt > best.mt) best = { name, mt }
228
+ } catch {
229
+ /* race */
230
+ }
231
+ }
232
+ if (!best) return { ok: false, reason: 'no transcript to resume' }
233
+ return { ok: true, ref: best.name.replace(/\.jsonl$/, '') }
234
+ },
235
+
236
+ /**
237
+ * Map a control command to claude's in-session mechanism (Ф-E, docs/Control-команды
238
+ * + docs/TUI-взаимодействие):
239
+ * - interrupt → ['Escape'] (claude interrupts the current turn with ONE Escape;
240
+ * the session + context stay intact — distinct from the `stop` verb which halts
241
+ * the session). The "кнопка заткнуть бредящего" without losing context.
242
+ * - compact → type '/compact' then Enter (claude's context-compaction slash).
243
+ * - anything else → null (unsupported → explicit refusal upstream).
244
+ */
245
+ executeControl(command: ControlCommand): ControlPlan | null {
246
+ if (command.name === 'interrupt') return { sequence: [['Escape']] }
247
+ if (command.name === 'compact') return { sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 }
248
+ return null
249
+ },
250
+ }