@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.
- package/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- 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
|
+
})
|