@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,331 @@
1
+ // Storage — the single authority over the ~/.iapeer/ (global) and
2
+ // <cwd>/.iapeer/ (per-peer) path trees: resolution, idempotent scaffold
3
+ // (mode 0o700), and atomic file write. Consolidated from inter-agent-protocol
4
+ // peers.ts (resolveGlobalRoot/resolvePeersPaths/ensureGlobalIapScaffold) +
5
+ // identity.ts (ensureLocalIapScaffold/ensureLocalRuntimeScopes/peerProfilePath).
6
+ //
7
+ // Structural invariant (#3, boris adversarial-review): peers-profiles.json is
8
+ // the registry's exclusive, locked write target. `writeFileAtomic` REFUSES that
9
+ // basename so no module can write the registry file out from under the lock by
10
+ // reaching for the generic atomic-write primitive. The registry keeps its own
11
+ // private writer (see registry/index.ts) used only inside withPeersLock.
12
+
13
+ import {
14
+ closeSync,
15
+ existsSync,
16
+ fsyncSync,
17
+ mkdirSync,
18
+ openSync,
19
+ readFileSync,
20
+ readdirSync,
21
+ renameSync,
22
+ unlinkSync,
23
+ writeSync,
24
+ } from 'fs'
25
+ import { homedir } from 'os'
26
+ import { basename, dirname, join } from 'path'
27
+ import { randomUUID } from 'crypto'
28
+ import {
29
+ CACHE_DIR,
30
+ IAPEER_DIR,
31
+ IAPEER_ROOT_ENV,
32
+ IAP_PLUGIN_DIR,
33
+ LOGS_DIR,
34
+ PEERS_HOME_DIR,
35
+ PEERS_PROFILES_FILE,
36
+ PEERS_PROFILES_LOCK_FILE,
37
+ PEER_PROFILE_FILE,
38
+ PLUGINS_DIR,
39
+ RUNTIMES_DIR,
40
+ STATE_DIR,
41
+ SUPPORTED_LOCAL_RUNTIMES,
42
+ isRuntime,
43
+ type Runtime,
44
+ type SupportedLocalRuntime,
45
+ } from '../core/constants.ts'
46
+ import { IapError } from '../core/errors.ts'
47
+
48
+ const DIR_MODE = 0o700
49
+ const FILE_MODE = 0o600
50
+
51
+ export interface StorageOptions {
52
+ rootDir?: string
53
+ env?: NodeJS.ProcessEnv
54
+ }
55
+
56
+ export interface PeersPaths {
57
+ rootDir: string
58
+ pluginDir: string
59
+ peersFile: string
60
+ lockTarget: string
61
+ tmpDir: string
62
+ }
63
+
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+ // Root + path resolution
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+
68
+ export function resolveGlobalRoot(env: NodeJS.ProcessEnv = process.env): string {
69
+ // One env override for the whole tree (blueprint §1 storage): IAPEER_ROOT.
70
+ const override = env[IAPEER_ROOT_ENV]?.trim()
71
+ if (override) return override
72
+ const home = env.HOME?.trim() || homedir()
73
+ if (!home) throw new IapError('cannot resolve home directory for ~/.iapeer')
74
+ return join(home, IAPEER_DIR)
75
+ }
76
+
77
+ export function resolvePeersPaths(options: StorageOptions = {}): PeersPaths {
78
+ const rootDir = options.rootDir ?? resolveGlobalRoot(options.env)
79
+ return {
80
+ rootDir,
81
+ pluginDir: join(rootDir, PLUGINS_DIR, IAP_PLUGIN_DIR),
82
+ peersFile: join(rootDir, PEERS_PROFILES_FILE),
83
+ lockTarget: join(rootDir, PEERS_PROFILES_LOCK_FILE),
84
+ tmpDir: rootDir,
85
+ }
86
+ }
87
+
88
+ export function peerProfilePath(cwd: string): string {
89
+ return join(cwd, IAPEER_DIR, PEER_PROFILE_FILE)
90
+ }
91
+
92
+ export function localIapPluginDir(cwd: string): string {
93
+ return join(cwd, IAPEER_DIR, PLUGINS_DIR, IAP_PLUGIN_DIR)
94
+ }
95
+
96
+ // Per-plugin namespaced category dirs (global scope). New API per blueprint §1.
97
+ export function pluginStateDir(plugin: string, options: StorageOptions = {}): string {
98
+ return join(resolveGlobalRoot(options.env), STATE_DIR, plugin)
99
+ }
100
+ export function pluginLogsDir(plugin: string, options: StorageOptions = {}): string {
101
+ return join(resolveGlobalRoot(options.env), LOGS_DIR, plugin)
102
+ }
103
+
104
+ /** GLOBAL log dir for an always-on INFRA peer — `~/.iapeer/logs/<personality>/`
105
+ * (zone Хранение / Фаза §8: infra logs are host-service logs, kept in the global
106
+ * log area, NOT buried per-peer under <cwd>/.iapeer/logs/ — doubly so now peers live
107
+ * under ~/.iapeer/peers/). Per-PERSONALITY (not per-runtime) so two peers of one
108
+ * runtime — notifier timer + watcher — never collide their stdout/stderr. */
109
+ export function peerLogsDir(personality: string, options: StorageOptions = {}): string {
110
+ return join(resolveGlobalRoot(options.env), LOGS_DIR, personality)
111
+ }
112
+ export function pluginCacheDir(plugin: string, options: StorageOptions = {}): string {
113
+ return join(resolveGlobalRoot(options.env), CACHE_DIR, plugin)
114
+ }
115
+ export function pluginInstallDir(plugin: string, options: StorageOptions = {}): string {
116
+ return join(resolveGlobalRoot(options.env), PLUGINS_DIR, plugin)
117
+ }
118
+ export function runtimeRoot(runtime: Runtime, options: StorageOptions = {}): string {
119
+ return join(resolveGlobalRoot(options.env), RUNTIMES_DIR, runtime)
120
+ }
121
+
122
+ /** The foundation-owned default home for provisioned peer cwds —
123
+ * `~/.iapeer/peers/` (IAPEER_ROOT-aware). `iapeer create <p>` lands a new peer at
124
+ * `<peersHome>/<p>` when no --path is given. */
125
+ export function peersHomeDir(options: StorageOptions = {}): string {
126
+ return join(resolveGlobalRoot(options.env), PEERS_HOME_DIR)
127
+ }
128
+
129
+ /** The default cwd for a peer created without an explicit --path:
130
+ * `~/.iapeer/peers/<personality>`. */
131
+ export function defaultPeerCwd(personality: string, options: StorageOptions = {}): string {
132
+ return join(peersHomeDir(options), personality)
133
+ }
134
+ export function runtimeScopeDir(
135
+ runtime: Runtime,
136
+ plugin: string,
137
+ options: StorageOptions = {},
138
+ ): string {
139
+ return join(runtimeRoot(runtime, options), PLUGINS_DIR, plugin)
140
+ }
141
+
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ // Scaffold (idempotent mkdir, 0o700)
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+
146
+ function resolveNativeHome(options: StorageOptions): string {
147
+ const env = options.env ?? process.env
148
+ if (env[IAPEER_ROOT_ENV]?.trim()) {
149
+ // Under an explicit root (tests/sandbox) the parent of the root stands in
150
+ // for $HOME so native-runtime detection still works deterministically.
151
+ return dirname(env[IAPEER_ROOT_ENV]!.trim())
152
+ }
153
+ if (env.HOME?.trim()) return env.HOME.trim()
154
+ if (options.rootDir && basename(options.rootDir) === IAPEER_DIR) {
155
+ return dirname(options.rootDir)
156
+ }
157
+ return homedir()
158
+ }
159
+
160
+ function availableGlobalRuntimeScopes(options: StorageOptions): SupportedLocalRuntime[] {
161
+ const home = resolveNativeHome(options)
162
+ return SUPPORTED_LOCAL_RUNTIMES.filter(runtime => existsSync(join(home, `.${runtime}`)))
163
+ }
164
+
165
+ export function ensureGlobalIapScaffold(options: StorageOptions = {}): void {
166
+ const paths = resolvePeersPaths(options)
167
+ mkdirSync(paths.rootDir, { recursive: true, mode: DIR_MODE })
168
+ mkdirSync(paths.pluginDir, { recursive: true, mode: DIR_MODE })
169
+ mkdirSync(join(paths.rootDir, STATE_DIR), { recursive: true, mode: DIR_MODE })
170
+ mkdirSync(join(paths.rootDir, LOGS_DIR), { recursive: true, mode: DIR_MODE })
171
+ mkdirSync(join(paths.rootDir, CACHE_DIR), { recursive: true, mode: DIR_MODE })
172
+ // The default home for foundation-provisioned peer cwds (`iapeer create`). Made
173
+ // here so the directory exists before the first create, install or onboard.
174
+ mkdirSync(join(paths.rootDir, PEERS_HOME_DIR), { recursive: true, mode: DIR_MODE })
175
+ const runtimesRoot = join(paths.rootDir, RUNTIMES_DIR)
176
+ mkdirSync(runtimesRoot, { recursive: true, mode: DIR_MODE })
177
+ for (const runtime of availableGlobalRuntimeScopes(options)) {
178
+ mkdirSync(join(runtimesRoot, runtime, PLUGINS_DIR, IAP_PLUGIN_DIR), {
179
+ recursive: true,
180
+ mode: DIR_MODE,
181
+ })
182
+ }
183
+ }
184
+
185
+ export function ensureLocalIapScaffold(cwd: string = process.cwd()): void {
186
+ const root = join(cwd, IAPEER_DIR)
187
+ mkdirSync(root, { recursive: true, mode: DIR_MODE })
188
+ mkdirSync(join(root, PLUGINS_DIR, IAP_PLUGIN_DIR), { recursive: true, mode: DIR_MODE })
189
+ mkdirSync(join(root, RUNTIMES_DIR), { recursive: true, mode: DIR_MODE })
190
+ mkdirSync(join(root, STATE_DIR), { recursive: true, mode: DIR_MODE })
191
+ mkdirSync(join(root, LOGS_DIR), { recursive: true, mode: DIR_MODE })
192
+ mkdirSync(join(root, CACHE_DIR), { recursive: true, mode: DIR_MODE })
193
+ }
194
+
195
+ function supportedLocalRuntimeScopes(runtimes: readonly Runtime[]): SupportedLocalRuntime[] {
196
+ const out: SupportedLocalRuntime[] = []
197
+ for (const runtime of runtimes) {
198
+ if ((SUPPORTED_LOCAL_RUNTIMES as readonly string[]).includes(runtime) &&
199
+ !out.includes(runtime as SupportedLocalRuntime)) {
200
+ out.push(runtime as SupportedLocalRuntime)
201
+ }
202
+ }
203
+ return out
204
+ }
205
+
206
+ export function ensureLocalRuntimeScopes(cwd: string, runtimes: readonly Runtime[]): void {
207
+ const root = join(cwd, IAPEER_DIR, RUNTIMES_DIR)
208
+ mkdirSync(root, { recursive: true, mode: DIR_MODE })
209
+ for (const runtime of supportedLocalRuntimeScopes(runtimes)) {
210
+ mkdirSync(join(root, runtime, PLUGINS_DIR, IAP_PLUGIN_DIR), { recursive: true, mode: DIR_MODE })
211
+ }
212
+ }
213
+
214
+ // ─────────────────────────────────────────────────────────────────────────────
215
+ // launch.env — per-peer per-runtime launch fragment
216
+ // ─────────────────────────────────────────────────────────────────────────────
217
+
218
+ export const LAUNCH_ENV_FILE = 'launch.env'
219
+
220
+ /** `<cwd>/.iapeer/runtimes/<runtime>/launch.env` — the per-peer per-runtime launch
221
+ * fragment (PEER_START_ARGS + extra peer env). Written by init, read at launch. */
222
+ export function peerLaunchEnvPath(cwd: string, runtime: Runtime): string {
223
+ return join(cwd, IAPEER_DIR, RUNTIMES_DIR, runtime, LAUNCH_ENV_FILE)
224
+ }
225
+
226
+ export interface LaunchEnv {
227
+ /** Tokens from PEER_START_ARGS, appended AFTER the adapter's base argv flags. */
228
+ startArgs: string[]
229
+ /** Other KEY=VALUE assignments — extra child-process env for the peer session. */
230
+ env: Record<string, string>
231
+ }
232
+
233
+ /**
234
+ * Read and parse `<cwd>/.iapeer/runtimes/<runtime>/launch.env` (zone Хранение /
235
+ * Рантайм-адаптеры). The file is a small bash-style fragment of `KEY=VALUE` (and
236
+ * `export KEY=VALUE`) lines; the legacy launcher sourced it and expanded
237
+ * `${PEER_START_ARGS}` UNQUOTED, so PEER_START_ARGS word-splits on whitespace —
238
+ * reproduced here with a whitespace split (faithful to that semantics; a value
239
+ * with embedded spaces was never one arg in the bash either). Surrounding single/
240
+ * double quotes around a value are stripped. Lines that are blank or start with
241
+ * `#` are ignored. A missing/unreadable file → empty (no flags, no env). This is a
242
+ * deliberately MINIMAL parser (assignments only — no command substitution, no
243
+ * conditionals); init writes the file, so the format is controlled.
244
+ */
245
+ export function readLaunchEnv(cwd: string, runtime: Runtime): LaunchEnv {
246
+ let text: string
247
+ try {
248
+ text = readFileSync(peerLaunchEnvPath(cwd, runtime), 'utf8')
249
+ } catch {
250
+ return { startArgs: [], env: {} }
251
+ }
252
+ const env: Record<string, string> = {}
253
+ for (const rawLine of text.split(/\r?\n/)) {
254
+ const line = rawLine.trim()
255
+ if (!line || line.startsWith('#')) continue
256
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)
257
+ if (!m) continue
258
+ let value = m[2].trim()
259
+ if (
260
+ value.length >= 2 &&
261
+ ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
262
+ ) {
263
+ value = value.slice(1, -1)
264
+ }
265
+ env[m[1]] = value
266
+ }
267
+ const startArgsRaw = (env.PEER_START_ARGS ?? '').trim()
268
+ delete env.PEER_START_ARGS // consumed as argv, not propagated as a child env var
269
+ return {
270
+ startArgs: startArgsRaw ? startArgsRaw.split(/\s+/) : [],
271
+ env,
272
+ }
273
+ }
274
+
275
+ export function listRuntimeScopeNames(cwd: string): Runtime[] {
276
+ const root = join(cwd, IAPEER_DIR, RUNTIMES_DIR)
277
+ let entries: string[]
278
+ try {
279
+ entries = readdirSync(root)
280
+ } catch {
281
+ return []
282
+ }
283
+ return entries.filter(isRuntime)
284
+ }
285
+
286
+ // ─────────────────────────────────────────────────────────────────────────────
287
+ // Atomic write (generic) — guards the registry file (#3)
288
+ // ─────────────────────────────────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Atomic write: tmp file alongside the target (same directory → within-fs
292
+ * rename, EXDEV-safe) then rename over the destination.
293
+ *
294
+ * STRUCTURAL GUARD (#3): refuses to write peers-profiles.json. That file is the
295
+ * registry's locked-write target; routing it through this generic primitive
296
+ * would let any module bypass withPeersLock and clobber the registry. There is
297
+ * deliberately NO unguarded variant exported from storage — the ONLY writer of
298
+ * peers-profiles.json is registry's own private function, called under
299
+ * withPeersLock. The guard is therefore structural, not a discipline.
300
+ */
301
+ export function writeFileAtomic(path: string, data: string, mode: number = FILE_MODE): void {
302
+ if (basename(path) === PEERS_PROFILES_FILE) {
303
+ throw new IapError(
304
+ `refusing to write ${PEERS_PROFILES_FILE} via storage.writeFileAtomic — it is the registry's locked write target; use registry.upsertPeer/removePeer/renamePeer`,
305
+ )
306
+ }
307
+ const dir = dirname(path)
308
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE })
309
+ const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`)
310
+ try {
311
+ // fsync the tmp file BEFORE the rename: rename is atomic on a single fs, but
312
+ // without flushing the bytes first a power-loss can publish a zero-length /
313
+ // partial shared file (codex config.toml, .mcp.json, router.json, plists).
314
+ const fd = openSync(tmp, 'w', mode)
315
+ try {
316
+ writeSync(fd, data)
317
+ fsyncSync(fd)
318
+ } finally {
319
+ closeSync(fd)
320
+ }
321
+ renameSync(tmp, path)
322
+ } catch (e) {
323
+ // Never leak the tmp file when the write/rename fails (#audit).
324
+ try {
325
+ if (existsSync(tmp)) unlinkSync(tmp)
326
+ } catch {
327
+ /* best-effort cleanup */
328
+ }
329
+ throw e
330
+ }
331
+ }
@@ -0,0 +1,34 @@
1
+ // peersHomeDir / defaultPeerCwd + ensureGlobalIapScaffold creating peers/ — the
2
+ // foundation-owned default home for `iapeer create`.
3
+
4
+ import { afterEach, describe, expect, test } from 'bun:test'
5
+ import { existsSync, mkdtempSync, rmSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import { defaultPeerCwd, ensureGlobalIapScaffold, peersHomeDir } from './index.ts'
9
+
10
+ const dirs: string[] = []
11
+ function mkTmp(): string {
12
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-ph-'))
13
+ dirs.push(d)
14
+ return d
15
+ }
16
+ afterEach(() => {
17
+ while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
18
+ })
19
+
20
+ describe('peers home', () => {
21
+ test('peersHomeDir / defaultPeerCwd resolve under IAPEER_ROOT', () => {
22
+ const root = mkTmp()
23
+ const env = { IAPEER_ROOT: join(root, 'iapeer'), HOME: root } as NodeJS.ProcessEnv
24
+ expect(peersHomeDir({ env })).toBe(join(root, 'iapeer', 'peers'))
25
+ expect(defaultPeerCwd('worker', { env })).toBe(join(root, 'iapeer', 'peers', 'worker'))
26
+ })
27
+
28
+ test('ensureGlobalIapScaffold creates ~/.iapeer/peers/', () => {
29
+ const root = mkTmp()
30
+ const env = { IAPEER_ROOT: join(root, 'iapeer'), HOME: root } as NodeJS.ProcessEnv
31
+ ensureGlobalIapScaffold({ env })
32
+ expect(existsSync(peersHomeDir({ env }))).toBe(true)
33
+ })
34
+ })
@@ -0,0 +1,65 @@
1
+ // readLaunchEnv — the per-peer per-runtime launch.env parser (Ф-A #5, zone
2
+ // Хранение / Рантайм-адаптеры). Pure FS parser, no tmux: PEER_START_ARGS
3
+ // word-splits (faithful to the legacy unquoted ${PEER_START_ARGS} expansion),
4
+ // other KEY=VALUE lines become child env, quotes are stripped, comments/blanks
5
+ // are ignored, and a missing file → empty.
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
9
+ import { tmpdir } from 'os'
10
+ import { join } from 'path'
11
+ import { peerLaunchEnvPath, readLaunchEnv } from './index.ts'
12
+
13
+ let cwd: string
14
+ beforeEach(() => {
15
+ cwd = mkdtempSync(join(tmpdir(), 'iapeer-launchenv-'))
16
+ })
17
+ afterEach(() => {
18
+ rmSync(cwd, { recursive: true, force: true })
19
+ })
20
+
21
+ function writeLaunchEnv(runtime: string, body: string): void {
22
+ const p = peerLaunchEnvPath(cwd, runtime)
23
+ mkdirSync(join(p, '..'), { recursive: true })
24
+ writeFileSync(p, body)
25
+ }
26
+
27
+ describe('readLaunchEnv', () => {
28
+ test('missing file → empty (no flags, no env)', () => {
29
+ expect(readLaunchEnv(cwd, 'claude')).toEqual({ startArgs: [], env: {} })
30
+ })
31
+
32
+ test('PEER_START_ARGS word-splits on whitespace; surrounding quotes stripped', () => {
33
+ writeLaunchEnv('claude', 'PEER_START_ARGS="--dangerously-load-development-channels --foo bar"\n')
34
+ expect(readLaunchEnv(cwd, 'claude').startArgs).toEqual([
35
+ '--dangerously-load-development-channels',
36
+ '--foo',
37
+ 'bar',
38
+ ])
39
+ })
40
+
41
+ test('single arg, no quotes', () => {
42
+ writeLaunchEnv('codex', 'PEER_START_ARGS=--no-alt-screen\n')
43
+ expect(readLaunchEnv(cwd, 'codex').startArgs).toEqual(['--no-alt-screen'])
44
+ })
45
+
46
+ test('other KEY=VALUE lines become child env; PEER_START_ARGS is consumed (not in env)', () => {
47
+ writeLaunchEnv('claude', '# a comment\nexport FOO=bar\nBAZ="q u x"\nPEER_START_ARGS="--x"\n\n')
48
+ const { startArgs, env } = readLaunchEnv(cwd, 'claude')
49
+ expect(startArgs).toEqual(['--x'])
50
+ expect(env).toEqual({ FOO: 'bar', BAZ: 'q u x' })
51
+ expect(env.PEER_START_ARGS).toBeUndefined()
52
+ })
53
+
54
+ test('blank lines, comments, and non-assignment lines are ignored', () => {
55
+ writeLaunchEnv('claude', '\n # comment\nnot an assignment line\nKEY=value\n')
56
+ expect(readLaunchEnv(cwd, 'claude').env).toEqual({ KEY: 'value' })
57
+ })
58
+
59
+ test('empty PEER_START_ARGS → no args', () => {
60
+ writeLaunchEnv('claude', 'PEER_START_ARGS=""\nFOO=bar\n')
61
+ const { startArgs, env } = readLaunchEnv(cwd, 'claude')
62
+ expect(startArgs).toEqual([])
63
+ expect(env).toEqual({ FOO: 'bar' })
64
+ })
65
+ })