@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,453 @@
1
+ // Registry — the global peers-profiles.json index. THE single locked writer.
2
+ // Consolidated from inter-agent-protocol/src/lib/peers.ts (wins) +
3
+ // schema-migrations.ts (inlined), with the H1 blueprint-v2 fix applied to
4
+ // upsertPeer (merge-with-existing, not full-replace).
5
+ //
6
+ // Structural invariant (#3): the ONLY function that writes peers-profiles.json
7
+ // is the module-private `writePeersIndexAtomic`, reached ONLY through
8
+ // `withPeersLock`. storage.writeFileAtomic refuses the peers basename, so there
9
+ // is no unlocked path to the registry file anywhere in the package.
10
+
11
+ import * as lockfile from 'proper-lockfile'
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
13
+ import { homedir } from 'os'
14
+ import { join } from 'path'
15
+ import { randomUUID } from 'crypto'
16
+ import {
17
+ IAPEER_DIR,
18
+ MAX_DESCRIPTION_LEN,
19
+ PEERS_PROFILES_FILE,
20
+ PEERS_SCHEMA_VERSION,
21
+ defaultIntelligenceForRuntime,
22
+ isIntelligence,
23
+ isRuntime,
24
+ isValidName,
25
+ normalizeIntelligenceValue,
26
+ type Intelligence,
27
+ type Runtime,
28
+ } from '../core/constants.ts'
29
+ import { IapError } from '../core/errors.ts'
30
+ import { resolvePeersPaths, type PeersPaths, type StorageOptions } from '../storage/index.ts'
31
+
32
+ export interface PeerRecord {
33
+ personality: string
34
+ runtime: Runtime
35
+ runtimes: Runtime[]
36
+ description: string
37
+ /** NORMALIZED intelligence (contract artificial/natural/absent) — for foundation
38
+ * logic (the launch nature-gate, publicPeerSummary projection, …). NOT what is
39
+ * persisted: the raw on-disk value is preserved via intelligenceRaw. */
40
+ intelligence: Intelligence
41
+ /**
42
+ * The RAW on-disk intelligence string, preserved VERBATIM so a read→write round-trip
43
+ * never mutates the live registry's vocabulary (the foundation read-compat maps
44
+ * legacy human→natural / scripted→absent IN MEMORY, but persisting the mapped value
45
+ * would corrupt the legacy IAP, which only knows human/artificial/scripted — that is
46
+ * exactly how a foundation registry write broke the live transport). writePeersIndex
47
+ * Atomic persists `intelligenceRaw ?? intelligence`. Symmetric to the peer-profile H1
48
+ * preserve-verbatim write. Absent ⇒ a healed default is persisted.
49
+ */
50
+ intelligenceRaw?: string
51
+ cwd: string
52
+ interfaces?: PeerInterfaces
53
+ }
54
+
55
+ export type PeerInterfaces = Record<string, unknown>
56
+
57
+ export interface PeersIndex {
58
+ version: number
59
+ peers: PeerRecord[]
60
+ }
61
+
62
+ export interface PeersUpdateOptions extends StorageOptions {
63
+ warn?: (message: string) => void
64
+ }
65
+
66
+ /**
67
+ * The normalized PUBLIC projection of a peer — exactly the five discovery fields
68
+ * (personality / runtime / runtimes / description / intelligence). NO cwd,
69
+ * interfaces, or audit fields. This is the ONE shared normalizer the contract
70
+ * mandates ("форма — общая функция нормализации `publicPeerSummary`"): it feeds
71
+ * BOTH the send_to_peer tool description (daemon) AND the registry layer (Слой 3)
72
+ * of the composed system prompt, so the two can never drift.
73
+ */
74
+ export interface PublicPeerSummary {
75
+ personality: string
76
+ runtime: Runtime
77
+ runtimes: Runtime[]
78
+ description: string
79
+ intelligence: Intelligence
80
+ }
81
+
82
+ export function publicPeerSummary(peer: PeerRecord): PublicPeerSummary {
83
+ return {
84
+ personality: peer.personality,
85
+ runtime: peer.runtime,
86
+ runtimes: peer.runtimes,
87
+ description: peer.description,
88
+ intelligence: peer.intelligence,
89
+ }
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+ // Schema migration (was schema-migrations.ts)
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+
96
+ export function migratePeersIndex(index: PeersIndex): PeersIndex {
97
+ if (index.version === PEERS_SCHEMA_VERSION) return index
98
+ if (index.version < PEERS_SCHEMA_VERSION) {
99
+ return { ...index, version: PEERS_SCHEMA_VERSION }
100
+ }
101
+ return index
102
+ }
103
+
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+ // Validation / normalization
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+
108
+ export function emptyPeersIndex(): PeersIndex {
109
+ return { version: PEERS_SCHEMA_VERSION, peers: [] }
110
+ }
111
+
112
+ export function clampDescription(value: string): { description: string; truncated: boolean } {
113
+ if (value.length <= MAX_DESCRIPTION_LEN) return { description: value, truncated: false }
114
+ return { description: value.slice(0, MAX_DESCRIPTION_LEN), truncated: true }
115
+ }
116
+
117
+ function ensurePersonality(personality: string): void {
118
+ if (!isValidName(personality)) {
119
+ throw new IapError(
120
+ `invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`,
121
+ )
122
+ }
123
+ }
124
+
125
+ function ensureRuntime(runtime: string): Runtime {
126
+ if (!isRuntime(runtime)) {
127
+ throw new IapError(`invalid runtime "${runtime}" — must match /^[a-z][a-z0-9]{0,31}$/`)
128
+ }
129
+ return runtime
130
+ }
131
+
132
+ function ensureRuntimes(runtimes: readonly string[], runtime: Runtime): Runtime[] {
133
+ const out: Runtime[] = []
134
+ for (const value of [runtime, ...runtimes]) {
135
+ const checked = ensureRuntime(value)
136
+ if (!out.includes(checked)) out.push(checked)
137
+ }
138
+ return out
139
+ }
140
+
141
+ function normalizeInterfaces(raw: unknown, peerName: string): PeerInterfaces | undefined {
142
+ if (raw === undefined) return undefined
143
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
144
+ throw new IapError(`peers-profiles.json corrupted: peer "${peerName}" interfaces must be an object`)
145
+ }
146
+ return raw as PeerInterfaces
147
+ }
148
+
149
+ function normalizePeer(raw: unknown): PeerRecord {
150
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
151
+ throw new IapError('peers-profiles.json corrupted: peer entry is not an object')
152
+ }
153
+ const obj = raw as Record<string, unknown>
154
+ if (typeof obj.personality !== 'string' || !isValidName(obj.personality)) {
155
+ throw new IapError(
156
+ `peers-profiles.json corrupted: peer personality must match /^[a-z][a-z0-9-]{0,31}$/, got "${String(
157
+ obj.personality ?? '',
158
+ )}"`,
159
+ )
160
+ }
161
+ if (!isRuntime(obj.runtime)) {
162
+ throw new IapError(
163
+ `peers-profiles.json corrupted: peer "${obj.personality}" runtime must match /^[a-z][a-z0-9]{0,31}$/`,
164
+ )
165
+ }
166
+ const runtimes = Array.isArray(obj.runtimes)
167
+ ? ensureRuntimes(obj.runtimes.filter(item => typeof item === 'string') as string[], obj.runtime)
168
+ : [obj.runtime]
169
+ const { description } = clampDescription(typeof obj.description === 'string' ? obj.description : '')
170
+ if (typeof obj.cwd !== 'string' || !obj.cwd.trim()) {
171
+ throw new IapError(`peers-profiles.json corrupted: peer "${obj.personality}" cwd is required`)
172
+ }
173
+ // Legacy/soft pre-migration: a missing/empty intelligence is healed to the
174
+ // runtime default. A present value is READ-COMPAT normalized (contract
175
+ // artificial/natural/absent, legacy human→natural / scripted→absent) so the
176
+ // foundation reads the live registry correctly before the data migration.
177
+ let intelligence: Intelligence
178
+ let intelligenceRaw: string | undefined
179
+ if (obj.intelligence === undefined || obj.intelligence === null || obj.intelligence === '') {
180
+ intelligence = defaultIntelligenceForRuntime(obj.runtime)
181
+ // no raw on disk → a healed default will be persisted
182
+ } else {
183
+ const normalized = normalizeIntelligenceValue(obj.intelligence)
184
+ if (!normalized) {
185
+ throw new IapError(
186
+ `peers-profiles.json corrupted: peer "${obj.personality}" intelligence must be one of artificial|natural|absent (legacy human|scripted accepted), got "${String(obj.intelligence)}"`,
187
+ )
188
+ }
189
+ intelligence = normalized
190
+ intelligenceRaw = obj.intelligence as string // PRESERVE the on-disk vocab verbatim (legacy-safe)
191
+ }
192
+ const interfaces = normalizeInterfaces(obj.interfaces, obj.personality)
193
+ return {
194
+ personality: obj.personality,
195
+ runtime: obj.runtime,
196
+ runtimes,
197
+ description,
198
+ intelligence,
199
+ ...(intelligenceRaw !== undefined ? { intelligenceRaw } : {}),
200
+ cwd: obj.cwd,
201
+ ...(interfaces ? { interfaces } : {}),
202
+ }
203
+ }
204
+
205
+ export function normalizePeersIndex(raw: unknown): PeersIndex {
206
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
207
+ throw new IapError('peers-profiles.json corrupted, restore from backup or delete to start fresh')
208
+ }
209
+ const obj = raw as Record<string, unknown>
210
+ const version =
211
+ typeof obj.version === 'number' && Number.isInteger(obj.version) ? obj.version : PEERS_SCHEMA_VERSION
212
+ const peersRaw = Array.isArray(obj.peers) ? obj.peers : []
213
+ const personalities = new Set<string>()
214
+ const peers = peersRaw.map(normalizePeer)
215
+ for (const peer of peers) {
216
+ if (personalities.has(peer.personality)) {
217
+ throw new IapError(`peers-profiles.json corrupted: duplicate peer "${peer.personality}"`)
218
+ }
219
+ personalities.add(peer.personality)
220
+ }
221
+ return migratePeersIndex({ version, peers })
222
+ }
223
+
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+ // Read
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+
228
+ export function readPeersIndex(options: StorageOptions = {}): PeersIndex {
229
+ const { peersFile } = resolvePeersPaths(options)
230
+ if (!existsSync(peersFile)) return emptyPeersIndex()
231
+ let raw: unknown
232
+ try {
233
+ raw = JSON.parse(readFileSync(peersFile, 'utf8'))
234
+ } catch (e) {
235
+ throw new IapError(
236
+ `peers-profiles.json corrupted, restore from backup or delete to start fresh: ${
237
+ e instanceof Error ? e.message : String(e)
238
+ }`,
239
+ )
240
+ }
241
+ const index = normalizePeersIndex(raw)
242
+ if (index.version > PEERS_SCHEMA_VERSION) {
243
+ process.stderr.write(
244
+ `iapeer: warning: peers-profiles.json version ${index.version} is newer than supported ${PEERS_SCHEMA_VERSION}; reading known fields only\n`,
245
+ )
246
+ }
247
+ return index
248
+ }
249
+
250
+ export function findPeer(index: PeersIndex, personality: string): PeerRecord | null {
251
+ return index.peers.find(peer => peer.personality === personality) ?? null
252
+ }
253
+
254
+ // ─────────────────────────────────────────────────────────────────────────────
255
+ // Locked write (THE single writer)
256
+ // ─────────────────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Fail-closed test/sandbox isolation (incident 2026-06-08): a test or sandbox
260
+ * run that resolves the registry to the REAL `~/.iapeer` would normalize the
261
+ * live fleet's vocab on the next write (the incident that broke legacy IAP).
262
+ * When IAPEER_TEST_SANDBOX=1, refuse to write the real root — the harness MUST
263
+ * target an isolated IAPEER_ROOT. The real root is recomputed here from HOME
264
+ * (ignoring the IAPEER_ROOT override) precisely so an unset/forgotten override
265
+ * cannot silently fall through to it.
266
+ */
267
+ function assertSandboxIsolated(rootDir: string, env: NodeJS.ProcessEnv): void {
268
+ if (env.IAPEER_TEST_SANDBOX !== '1') return
269
+ const realRoot = join(env.HOME?.trim() || homedir(), IAPEER_DIR)
270
+ if (rootDir === realRoot) {
271
+ throw new IapError(
272
+ `refusing to write the REAL registry (${realRoot}) under IAPEER_TEST_SANDBOX=1 — ` +
273
+ 'a test/sandbox must set IAPEER_ROOT to an isolated path',
274
+ )
275
+ }
276
+ }
277
+
278
+ export async function withPeersLock<T>(
279
+ options: StorageOptions,
280
+ fn: (paths: PeersPaths) => T | Promise<T>,
281
+ ): Promise<T> {
282
+ if (process.platform === 'win32') {
283
+ throw new IapError('POSIX tmux transport required, Windows support deferred')
284
+ }
285
+ const paths = resolvePeersPaths(options)
286
+ assertSandboxIsolated(paths.rootDir, options.env ?? process.env)
287
+ mkdirSync(paths.rootDir, { recursive: true, mode: 0o700 })
288
+ writeFileSync(paths.lockTarget, '', { flag: 'a', mode: 0o600 })
289
+ const release = await lockfile.lock(paths.lockTarget, {
290
+ realpath: false,
291
+ stale: 10_000,
292
+ update: 1_000,
293
+ retries: { retries: 13, factor: 1.4, minTimeout: 50, maxTimeout: 500 },
294
+ })
295
+ try {
296
+ return await fn(paths)
297
+ } finally {
298
+ await release()
299
+ }
300
+ }
301
+
302
+ /**
303
+ * PRIVATE registry writer for peers-profiles.json. Not exported. Reached ONLY
304
+ * from `updatePeersIndex` inside `withPeersLock`. Does its own atomic rename
305
+ * (tmp alongside target) and deliberately does NOT route through
306
+ * storage.writeFileAtomic (which refuses this basename) — this is the single
307
+ * sanctioned write path for the registry file (#3).
308
+ */
309
+ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
310
+ const tmp = join(paths.tmpDir, `.${PEERS_PROFILES_FILE}.${process.pid}.${randomUUID()}.tmp`)
311
+ // Persist the RAW intelligence verbatim (legacy-safe round-trip) and DROP the
312
+ // intelligenceRaw shadow field from the on-disk shape — the file carries the
313
+ // legacy vocab the live IAP reads, never the foundation's in-memory normalization.
314
+ const peersForDisk = [...index.peers]
315
+ .sort((a, b) => a.personality.localeCompare(b.personality))
316
+ .map(({ intelligenceRaw, ...peer }) => ({ ...peer, intelligence: intelligenceRaw ?? peer.intelligence }))
317
+ const normalized = { version: PEERS_SCHEMA_VERSION, peers: peersForDisk }
318
+ writeFileSync(tmp, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
319
+ renameSync(tmp, paths.peersFile)
320
+ }
321
+
322
+ export async function updatePeersIndex(
323
+ updater: (index: PeersIndex) => PeersIndex,
324
+ options: PeersUpdateOptions = {},
325
+ ): Promise<PeersIndex> {
326
+ return withPeersLock(options, paths => {
327
+ const current = readPeersIndex({ ...options, rootDir: paths.rootDir })
328
+ const next = updater(current)
329
+ writePeersIndexAtomic(paths, next)
330
+ return next
331
+ })
332
+ }
333
+
334
+ // ─────────────────────────────────────────────────────────────────────────────
335
+ // upsertPeer — H1 merge-with-existing (blueprint-v2 §H1)
336
+ // ─────────────────────────────────────────────────────────────────────────────
337
+
338
+ export interface UpsertPeerArgs {
339
+ personality: string
340
+ runtime: string
341
+ runtimes?: readonly string[]
342
+ description?: string
343
+ intelligence?: Intelligence
344
+ interfaces?: PeerInterfaces
345
+ cwd: string
346
+ }
347
+
348
+ export async function upsertPeer(
349
+ args: UpsertPeerArgs,
350
+ options: PeersUpdateOptions = {},
351
+ ): Promise<PeersIndex> {
352
+ ensurePersonality(args.personality)
353
+ const runtime = ensureRuntime(args.runtime)
354
+
355
+ // Validate an explicitly-supplied intelligence eagerly (fail before locking). Accept
356
+ // BOTH the contract vocab (artificial/natural/absent) AND the legacy vocab the READ
357
+ // path accepts (human/scripted): a caller forwarding a raw legacy value must not be
358
+ // rejected. normalizeIntelligenceValue maps it to the contract value (used for the
359
+ // `intelligence` field); the raw is preserved verbatim below.
360
+ let argsIntelligence: Intelligence | undefined
361
+ let argsIntelligenceRaw: string | undefined
362
+ if (args.intelligence !== undefined) {
363
+ const normalized = normalizeIntelligenceValue(args.intelligence)
364
+ if (!normalized) {
365
+ throw new IapError(
366
+ `invalid intelligence "${String(args.intelligence)}" — must be one of artificial|natural|absent (legacy human|scripted accepted)`,
367
+ )
368
+ }
369
+ argsIntelligence = normalized
370
+ argsIntelligenceRaw = String(args.intelligence)
371
+ }
372
+
373
+ // Clamp an explicitly-supplied description (and warn) before the lock. An
374
+ // empty / whitespace-only description is treated as "not provided" so a boot
375
+ // upsert carrying an empty profile.description does NOT wipe a meaningful
376
+ // existing registry description (blueprint-v2 §H1 note). To deliberately clear
377
+ // a description, a caller removes the peer and re-adds it — not by passing ''.
378
+ let argsDescription: string | undefined
379
+ if (args.description !== undefined && args.description.trim()) {
380
+ const { description, truncated } = clampDescription(args.description)
381
+ if (truncated) options.warn?.(`description exceeded ${MAX_DESCRIPTION_LEN} chars and was truncated`)
382
+ argsDescription = description
383
+ }
384
+
385
+ return updatePeersIndex(index => {
386
+ const existing = index.peers.find(peer => peer.personality === args.personality)
387
+
388
+ // H1 merge-with-existing: a field ABSENT from args inherits from `existing`,
389
+ // NOT from a runtime default. The runtime default applies ONLY to a brand-new
390
+ // peer (no existing). This is what stops a claude-boot upsert (no intelligence)
391
+ // from downgrading a human telegram peer to artificial.
392
+ const intelligence: Intelligence =
393
+ argsIntelligence !== undefined
394
+ ? argsIntelligence
395
+ : existing?.intelligence ?? defaultIntelligenceForRuntime(runtime)
396
+ // Preserve the on-disk vocab. An explicit args value adopts a NEW raw ONLY when it
397
+ // changes the NATURE; if it merely re-asserts the existing peer's nature (e.g.
398
+ // provisionPeer forwarding the read-normalized 'natural' for a legacy 'human' peer
399
+ // on a routine re-init), keep the existing legacy raw verbatim. This makes the
400
+ // registry boundary self-defending: no caller can silently migrate a legacy peer's
401
+ // vocab (the exact incident that broke legacy-IAP). An upsert with no intelligence
402
+ // inherits the existing raw unchanged.
403
+ const intelligenceRaw: string | undefined =
404
+ argsIntelligence !== undefined
405
+ ? existing?.intelligenceRaw !== undefined &&
406
+ normalizeIntelligenceValue(existing.intelligenceRaw) === argsIntelligence
407
+ ? existing.intelligenceRaw
408
+ : argsIntelligenceRaw
409
+ : existing?.intelligenceRaw
410
+
411
+ const description =
412
+ argsDescription !== undefined ? argsDescription : existing?.description ?? ''
413
+
414
+ const interfaces = args.interfaces ?? existing?.interfaces
415
+
416
+ // runtimes: union of existing ∪ args ∪ {runtime} (never a replace — a peer
417
+ // already declared on telegram+claude must not lose either on a claude upsert).
418
+ const runtimes = ensureRuntimes(
419
+ [...(existing?.runtimes ?? []), ...(args.runtimes ?? [])],
420
+ runtime,
421
+ )
422
+
423
+ const record: PeerRecord = {
424
+ personality: args.personality,
425
+ runtime, // args.runtime wins — caller knows the current runtime
426
+ runtimes,
427
+ description,
428
+ intelligence,
429
+ ...(intelligenceRaw !== undefined ? { intelligenceRaw } : {}),
430
+ cwd: args.cwd, // args.cwd wins
431
+ ...(interfaces ? { interfaces } : {}),
432
+ }
433
+
434
+ if (!existing) {
435
+ return { ...index, peers: [...index.peers, record] }
436
+ }
437
+ return {
438
+ ...index,
439
+ peers: index.peers.map(peer => (peer.personality === args.personality ? record : peer)),
440
+ }
441
+ }, options)
442
+ }
443
+
444
+ export async function removePeer(
445
+ personality: string,
446
+ options: PeersUpdateOptions = {},
447
+ ): Promise<PeersIndex> {
448
+ ensurePersonality(personality)
449
+ return updatePeersIndex(
450
+ index => ({ ...index, peers: index.peers.filter(peer => peer.personality !== personality) }),
451
+ options,
452
+ )
453
+ }