@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,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
|
+
}
|