@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,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'
|