@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,603 @@
1
+ // Identity — peer-profile.json R/W with field preservation, per-process identity
2
+ // resolution (resolveIdentity, for runtime adapters) and per-REQUEST identity
3
+ // resolution (resolveCallerIdentity, for the always-on daemon — NO process.cwd()).
4
+ // Consolidated from inter-agent-protocol/src/lib/identity.ts (wins), with the H1
5
+ // blueprint-v2 fix on writePeerProfileAtomic (never downgrade intelligence /
6
+ // wipe description without an explicit new value). Path helpers moved to storage.
7
+
8
+ import { basename, join, resolve } from 'path'
9
+ import { existsSync, readFileSync, realpathSync } from 'fs'
10
+ import {
11
+ IAPEER_DIR,
12
+ PEER_PROFILE_FILE,
13
+ NAME_RE,
14
+ defaultIntelligenceForRuntime,
15
+ isInfraRuntime,
16
+ isRuntime,
17
+ isValidName,
18
+ normalizeIntelligenceValue,
19
+ normalizeNameCandidate,
20
+ type Intelligence,
21
+ type Runtime,
22
+ } from '../core/constants.ts'
23
+ // Provision-time only: an INFRA peer (notifier/telegram) is held live by launchd
24
+ // KeepAlive, so creating one installs its always-on plist. Imported from the
25
+ // launchd module directly (not the launch barrel) to keep the dependency surface
26
+ // minimal — launchd.ts pulls only core/*, so identity → launch introduces no cycle.
27
+ import { installAlwaysOnPlist } from '../launch/launchd.ts'
28
+ import { buildProcessAddress } from '../core/socket.ts'
29
+ import { IapError } from '../core/errors.ts'
30
+ import {
31
+ ensureLocalIapScaffold,
32
+ ensureLocalRuntimeScopes,
33
+ listRuntimeScopeNames,
34
+ peerProfilePath,
35
+ writeFileAtomic,
36
+ } from '../storage/index.ts'
37
+ import {
38
+ findPeer,
39
+ readPeersIndex,
40
+ updatePeersIndex,
41
+ type PeerInterfaces,
42
+ type PeerRecord,
43
+ type PeersIndex,
44
+ type PeersUpdateOptions,
45
+ } from '../registry/index.ts'
46
+
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+ // Types
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+
51
+ export interface PeerProfile {
52
+ personality: string
53
+ runtime: Runtime
54
+ runtimes: Runtime[]
55
+ description: string
56
+ intelligence: Intelligence
57
+ /** C2 — launch-seed (contract ЖЦ §initial_prompt, iapeer/lifecycle-owned). Opt;
58
+ * default empty. Injected as the FIRST turn on ANY fresh session (not resume/
59
+ * warm). Carries an opening directive and/or a "I'm up" report. */
60
+ initial_prompt?: string
61
+ interfaces?: PeerInterfaces
62
+ }
63
+
64
+ // Write shape: intelligence/description optional so a caller can write a profile
65
+ // WITHOUT asserting an intelligence (→ existing on-disk value is preserved). A
66
+ // full PeerProfile is assignable to this, so existing callers keep working.
67
+ export type PeerProfileWrite = Omit<PeerProfile, 'intelligence' | 'description'> & {
68
+ intelligence?: Intelligence
69
+ description?: string
70
+ }
71
+
72
+ export interface KnownPeerForProfile {
73
+ personality: string
74
+ cwd: string
75
+ }
76
+
77
+ export interface Identity {
78
+ personality: string
79
+ runtime: Runtime
80
+ address: `${Runtime}-${string}`
81
+ description: string
82
+ intelligence: Intelligence
83
+ cwd: string
84
+ profilePath: string
85
+ profile: PeerProfile
86
+ }
87
+
88
+ export interface CallerIdentity {
89
+ personality: string
90
+ runtime: Runtime
91
+ }
92
+
93
+ export interface ResolveIdentityOptions {
94
+ cwd?: string
95
+ env?: NodeJS.ProcessEnv
96
+ profile?: PeerProfile
97
+ }
98
+
99
+ export interface EnsurePeerProfileOptions {
100
+ cwd?: string
101
+ env?: NodeJS.ProcessEnv
102
+ runtime: Runtime
103
+ peers?: readonly KnownPeerForProfile[]
104
+ warn?: (message: string) => void
105
+ /** Explicit personality (validated/normalized) instead of deriving from
106
+ * basename(cwd). A collision with another cwd throws (no silent suffixing —
107
+ * the caller named it deliberately). */
108
+ personality?: string
109
+ /** For an INFRA runtime: absolute path to the runtime launcher, baked into the
110
+ * always-on plist so launchd's minimal PATH resolves it. Forwarded to
111
+ * installAlwaysOnPlist; ignored for warm-on-demand runtimes. */
112
+ runtimeBin?: string
113
+ }
114
+
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+ // Validation helpers
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+
119
+ function validatePersonality(raw: string, source: string): string {
120
+ const value = normalizeNameCandidate(raw)
121
+ if (!isValidName(value)) {
122
+ throw new IapError(`${source} must match /^[a-z][a-z0-9-]{0,31}$/, got "${raw}"`)
123
+ }
124
+ return value
125
+ }
126
+
127
+ function validateRuntime(raw: unknown, source: string): Runtime {
128
+ if (!isRuntime(raw)) {
129
+ throw new IapError(
130
+ `${source} must be a runtime id matching /^[a-z][a-z0-9]{0,31}$/, got "${String(raw ?? '')}"`,
131
+ )
132
+ }
133
+ return raw
134
+ }
135
+
136
+ function uniqueRuntimes(values: readonly Runtime[]): Runtime[] {
137
+ const out: Runtime[] = []
138
+ for (const value of values) if (!out.includes(value)) out.push(value)
139
+ return out
140
+ }
141
+
142
+ function normalizeRuntimes(raw: unknown, runtime: Runtime): Runtime[] {
143
+ if (!Array.isArray(raw)) return [runtime]
144
+ const runtimes = raw.map(item => validateRuntime(item, 'peer-profile runtimes item'))
145
+ return uniqueRuntimes([runtime, ...runtimes])
146
+ }
147
+
148
+ function normalizeInterfaces(raw: unknown, source: string): PeerInterfaces | undefined {
149
+ if (raw === undefined) return undefined
150
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
151
+ throw new IapError(`${source} interfaces must be a JSON object`)
152
+ }
153
+ return raw as PeerInterfaces
154
+ }
155
+
156
+ function normalizeIntelligence(raw: unknown, runtime: Runtime): Intelligence {
157
+ if (raw === undefined || raw === null || raw === '') return defaultIntelligenceForRuntime(runtime)
158
+ // READ-COMPAT: accept contract artificial/natural/absent and legacy
159
+ // human→natural / scripted→absent off a live profile (no rewrite of the fleet).
160
+ const normalized = normalizeIntelligenceValue(raw)
161
+ if (!normalized) {
162
+ throw new IapError(
163
+ `peer-profile intelligence must be one of artificial|natural|absent (legacy human|scripted accepted), got "${String(raw)}"`,
164
+ )
165
+ }
166
+ return normalized
167
+ }
168
+
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+ // Read / write peer-profile.json
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+
173
+ export function discoverPeerRuntimes(cwd: string, currentRuntime: Runtime): Runtime[] {
174
+ const discovered: Runtime[] = [currentRuntime]
175
+ if (existsSync(join(cwd, '.claude'))) discovered.push('claude')
176
+ if (existsSync(join(cwd, '.codex'))) discovered.push('codex')
177
+ discovered.push(...listRuntimeScopeNames(cwd))
178
+ return uniqueRuntimes(discovered)
179
+ }
180
+
181
+ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null {
182
+ const path = peerProfilePath(cwd)
183
+ if (!existsSync(path)) return null
184
+ let raw: unknown
185
+ try {
186
+ raw = JSON.parse(readFileSync(path, 'utf8'))
187
+ } catch (e) {
188
+ throw new IapError(
189
+ `${IAPEER_DIR}/${PEER_PROFILE_FILE} is invalid JSON: ${e instanceof Error ? e.message : String(e)}`,
190
+ )
191
+ }
192
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
193
+ throw new IapError(`${IAPEER_DIR}/${PEER_PROFILE_FILE} must be a JSON object`)
194
+ }
195
+ const obj = raw as Record<string, unknown>
196
+ if (typeof obj.personality !== 'string' || !obj.personality.trim()) {
197
+ throw new IapError(`${IAPEER_DIR}/${PEER_PROFILE_FILE} personality is required`)
198
+ }
199
+ const personality = validatePersonality(obj.personality, 'peer-profile personality')
200
+ const runtime = validateRuntime(obj.runtime, 'peer-profile runtime')
201
+ const interfaces = normalizeInterfaces(obj.interfaces, `${IAPEER_DIR}/${PEER_PROFILE_FILE}`)
202
+ const intelligence = normalizeIntelligence(obj.intelligence, runtime)
203
+ return {
204
+ personality,
205
+ runtime,
206
+ runtimes: normalizeRuntimes(obj.runtimes, runtime),
207
+ description: typeof obj.description === 'string' ? obj.description.trim() : '',
208
+ intelligence,
209
+ // C2 — initial_prompt (launch-seed). A non-string/absent value → omitted (empty).
210
+ ...(typeof obj.initial_prompt === 'string' && obj.initial_prompt
211
+ ? { initial_prompt: obj.initial_prompt }
212
+ : {}),
213
+ ...(interfaces ? { interfaces } : {}),
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Atomically write peer-profile.json, preserving fields the foundation does not
219
+ * own (e.g. persistent-peer's initial_prompt/aliases section) via a raw read-
220
+ * before-write merge.
221
+ *
222
+ * H1 (blueprint-v2): the write NEVER lowers intelligence nor wipes a non-empty
223
+ * description without an explicit new value:
224
+ * - intelligence absent in the write input → inherit existing on-disk value
225
+ * (only a brand-new profile falls to the runtime default).
226
+ * - description absent/empty in the write input → keep existing.
227
+ * The profile file (basename peer-profile.json) is allowed through
228
+ * storage.writeFileAtomic; only peers-profiles.json is guarded there.
229
+ */
230
+ export function writePeerProfileAtomic(cwd: string, profile: PeerProfileWrite): void {
231
+ ensureLocalIapScaffold(cwd)
232
+ const path = peerProfilePath(cwd)
233
+
234
+ let existing: Record<string, unknown> = {}
235
+ try {
236
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
237
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
238
+ existing = parsed as Record<string, unknown>
239
+ }
240
+ } catch {
241
+ // absent or invalid — only known fields are written; no unknown loss
242
+ }
243
+
244
+ // H1 + NO-MIGRATION: when the write does not assert an intelligence, preserve
245
+ // the existing on-disk value VERBATIM — including a legacy human/scripted value.
246
+ // Normalizing-then-writing would silently migrate the live fleet; read-compat
247
+ // happens on READ (readPeerProfile), the write must not rewrite live data. Only
248
+ // a brand-new profile (no recognizable existing value) falls to the default.
249
+ const existingNormalized =
250
+ typeof existing.intelligence === 'string' ? normalizeIntelligenceValue(existing.intelligence) : undefined
251
+ const existingIntelligenceRaw = existingNormalized !== undefined ? (existing.intelligence as string) : undefined
252
+ // Raw-preserve symmetrically to the registry boundary: an asserted intelligence adopts
253
+ // a NEW raw ONLY when it changes the NATURE. If it re-asserts the existing nature (e.g.
254
+ // ensurePeerProfile's merge-runtimes rewrite or renamePeer, both carrying the
255
+ // read-normalized value), keep the existing legacy raw verbatim — the write must never
256
+ // migrate live data; read-compat happens on READ (readPeerProfile).
257
+ const assertedNormalized =
258
+ profile.intelligence !== undefined ? normalizeIntelligenceValue(profile.intelligence) : undefined
259
+ const intelligence: string =
260
+ assertedNormalized !== undefined
261
+ ? existingIntelligenceRaw !== undefined && assertedNormalized === existingNormalized
262
+ ? existingIntelligenceRaw
263
+ : (profile.intelligence as string)
264
+ : existingIntelligenceRaw ?? defaultIntelligenceForRuntime(profile.runtime)
265
+
266
+ const explicitDescription = profile.description?.trim() ? profile.description.trim() : undefined
267
+ const existingDescription =
268
+ typeof existing.description === 'string' ? existing.description : undefined
269
+ const description = explicitDescription ?? existingDescription ?? ''
270
+
271
+ const merged: Record<string, unknown> = {
272
+ ...existing,
273
+ personality: profile.personality,
274
+ runtime: profile.runtime,
275
+ runtimes: profile.runtimes,
276
+ description,
277
+ intelligence,
278
+ ...(profile.interfaces ? { interfaces: profile.interfaces } : {}),
279
+ }
280
+ // C2 — initial_prompt: an explicit value in the write wins; otherwise the existing
281
+ // on-disk value is preserved verbatim by `...existing` (a caller that doesn't own
282
+ // it never wipes it). Only a non-empty string sets it.
283
+ if (typeof profile.initial_prompt === 'string' && profile.initial_prompt) {
284
+ merged.initial_prompt = profile.initial_prompt
285
+ }
286
+ writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}\n`)
287
+ }
288
+
289
+ // ─────────────────────────────────────────────────────────────────────────────
290
+ // cwd canonicalization (identity comparison)
291
+ // ─────────────────────────────────────────────────────────────────────────────
292
+
293
+ function canonicalCwd(p: string): string {
294
+ const resolved = resolve(p)
295
+ try {
296
+ return realpathSync.native(resolved)
297
+ } catch {
298
+ return resolved
299
+ }
300
+ }
301
+
302
+ export function sameCwd(a: string, b: string): boolean {
303
+ const ca = canonicalCwd(a)
304
+ const cb = canonicalCwd(b)
305
+ if (ca === cb) return true
306
+ return ca.toLowerCase() === cb.toLowerCase()
307
+ }
308
+
309
+ function peerCollision(
310
+ personality: string,
311
+ cwd: string,
312
+ peers: readonly KnownPeerForProfile[],
313
+ ): KnownPeerForProfile | null {
314
+ return peers.find(peer => peer.personality === personality && !sameCwd(peer.cwd, cwd)) ?? null
315
+ }
316
+
317
+ /**
318
+ * The personality derived from basename(cwd), validated and checked for collision
319
+ * — FAIL-CLOSED, never silently suffixed. Contract (docs/Идентичность, Уникальность):
320
+ * "занятое имя у ДРУГОГО живого cwd → fail closed (оператору «переименуй папку»),
321
+ * без молчаливого авто-суффикса. Одна личность — одна папка." A silent `-2` suffix
322
+ * would split one logical identity across two folders (the very "раздвоение" the
323
+ * 1:1 personality↔cwd invariant forbids) and bind the peer to a name the operator
324
+ * never chose. So a collision is surfaced LOUDLY: the operator renames the folder
325
+ * (mv cwd → recompute) to resolve it deliberately.
326
+ */
327
+ function chooseUniquePersonality(
328
+ base: string,
329
+ cwd: string,
330
+ peers: readonly KnownPeerForProfile[],
331
+ ): string {
332
+ if (!NAME_RE.test(base)) {
333
+ throw new IapError(
334
+ `cannot derive peer personality from cwd basename "${basename(cwd)}"; create ${IAPEER_DIR}/${PEER_PROFILE_FILE} explicitly`,
335
+ )
336
+ }
337
+ const collision = peerCollision(base, cwd, peers)
338
+ if (collision) {
339
+ throw new IapError(
340
+ `personality "${base}" (from cwd basename) already belongs to ${collision.cwd}; ` +
341
+ `personality ↔ cwd is 1:1 — rename this folder (mv) so its basename normalizes to a free name, then re-init. No silent auto-suffix.`,
342
+ )
343
+ }
344
+ return base
345
+ }
346
+
347
+ // ─────────────────────────────────────────────────────────────────────────────
348
+ // Per-process runtime / identity resolution (runtime adapters; cwd-bound)
349
+ // ─────────────────────────────────────────────────────────────────────────────
350
+
351
+ function detectRuntimeFromEnv(env: NodeJS.ProcessEnv): Runtime | null {
352
+ if (env.CODEX_THREAD_ID || env.CODEX_SANDBOX) return 'codex'
353
+ if (env.CLAUDECODE === '1' || env.CLAUDE_CODE_SESSION_ID) return 'claude'
354
+ return null
355
+ }
356
+
357
+ function runtimeFromPeerIdentity(identity: string | undefined): Runtime | null {
358
+ const value = identity?.trim()
359
+ if (!value) return null
360
+ const dash = value.indexOf('-')
361
+ if (dash <= 0) return null
362
+ const candidate = value.slice(0, dash)
363
+ return isRuntime(candidate) ? candidate : null
364
+ }
365
+
366
+ export function resolveRuntime(
367
+ profile: PeerProfile | null,
368
+ env: NodeJS.ProcessEnv = process.env,
369
+ ): Runtime {
370
+ const identityRuntime = runtimeFromPeerIdentity(env.PEER_IDENTITY)
371
+ if (identityRuntime) return identityRuntime
372
+ if (profile?.runtime) return profile.runtime
373
+ if (env.PEER_RUNTIME?.trim()) return validateRuntime(env.PEER_RUNTIME, 'PEER_RUNTIME')
374
+ const detected = detectRuntimeFromEnv(env)
375
+ if (detected) return detected
376
+ throw new IapError(
377
+ `cannot resolve runtime, set PEER_RUNTIME or create ${IAPEER_DIR}/${PEER_PROFILE_FILE}`,
378
+ )
379
+ }
380
+
381
+ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfile {
382
+ const cwd = resolve(options.cwd ?? process.cwd())
383
+ const peers = options.peers ?? []
384
+ ensureLocalIapScaffold(cwd)
385
+ const discoveredRuntimes = discoverPeerRuntimes(cwd, options.runtime)
386
+ const existing = readPeerProfile(cwd)
387
+ if (!existing) {
388
+ let personality: string
389
+ if (options.personality !== undefined) {
390
+ personality = validatePersonality(options.personality, 'personality')
391
+ const collision = peerCollision(personality, cwd, peers)
392
+ if (collision) {
393
+ throw new IapError(
394
+ `personality "${personality}" already belongs to ${collision.cwd}; choose another`,
395
+ )
396
+ }
397
+ } else {
398
+ // FAIL-CLOSED on collision (no silent auto-suffix) — chooseUniquePersonality
399
+ // throws when basename(cwd) normalizes to a name another cwd already holds.
400
+ personality = chooseUniquePersonality(normalizeNameCandidate(basename(cwd)), cwd, peers)
401
+ }
402
+ // Audit #20: honor the PEER_PERSONALITY env gate on the NEW-profile branch too (it
403
+ // existed only on the existing-profile branch) — a mismatched env identity must not
404
+ // silently create a profile under a different personality.
405
+ if (
406
+ options.env?.PEER_PERSONALITY?.trim() &&
407
+ normalizeNameCandidate(options.env.PEER_PERSONALITY) !== personality
408
+ ) {
409
+ throw new IapError(
410
+ `PEER_PERSONALITY "${options.env.PEER_PERSONALITY}" does not match the peer being initialized ("${personality}")`,
411
+ )
412
+ }
413
+ const profile: PeerProfile = {
414
+ personality,
415
+ runtime: options.runtime,
416
+ runtimes: discoveredRuntimes,
417
+ description: '',
418
+ intelligence: defaultIntelligenceForRuntime(options.runtime),
419
+ }
420
+ ensureLocalRuntimeScopes(cwd, profile.runtimes)
421
+ // INFRA runtime → provision the always-on launchd plist that holds it live.
422
+ // BEFORE writing the profile so a collision-guard refusal (the chosen
423
+ // com.iapeer.<personality> Label already belongs to a foreign / PP-managed
424
+ // plist) fails the provision LOUDLY and leaves no half-created peer-profile.json
425
+ // behind — instead of silently clobbering a live persistent-peer's plist (H4).
426
+ // Warm-on-demand runtimes (claude/codex) are daemon-managed → no plist (unchanged).
427
+ if (isInfraRuntime(options.runtime)) {
428
+ installAlwaysOnPlist({
429
+ personality,
430
+ runtime: options.runtime,
431
+ cwd,
432
+ runtimeBin: options.runtimeBin,
433
+ env: options.env,
434
+ })
435
+ }
436
+ writePeerProfileAtomic(cwd, profile)
437
+ return profile
438
+ }
439
+
440
+ const collision = peerCollision(existing.personality, cwd, peers)
441
+ if (collision) {
442
+ throw new IapError(
443
+ `personality collision: "${existing.personality}" already belongs to ${collision.cwd}; change ${IAPEER_DIR}/${PEER_PROFILE_FILE}`,
444
+ )
445
+ }
446
+ if (
447
+ options.env?.PEER_PERSONALITY?.trim() &&
448
+ normalizeNameCandidate(options.env.PEER_PERSONALITY) !== existing.personality
449
+ ) {
450
+ throw new IapError(
451
+ `PEER_PERSONALITY must match ${IAPEER_DIR}/${PEER_PROFILE_FILE} personality "${existing.personality}", got "${options.env.PEER_PERSONALITY}"`,
452
+ )
453
+ }
454
+ const mergedRuntimes = uniqueRuntimes([...existing.runtimes, ...discoveredRuntimes])
455
+ ensureLocalRuntimeScopes(cwd, mergedRuntimes)
456
+ if (mergedRuntimes.length !== existing.runtimes.length) {
457
+ const updated = { ...existing, runtimes: mergedRuntimes }
458
+ writePeerProfileAtomic(cwd, updated)
459
+ return updated
460
+ }
461
+ return existing
462
+ }
463
+
464
+ function assertPeerIdentity(env: NodeJS.ProcessEnv, address: string): void {
465
+ if (!env.PEER_IDENTITY?.trim()) return
466
+ if (env.PEER_IDENTITY !== address) {
467
+ throw new IapError(
468
+ `PEER_IDENTITY must equal PEER_RUNTIME + "-" + PEER_PERSONALITY (${address}), got "${env.PEER_IDENTITY}"`,
469
+ )
470
+ }
471
+ }
472
+
473
+ export function resolveIdentity(options: ResolveIdentityOptions = {}): Identity {
474
+ const cwd = resolve(options.cwd ?? process.cwd())
475
+ const env = options.env ?? process.env
476
+ const profile = options.profile ?? readPeerProfile(cwd)
477
+ if (!profile) {
478
+ throw new IapError(
479
+ `cannot resolve identity without ${IAPEER_DIR}/${PEER_PROFILE_FILE}; run from an initialized peer cwd`,
480
+ )
481
+ }
482
+ if (
483
+ env.PEER_PERSONALITY?.trim() &&
484
+ normalizeNameCandidate(env.PEER_PERSONALITY) !== profile.personality
485
+ ) {
486
+ throw new IapError(
487
+ `PEER_PERSONALITY must match ${IAPEER_DIR}/${PEER_PROFILE_FILE} personality "${profile.personality}", got "${env.PEER_PERSONALITY}"`,
488
+ )
489
+ }
490
+ const runtime = resolveRuntime(profile, env)
491
+ const personality = profile.personality
492
+ const address = buildProcessAddress(runtime, personality)
493
+ assertPeerIdentity(env, address)
494
+ return {
495
+ personality,
496
+ runtime,
497
+ address,
498
+ description: profile.description,
499
+ intelligence: profile.intelligence,
500
+ cwd,
501
+ profilePath: peerProfilePath(cwd),
502
+ profile,
503
+ }
504
+ }
505
+
506
+ // ─────────────────────────────────────────────────────────────────────────────
507
+ // Per-REQUEST identity resolution (daemon) — NO process.cwd(), NO local profile
508
+ // ─────────────────────────────────────────────────────────────────────────────
509
+
510
+ export interface ResolvedCaller {
511
+ personality: string
512
+ runtime: Runtime
513
+ address: `${Runtime}-${string}`
514
+ description: string
515
+ intelligence: Intelligence
516
+ cwd: string
517
+ record: PeerRecord
518
+ }
519
+
520
+ /**
521
+ * Resolve a caller identity carried IN THE REQUEST (X-IAPeer-Identity), against
522
+ * the registry only. Deliberately does NOT read process.cwd(), process.env, or
523
+ * the local peer-profile.json — the always-on daemon has no single cwd, so the
524
+ * caller MUST carry its own identity (blueprint §0.2, §5.1). The registry record
525
+ * is the authority for cwd/description/intelligence/runtimes.
526
+ *
527
+ * Spoofing guard (minimum, per blueprint §5.1): the personality must exist in
528
+ * the registry and the runtime must be one it declares.
529
+ */
530
+ export function resolveCallerIdentity(
531
+ caller: CallerIdentity,
532
+ index: PeersIndex = readPeersIndex(),
533
+ ): ResolvedCaller {
534
+ if (!isValidName(caller.personality)) {
535
+ throw new IapError(
536
+ `invalid caller personality "${caller.personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`,
537
+ )
538
+ }
539
+ const runtime = validateRuntime(caller.runtime, 'caller runtime')
540
+ const record = findPeer(index, caller.personality)
541
+ if (!record) {
542
+ throw new IapError(`unknown caller "${caller.personality}" — not registered in peers-profiles.json`)
543
+ }
544
+ const declared = record.runtime === runtime || record.runtimes.includes(runtime)
545
+ if (!declared) {
546
+ throw new IapError(
547
+ `runtime "${runtime}" is not declared for caller "${caller.personality}" (declared: ${record.runtimes.join(', ')})`,
548
+ )
549
+ }
550
+ return {
551
+ personality: record.personality,
552
+ runtime,
553
+ address: buildProcessAddress(runtime, record.personality),
554
+ description: record.description,
555
+ intelligence: record.intelligence,
556
+ cwd: record.cwd,
557
+ record,
558
+ }
559
+ }
560
+
561
+ // ─────────────────────────────────────────────────────────────────────────────
562
+ // renamePeer — cross-cutting (profile + registry), orchestrated above registry
563
+ // so the registry layer stays free of an identity import. The profile rewrite
564
+ // happens INSIDE the registry lock for atomicity.
565
+ // ─────────────────────────────────────────────────────────────────────────────
566
+
567
+ export async function renamePeer(
568
+ oldPersonality: string,
569
+ newPersonality: string,
570
+ options: PeersUpdateOptions = {},
571
+ ): Promise<PeersIndex> {
572
+ if (!isValidName(oldPersonality) || !isValidName(newPersonality)) {
573
+ throw new IapError('peers rename requires valid personalities')
574
+ }
575
+ if (oldPersonality === newPersonality) {
576
+ throw new IapError('peers rename requires distinct old and new personality')
577
+ }
578
+ return updatePeersIndex(index => {
579
+ const target = index.peers.find(peer => peer.personality === oldPersonality)
580
+ if (!target) throw new IapError(`peer "${oldPersonality}" not found`)
581
+ if (index.peers.some(peer => peer.personality === newPersonality)) {
582
+ throw new IapError(`peer "${newPersonality}" already exists`)
583
+ }
584
+ const sourceProfile = readPeerProfile(target.cwd)
585
+ if (!sourceProfile) {
586
+ throw new IapError(
587
+ `peer "${oldPersonality}" cwd ${target.cwd} has no ${IAPEER_DIR}/${PEER_PROFILE_FILE}; restore the cwd or remove the registry entry`,
588
+ )
589
+ }
590
+ if (sourceProfile.personality !== oldPersonality) {
591
+ throw new IapError(
592
+ `peer "${oldPersonality}" cwd ${target.cwd} profile has personality "${sourceProfile.personality}", not "${oldPersonality}"; registry out of sync`,
593
+ )
594
+ }
595
+ writePeerProfileAtomic(target.cwd, { ...sourceProfile, personality: newPersonality })
596
+ return {
597
+ ...index,
598
+ peers: index.peers.map(peer =>
599
+ peer.personality === oldPersonality ? { ...peer, personality: newPersonality } : peer,
600
+ ),
601
+ }
602
+ }, options)
603
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // @agfpd/iapeer — foundation core.
2
+ // Ф0 data layer:
3
+ export * from './core/index.ts'
4
+ export * from './storage/index.ts'
5
+ export * from './codec/index.ts'
6
+ export * from './registry/index.ts'
7
+ export * from './identity/index.ts'
8
+ // Ф1 transport + HTTP-MCP router daemon:
9
+ export * from './transport/index.ts'
10
+ export * from './daemon/index.ts'
11
+ // Ф2 lifecycle (wake-on-miss / supervise / reap):
12
+ export * from './lifecycle/index.ts'
13
+ // Ф3 launch primitive + runtime adapters + composeSystemPrompt:
14
+ export * from './launch/index.ts'
15
+ // Daemon production main — composition point (wake + supervise) + daemon plist.
16
+ // Last: it wires daemon/index ⇆ lifecycle ⇆ launch (the top of the dependency graph).
17
+ export * from './daemon/main.ts'
18
+ // Provision — one-call peer creation (identity + registry + infra plist).
19
+ export * from './provision/index.ts'
20
+ // Init — per-peer onboarding (provision + HTTP-MCP .mcp.json wiring + doctrine template).
21
+ export * from './init/index.ts'
22
+ // Install — the foundation install-phase (stable ~/.local/bin/iapeer, decoupled from src).
23
+ export * from './install/index.ts'
24
+ // Onboard — the host-phase (idempotent marketplace registration in claude + codex).
25
+ export * from './onboard/index.ts'
26
+ export { composeSystemPrompt, gatherPromptInput } from './launch/composeSystemPrompt.ts'
27
+ export type { GatherPromptOptions } from './launch/composeSystemPrompt.ts'