@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,496 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import {
6
+ attachPeer,
7
+ clearNewMark,
8
+ clearStopped,
9
+ composeFirstMessage,
10
+ folderLaunch,
11
+ hasNewMark,
12
+ isLaunchdManaged,
13
+ isStopped,
14
+ lastActiveRuntime,
15
+ loadLifecycleConfig,
16
+ resolveWakeMode,
17
+ resolveWakeRuntime,
18
+ setNewMark,
19
+ setStopped,
20
+ superviseTick,
21
+ wakeOrSpawn,
22
+ withWakeLock,
23
+ type LifecycleConfig,
24
+ } from './index.ts'
25
+ import { upsertPeer, type PeerRecord } from '../registry/index.ts'
26
+
27
+ function peer(over: Partial<PeerRecord>): PeerRecord {
28
+ return {
29
+ personality: 'p',
30
+ runtime: 'claude',
31
+ runtimes: ['claude'],
32
+ description: '',
33
+ intelligence: 'artificial',
34
+ cwd: '/tmp/p',
35
+ ...over,
36
+ }
37
+ }
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // H5 — resolveWakeRuntime (registry-based, no live-socket scan)
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ describe('resolveWakeRuntime (H5)', () => {
44
+ test('explicit declared caller runtime wins', () => {
45
+ const r = resolveWakeRuntime('codex', peer({ runtimes: ['claude', 'codex'] }))
46
+ expect(r.ok && r.value).toBe('codex')
47
+ })
48
+
49
+ test('explicit UNDECLARED caller runtime → fail-loud (no silent claude)', () => {
50
+ const r = resolveWakeRuntime('codex', peer({ runtime: 'claude', runtimes: ['claude'] }))
51
+ expect(r.ok).toBe(false)
52
+ })
53
+
54
+ test('no caller runtime → peer.runtime (registry default)', () => {
55
+ const r = resolveWakeRuntime(undefined, peer({ runtime: 'codex', runtimes: ['codex', 'claude'] }))
56
+ expect(r.ok && r.value).toBe('codex')
57
+ })
58
+
59
+ test('no caller runtime, no peer.runtime → first of runtimes[]', () => {
60
+ const r = resolveWakeRuntime(undefined, peer({ runtime: '' as never, runtimes: ['codex'] }))
61
+ expect(r.ok && r.value).toBe('codex')
62
+ })
63
+
64
+ test('nothing to pick → fail-loud', () => {
65
+ const r = resolveWakeRuntime(undefined, peer({ runtime: '' as never, runtimes: [] }))
66
+ expect(r.ok).toBe(false)
67
+ })
68
+ })
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // H4 — isLaunchdManaged on the LIVE fleet (read-only)
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ describe('isLaunchdManaged (H4 detector, live read-only)', () => {
75
+ test('a launchd-managed peer (boris has com.iapeer.boris.plist) → true', () => {
76
+ // The live host has com.iapeer.boris.plist — the daemon must treat boris as
77
+ // READ-ONLY (never wake/reap). This proves the detector fires on the fleet.
78
+ expect(isLaunchdManaged('boris')).toBe(true)
79
+ })
80
+
81
+ test('a made-up daemon-owned name (no plist) → false', () => {
82
+ expect(isLaunchdManaged('iapeer-throwaway-no-plist-xyz')).toBe(false)
83
+ })
84
+ })
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // withWakeLock — serializes per identity (concurrent = one at a time)
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ describe('withWakeLock', () => {
91
+ let stateDir: string
92
+ beforeEach(() => {
93
+ stateDir = mkdtempSync(join(tmpdir(), 'iapeer-wakelock-'))
94
+ })
95
+ afterEach(() => {
96
+ rmSync(stateDir, { recursive: true, force: true })
97
+ })
98
+
99
+ const cfg = (): LifecycleConfig =>
100
+ ({
101
+ claudeBin: 'claude',
102
+ codexBin: 'codex',
103
+ sockDir: '/tmp',
104
+ stateDir,
105
+ logDir: stateDir,
106
+ bootDeadlineSecs: 1,
107
+ readyGateSecs: 1,
108
+ idleSecs: 1,
109
+ maxAgeSecs: 1,
110
+ }) as LifecycleConfig
111
+
112
+ test('two concurrent locks on the same identity run strictly serialized', async () => {
113
+ const order: string[] = []
114
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
115
+ const p1 = withWakeLock(cfg(), 'claude-x', async () => {
116
+ order.push('1-start')
117
+ await sleep(150)
118
+ order.push('1-end')
119
+ })
120
+ // ensure p1 grabs the lock first
121
+ await sleep(20)
122
+ const p2 = withWakeLock(cfg(), 'claude-x', async () => {
123
+ order.push('2-start')
124
+ order.push('2-end')
125
+ })
126
+ await Promise.all([p1, p2])
127
+ expect(order).toEqual(['1-start', '1-end', '2-start', '2-end'])
128
+ })
129
+
130
+ test('locks on DIFFERENT identities do not block each other', async () => {
131
+ const order: string[] = []
132
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
133
+ const a = withWakeLock(cfg(), 'claude-a', async () => {
134
+ order.push('a-start')
135
+ await sleep(120)
136
+ order.push('a-end')
137
+ })
138
+ await sleep(20)
139
+ const b = withWakeLock(cfg(), 'claude-b', async () => {
140
+ order.push('b-start') // must interleave — different lock
141
+ })
142
+ await Promise.all([a, b])
143
+ expect(order.indexOf('b-start')).toBeLessThan(order.indexOf('a-end'))
144
+ })
145
+ })
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ // superviseTick — H4 guard FIRST (safe: temp LaunchAgents dir, no real fleet)
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+
151
+ describe('superviseTick H4 guard', () => {
152
+ let stateDir: string
153
+ let laDir: string
154
+ beforeEach(() => {
155
+ stateDir = mkdtempSync(join(tmpdir(), 'iapeer-sup-state-'))
156
+ laDir = mkdtempSync(join(tmpdir(), 'iapeer-sup-la-'))
157
+ })
158
+ afterEach(() => {
159
+ rmSync(stateDir, { recursive: true, force: true })
160
+ rmSync(laDir, { recursive: true, force: true })
161
+ })
162
+
163
+ const cfg = (): LifecycleConfig =>
164
+ ({
165
+ claudeBin: 'claude',
166
+ codexBin: 'codex',
167
+ sockDir: '/tmp',
168
+ stateDir,
169
+ logDir: stateDir,
170
+ bootDeadlineSecs: 1,
171
+ readyGateSecs: 1,
172
+ idleSecs: 1,
173
+ maxAgeSecs: 1,
174
+ }) as LifecycleConfig
175
+
176
+ function writeState(personality: string): string {
177
+ const identity = `claude-${personality}`
178
+ writeFileSync(
179
+ join(stateDir, `${identity}.session`),
180
+ JSON.stringify({ identity, runtime: 'claude', personality, cwd: '/tmp/none', wokeAt: 0 }),
181
+ )
182
+ return identity
183
+ }
184
+ const env = () => ({ ...process.env, IAPEER_LAUNCHAGENTS_DIR: laDir })
185
+
186
+ test('a launchd-managed peer (plist present) is SKIPPED first — never reaped', () => {
187
+ // a fake plist in the TEMP LaunchAgents dir (real ~/Library is untouched)
188
+ writeFileSync(join(laDir, 'com.iapeer.iapeer-fakelaunchd.plist'), '')
189
+ const id = writeState('iapeer-fakelaunchd')
190
+ const out = superviseTick(cfg(), { env: env(), nowMs: Date.now() })
191
+ const o = out.find(x => x.identity === id)
192
+ expect(o?.action).toBe('skipped-launchd')
193
+ // read-only: its session-state is NOT removed
194
+ expect(existsSync(join(stateDir, `${id}.session`))).toBe(true)
195
+ })
196
+
197
+ test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
198
+ const id = writeState('iapeer-supgone') // no plist, no live tmux session
199
+ const out = superviseTick(cfg(), { env: env(), nowMs: Date.now() })
200
+ const o = out.find(x => x.identity === id)
201
+ expect(o?.action).toBe('reaped-gone')
202
+ expect(existsSync(join(stateDir, `${id}.session`))).toBe(false)
203
+ })
204
+
205
+ test('empty state dir → no outcomes', () => {
206
+ expect(superviseTick(cfg(), { env: env() })).toEqual([])
207
+ })
208
+ })
209
+
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+ // wakeOrSpawn H4 — REFUSES to wake a launchd-managed peer (no spawn at all)
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+
214
+ describe('wakeOrSpawn H4 refusal', () => {
215
+ test('a launchd-managed peer is NOT woken (returns FAILED before any spawn)', async () => {
216
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-h4-root-'))
217
+ const laDir = mkdtempSync(join(tmpdir(), 'iapeer-h4-la-'))
218
+ try {
219
+ // a registered peer that ALSO has a (fake) launchd plist → launchd domain
220
+ writeFileSync(join(laDir, 'com.iapeer.iapeer-h4ld.plist'), '')
221
+ await upsertPeer(
222
+ { personality: 'iapeer-h4ld', runtime: 'claude', cwd: '/tmp/none', intelligence: 'artificial' },
223
+ { rootDir: root },
224
+ )
225
+ const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir }
226
+ const r = await wakeOrSpawn({ personality: 'iapeer-h4ld', runtime: 'claude', task: 'must not spawn' }, { env })
227
+ expect(r.status).toBe('FAILED')
228
+ expect(r.woke).toBe(false)
229
+ expect(r.reason).toMatch(/launchd-managed/)
230
+ } finally {
231
+ rmSync(root, { recursive: true, force: true })
232
+ rmSync(laDir, { recursive: true, force: true })
233
+ }
234
+ })
235
+ })
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // C1 — durable stopped flag (stop/start; daemon refuses to wake a stopped peer)
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+
241
+ describe('C1 durable stopped flag', () => {
242
+ test('isStopped/setStopped/clearStopped round-trip', () => {
243
+ const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-stopped-'))
244
+ const cfg = { stateDir } as LifecycleConfig
245
+ try {
246
+ expect(isStopped(cfg, 'claude-x')).toBe(false)
247
+ setStopped(cfg, 'claude-x')
248
+ expect(isStopped(cfg, 'claude-x')).toBe(true)
249
+ clearStopped(cfg, 'claude-x')
250
+ expect(isStopped(cfg, 'claude-x')).toBe(false)
251
+ } finally {
252
+ rmSync(stateDir, { recursive: true, force: true })
253
+ }
254
+ })
255
+
256
+ test('wakeOrSpawn REFUSES a stopped peer (FAILED stopped:true, before any spawn)', async () => {
257
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-stp-root-'))
258
+ const laDir = mkdtempSync(join(tmpdir(), 'iapeer-stp-la-')) // empty → not launchd-managed
259
+ try {
260
+ await upsertPeer(
261
+ { personality: 'stp', runtime: 'claude', cwd: '/tmp/none', intelligence: 'artificial' },
262
+ { rootDir: root },
263
+ )
264
+ const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir }
265
+ const cfg = loadLifecycleConfig(env)
266
+ setStopped(cfg, 'claude-stp')
267
+ const r = await wakeOrSpawn({ personality: 'stp', runtime: 'claude', task: 'must not wake' }, { env })
268
+ expect(r.status).toBe('FAILED')
269
+ expect(r.stopped).toBe(true)
270
+ expect(r.reason).toMatch(/stopped/)
271
+ // cleared → wakeable again (it will then FAIL later for the missing cwd, NOT stopped)
272
+ clearStopped(cfg, 'claude-stp')
273
+ const r2 = await wakeOrSpawn({ personality: 'stp', runtime: 'claude', task: 'x' }, { env })
274
+ expect(r2.stopped).toBeFalsy()
275
+ } finally {
276
+ rmSync(root, { recursive: true, force: true })
277
+ rmSync(laDir, { recursive: true, force: true })
278
+ }
279
+ })
280
+ })
281
+
282
+ // ─────────────────────────────────────────────────────────────────────────────
283
+ // C2 — initial_prompt launch-seed (composeFirstMessage)
284
+ // ─────────────────────────────────────────────────────────────────────────────
285
+
286
+ describe('C2 initial_prompt (composeFirstMessage)', () => {
287
+ function withProfile(initial_prompt?: string): string {
288
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-seed-'))
289
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
290
+ writeFileSync(
291
+ join(cwd, '.iapeer', 'peer-profile.json'),
292
+ JSON.stringify({
293
+ personality: 'p',
294
+ runtime: 'claude',
295
+ runtimes: ['claude'],
296
+ intelligence: 'artificial',
297
+ ...(initial_prompt ? { initial_prompt } : {}),
298
+ }),
299
+ )
300
+ return cwd
301
+ }
302
+
303
+ test('fresh wake + initial_prompt → seed THEN task (both, seed first)', () => {
304
+ const cwd = withProfile('First, read STATE.md.')
305
+ try {
306
+ expect(composeFirstMessage(cwd, '<iap>msg</iap>', true)).toBe('First, read STATE.md.\n\n<iap>msg</iap>')
307
+ } finally {
308
+ rmSync(cwd, { recursive: true, force: true })
309
+ }
310
+ })
311
+
312
+ test('resume (fresh=false) → task only, no seed', () => {
313
+ const cwd = withProfile('First, read STATE.md.')
314
+ try {
315
+ expect(composeFirstMessage(cwd, 'TASK', false)).toBe('TASK')
316
+ } finally {
317
+ rmSync(cwd, { recursive: true, force: true })
318
+ }
319
+ })
320
+
321
+ test('fresh wake, NO initial_prompt → task only', () => {
322
+ const cwd = withProfile(undefined)
323
+ try {
324
+ expect(composeFirstMessage(cwd, 'TASK', true)).toBe('TASK')
325
+ } finally {
326
+ rmSync(cwd, { recursive: true, force: true })
327
+ }
328
+ })
329
+
330
+ test('fresh with seed but EMPTY task (eager /new) → seed alone (no trailing)', () => {
331
+ const cwd = withProfile('Report you are up.')
332
+ try {
333
+ expect(composeFirstMessage(cwd, '', true)).toBe('Report you are up.')
334
+ } finally {
335
+ rmSync(cwd, { recursive: true, force: true })
336
+ }
337
+ })
338
+ })
339
+
340
+ // ─────────────────────────────────────────────────────────────────────────────
341
+ // C3a + C4a — resolveWakeMode (resume-vs-fresh; the contract-divergence fix)
342
+ // ─────────────────────────────────────────────────────────────────────────────
343
+
344
+ describe('resolveWakeMode (C3a default-resume + C4a /new-mark)', () => {
345
+ let stateDir: string
346
+ beforeEach(() => {
347
+ stateDir = mkdtempSync(join(tmpdir(), 'iapeer-wakemode-'))
348
+ })
349
+ afterEach(() => {
350
+ rmSync(stateDir, { recursive: true, force: true })
351
+ })
352
+ const cfg = () => ({ stateDir } as LifecycleConfig)
353
+ const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
354
+ const noTranscript = () => ({ ok: false, reason: 'no transcript to resume' })
355
+
356
+ test('DEFAULT (undefined) + transcript exists → RESUME (the warm-asleep contract fix)', () => {
357
+ expect(resolveWakeMode(cfg(), 'claude-p', '/cwd', undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1' })
358
+ })
359
+ test('DEFAULT + NO transcript → FRESH (first-ever launch, not an error)', () => {
360
+ expect(resolveWakeMode(cfg(), 'claude-p', '/cwd', undefined, noTranscript)).toEqual({ resume: false })
361
+ })
362
+ test('explicit resume=true + nothing to resume → FAIL-LOUD (failReason, no silent fresh)', () => {
363
+ const m = resolveWakeMode(cfg(), 'claude-p', '/cwd', true, noTranscript)
364
+ expect(m.resume).toBe(false)
365
+ expect(m.failReason).toMatch(/nothing to resume|no transcript/)
366
+ })
367
+ test('explicit resume=false → FRESH', () => {
368
+ expect(resolveWakeMode(cfg(), 'claude-p', '/cwd', false, hasTranscript)).toEqual({ resume: false })
369
+ })
370
+ test('/new-mark present → FRESH and CONSUMES the mark (even if a transcript exists)', () => {
371
+ const c = cfg()
372
+ setNewMark(c, 'claude-p')
373
+ expect(hasNewMark(c, 'claude-p')).toBe(true)
374
+ expect(resolveWakeMode(c, 'claude-p', '/cwd', undefined, hasTranscript)).toEqual({ resume: false })
375
+ expect(hasNewMark(c, 'claude-p')).toBe(false) // consumed
376
+ })
377
+ })
378
+
379
+ describe('C4a /new-mark round-trip', () => {
380
+ test('set/has/clear', () => {
381
+ const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-newmark-'))
382
+ const cfg = { stateDir } as LifecycleConfig
383
+ try {
384
+ expect(hasNewMark(cfg, 'claude-y')).toBe(false)
385
+ setNewMark(cfg, 'claude-y')
386
+ expect(hasNewMark(cfg, 'claude-y')).toBe(true)
387
+ clearNewMark(cfg, 'claude-y')
388
+ expect(hasNewMark(cfg, 'claude-y')).toBe(false)
389
+ } finally {
390
+ rmSync(stateDir, { recursive: true, force: true })
391
+ }
392
+ })
393
+ })
394
+
395
+ // ─────────────────────────────────────────────────────────────────────────────
396
+ // C4b — eager fresh re-launch detection (superviseTick flags a dead +/new session)
397
+ // ─────────────────────────────────────────────────────────────────────────────
398
+
399
+ describe('C4b eager fresh re-launch (superviseTick detection)', () => {
400
+ test('a DEAD session carrying a /new-mark → needs-eager-fresh (mark LEFT for relaunch)', () => {
401
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-c4b-root-'))
402
+ const laDir = mkdtempSync(join(tmpdir(), 'iapeer-c4b-la-')) // empty → not launchd-managed
403
+ try {
404
+ const env = {
405
+ ...process.env,
406
+ IAPEER_ROOT: root,
407
+ IAPEER_LAUNCHAGENTS_DIR: laDir,
408
+ IAPEER_SOCK_DIR: join(root, 'socks'), // isolated, no live session here → dead
409
+ }
410
+ const cfg = loadLifecycleConfig(env)
411
+ mkdirSync(cfg.stateDir, { recursive: true })
412
+ // a session-state for a peer whose session is NOT live (dead)
413
+ writeFileSync(
414
+ join(cfg.stateDir, 'claude-z.session'),
415
+ JSON.stringify({ identity: 'claude-z', runtime: 'claude', personality: 'z', cwd: '/tmp/z', wokeAt: Date.now() }),
416
+ )
417
+ setNewMark(cfg, 'claude-z')
418
+ const out = superviseTick(cfg, { env })
419
+ const o = out.find(x => x.identity === 'claude-z')
420
+ expect(o?.action).toBe('needs-eager-fresh')
421
+ expect(o?.personality).toBe('z')
422
+ expect(o?.runtime).toBe('claude')
423
+ // the session-state is removed, but the /new-mark is LEFT for the eager
424
+ // relaunch's resolveWakeMode to consume (so the relaunch resolves to fresh).
425
+ expect(hasNewMark(cfg, 'claude-z')).toBe(true)
426
+ } finally {
427
+ rmSync(root, { recursive: true, force: true })
428
+ rmSync(laDir, { recursive: true, force: true })
429
+ }
430
+ })
431
+
432
+ test('a DEAD session with NO /new-mark → reaped-gone (not eager)', () => {
433
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-c4b2-root-'))
434
+ const laDir = mkdtempSync(join(tmpdir(), 'iapeer-c4b2-la-'))
435
+ try {
436
+ const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_SOCK_DIR: join(root, 'socks') }
437
+ const cfg = loadLifecycleConfig(env)
438
+ mkdirSync(cfg.stateDir, { recursive: true })
439
+ writeFileSync(
440
+ join(cfg.stateDir, 'claude-w.session'),
441
+ JSON.stringify({ identity: 'claude-w', runtime: 'claude', personality: 'w', cwd: '/tmp/w', wokeAt: Date.now() }),
442
+ )
443
+ const out = superviseTick(cfg, { env })
444
+ expect(out.find(x => x.identity === 'claude-w')?.action).toBe('reaped-gone')
445
+ } finally {
446
+ rmSync(root, { recursive: true, force: true })
447
+ rmSync(laDir, { recursive: true, force: true })
448
+ }
449
+ })
450
+ })
451
+
452
+ // ─────────────────────────────────────────────────────────────────────────────
453
+ // Ф-D launch / attach — operator verbs (error paths; success paths are live-verified)
454
+ // ─────────────────────────────────────────────────────────────────────────────
455
+
456
+ describe('lastActiveRuntime', () => {
457
+ test('a peer with no transcript anywhere → undefined (never run)', () => {
458
+ const cfg = { sockDir: '/tmp' } as LifecycleConfig
459
+ const rt = lastActiveRuntime(peer({ personality: 'np', runtimes: ['claude', 'codex'], cwd: '/tmp/does-not-exist-xyz' }), cfg)
460
+ expect(rt).toBeUndefined()
461
+ })
462
+ })
463
+
464
+ describe('attachPeer (error paths)', () => {
465
+ test('an unregistered peer → ok:false', async () => {
466
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-att-'))
467
+ try {
468
+ const r = await attachPeer({ personality: 'ghost', env: { ...process.env, IAPEER_ROOT: root } })
469
+ expect(r.ok).toBe(false)
470
+ if (!r.ok) expect(r.reason).toMatch(/not registered/)
471
+ } finally {
472
+ rmSync(root, { recursive: true, force: true })
473
+ }
474
+ })
475
+ test('an explicit UNDECLARED runtime → ok:false (fail-loud)', async () => {
476
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-att2-'))
477
+ try {
478
+ await upsertPeer({ personality: 'solo', runtime: 'claude', cwd: '/tmp/solo', intelligence: 'artificial' }, { rootDir: root })
479
+ const r = await attachPeer({ personality: 'solo', runtime: 'codex', env: { ...process.env, IAPEER_ROOT: root } })
480
+ expect(r.ok).toBe(false)
481
+ } finally {
482
+ rmSync(root, { recursive: true, force: true })
483
+ }
484
+ })
485
+ })
486
+
487
+ describe('folderLaunch (error path)', () => {
488
+ test('a cwd without a peer-profile → throws (resolveIdentity fail-loud)', async () => {
489
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-fl-'))
490
+ try {
491
+ await expect(folderLaunch({ cwd, env: { ...process.env } })).rejects.toThrow()
492
+ } finally {
493
+ rmSync(cwd, { recursive: true, force: true })
494
+ }
495
+ })
496
+ })
@@ -0,0 +1,135 @@
1
+ // onboard — the host-phase (contract Установка §2 ONBOARD). The linking step
2
+ // between install (the binary) and init (per-peer): register OUR marketplace in
3
+ // claude AND codex so peers can install agfpd capability plugins. Infra-runtime
4
+ // install (telegram/notifier self-installable npx) is the operator-choice follow-up.
5
+ //
6
+ // FLEET SAFETY: a host already configured for the legacy fleet MUST NOT be mutated by
7
+ // onboard. So onboard is strictly IDEMPOTENT — it DETECTS whether the marketplace is
8
+ // already registered (the runtime's own `plugin marketplace list`) and SKIPS when it
9
+ // is. On a configured host every step is a no-op; only a fresh host is written. A
10
+ // dry-run reports the would-be actions without touching anything.
11
+
12
+ import { spawnSync } from 'child_process'
13
+ import { homedir } from 'os'
14
+ import { join } from 'path'
15
+ import { accessSync, constants as FS } from 'fs'
16
+
17
+ /** OUR marketplace — GitHub owner/repo, the source both runtimes' `marketplace add`
18
+ * takes; and the registered NAME both runtimes' `marketplace list` shows. */
19
+ export const MARKETPLACE_REF = 'agfpd/agfpd-marketplace'
20
+ export const MARKETPLACE_NAME = 'agfpd'
21
+
22
+ export type OnboardRuntime = 'claude' | 'codex'
23
+
24
+ export type MarketplaceState =
25
+ | 'already-registered' // present → no-op (fleet-safe)
26
+ | 'registered' // was absent → added now
27
+ | 'would-register' // dry-run: absent, would add
28
+ | 'runtime-missing' // the runtime binary is not installed
29
+ | 'failed' // the add command failed
30
+
31
+ export interface OnboardRuntimeResult {
32
+ runtime: OnboardRuntime
33
+ state: MarketplaceState
34
+ detail?: string
35
+ }
36
+
37
+ export interface OnboardResult {
38
+ marketplaces: OnboardRuntimeResult[]
39
+ /** True when nothing was mutated (every runtime already-registered / dry-run / missing). */
40
+ noop: boolean
41
+ }
42
+
43
+ export interface OnboardOptions {
44
+ /** Report the would-be actions without running any `marketplace add`. */
45
+ dryRun?: boolean
46
+ /** Restrict to these runtimes (default both). */
47
+ runtimes?: OnboardRuntime[]
48
+ env?: NodeJS.ProcessEnv
49
+ }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+
53
+ function runtimeBin(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): string {
54
+ if (runtime === 'claude') return env.IAPEER_CLAUDE_BIN?.trim() || join(env.HOME?.trim() || homedir(), '.local', 'bin', 'claude')
55
+ return env.IAPEER_CODEX_BIN?.trim() || 'codex'
56
+ }
57
+
58
+ function isExecutable(binOrName: string): boolean {
59
+ if (binOrName.includes('/')) {
60
+ try {
61
+ accessSync(binOrName, FS.X_OK)
62
+ return true
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+ // bare name → resolved by spawnSync against PATH; probe with `which`-free spawn
68
+ const r = spawnSync(binOrName, ['--version'], { stdio: 'ignore' })
69
+ return r.error === undefined && r.status !== null
70
+ }
71
+
72
+ /**
73
+ * Is OUR marketplace already registered for this runtime? Reads the runtime's own
74
+ * `plugin marketplace list` and matches the agfpd source-ref OR a standalone agfpd
75
+ * name entry (both runtimes render the name; claude also shows the GitHub ref). A
76
+ * word-boundary-ish match so a different agfpd-* string never false-positives.
77
+ */
78
+ export function isMarketplaceRegistered(runtime: OnboardRuntime, env: NodeJS.ProcessEnv = process.env): boolean {
79
+ const bin = runtimeBin(runtime, env)
80
+ const r = spawnSync(bin, ['plugin', 'marketplace', 'list'], { encoding: 'utf8' })
81
+ if (r.status !== 0) return false
82
+ return isAgfpdInList(`${r.stdout ?? ''}`)
83
+ }
84
+
85
+ /**
86
+ * Pure detector over a `plugin marketplace list` output: is OUR marketplace present?
87
+ * Matches the agfpd GitHub source-ref (claude renders it) OR a standalone agfpd name
88
+ * entry (both runtimes render the name). The name match is anchored to a line start
89
+ * (optionally after the `❯` selection glyph) and followed by whitespace/EOL, so a
90
+ * different `agfpd-<something>` token never false-positives. Pure → unit-testable
91
+ * against real claude/codex samples (the fleet-guard hinges on it).
92
+ */
93
+ export function isAgfpdInList(listOutput: string): boolean {
94
+ if (new RegExp(MARKETPLACE_REF.replace('/', '\\/')).test(listOutput)) return true
95
+ return /(^|\n)\s*(❯\s*)?agfpd(\s|$)/.test(listOutput)
96
+ }
97
+
98
+ /** Register OUR marketplace for this runtime (`<runtime> plugin marketplace add <ref>`). */
99
+ function registerMarketplace(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): { ok: boolean; detail?: string } {
100
+ const bin = runtimeBin(runtime, env)
101
+ const r = spawnSync(bin, ['plugin', 'marketplace', 'add', MARKETPLACE_REF], { encoding: 'utf8' })
102
+ return r.status === 0 ? { ok: true } : { ok: false, detail: (r.stderr ?? '').trim() || `exit ${r.status}` }
103
+ }
104
+
105
+ /**
106
+ * Onboard the host: ensure OUR marketplace is registered in each runtime — IDEMPOTENT
107
+ * (detect → skip when present). On an already-configured host every runtime is
108
+ * 'already-registered' and NOTHING is mutated (fleet-safe). dryRun reports 'would-
109
+ * register' for any absent one without running the add. A missing runtime binary is
110
+ * 'runtime-missing' (skipped, not an error). Infra-runtime install is a separate
111
+ * operator-choice step (not done here).
112
+ */
113
+ export function onboardHost(opts: OnboardOptions = {}): OnboardResult {
114
+ const env = opts.env ?? process.env
115
+ const runtimes = opts.runtimes ?? (['claude', 'codex'] as OnboardRuntime[])
116
+ const marketplaces: OnboardRuntimeResult[] = []
117
+ for (const runtime of runtimes) {
118
+ if (!isExecutable(runtimeBin(runtime, env))) {
119
+ marketplaces.push({ runtime, state: 'runtime-missing' })
120
+ continue
121
+ }
122
+ if (isMarketplaceRegistered(runtime, env)) {
123
+ marketplaces.push({ runtime, state: 'already-registered' })
124
+ continue
125
+ }
126
+ if (opts.dryRun) {
127
+ marketplaces.push({ runtime, state: 'would-register' })
128
+ continue
129
+ }
130
+ const r = registerMarketplace(runtime, env)
131
+ marketplaces.push({ runtime, state: r.ok ? 'registered' : 'failed', detail: r.detail })
132
+ }
133
+ const noop = marketplaces.every(m => m.state !== 'registered')
134
+ return { marketplaces, noop }
135
+ }
@@ -0,0 +1,39 @@
1
+ // onboard — the fleet-critical detector (isAgfpdInList): is OUR marketplace already
2
+ // registered? A false negative would RE-register on an already-configured host (the
3
+ // exact fleet-mutation onboard must avoid). Tested against REAL claude/codex
4
+ // `plugin marketplace list` output shapes.
5
+
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { isAgfpdInList } from './index.ts'
8
+
9
+ describe('isAgfpdInList (marketplace-registered detector)', () => {
10
+ test('claude list shape (❯ name + GitHub source ref) → true', () => {
11
+ const claudeOut = `Configured marketplaces:
12
+
13
+ ❯ claude-plugins-official
14
+ Source: GitHub (anthropics/claude-plugins-official)
15
+
16
+ ❯ agfpd
17
+ Source: GitHub (agfpd/agfpd-marketplace)
18
+ `
19
+ expect(isAgfpdInList(claudeOut)).toBe(true)
20
+ })
21
+
22
+ test('codex list shape (name + root path columns) → true', () => {
23
+ const codexOut = `MARKETPLACE ROOT
24
+ agfpd /Users/x/.codex/.tmp/marketplaces/agfpd
25
+ claude-plugins-official /Users/x/.codex/.tmp/marketplaces/claude-plugins-official
26
+ `
27
+ expect(isAgfpdInList(codexOut)).toBe(true)
28
+ })
29
+
30
+ test('NOT registered (only other marketplaces) → false', () => {
31
+ expect(isAgfpdInList('Configured marketplaces:\n\n ❯ claude-plugins-official\n Source: GitHub (anthropics/claude-plugins-official)\n')).toBe(false)
32
+ expect(isAgfpdInList('')).toBe(false)
33
+ })
34
+
35
+ test('a different agfpd-* token (e.g. a plugin name) does NOT false-positive', () => {
36
+ // a line that mentions agfpd-prompt-architect but the agfpd marketplace is NOT listed
37
+ expect(isAgfpdInList('plugins:\n agfpd-prompt-architect@somewhere (installed)\n')).toBe(false)
38
+ })
39
+ })