@agfpd/iapeer 0.2.24 → 0.2.25
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/package.json +1 -1
- package/src/cli/index.ts +24 -0
- package/src/enable/index.ts +165 -25
- package/src/enable/memoryPlugin.test.ts +103 -0
- package/src/enable/memoryPlugin.ts +133 -0
- package/src/onboard/index.ts +55 -9
- package/src/provision/index.ts +35 -2
- package/src/provision/provision.test.ts +25 -0
- package/src/status/index.ts +31 -0
- package/src/status/status.test.ts +20 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -433,6 +433,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
433
433
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
434
434
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
435
435
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
436
|
+
memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
|
|
436
437
|
`
|
|
437
438
|
|
|
438
439
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -562,6 +563,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
562
563
|
}
|
|
563
564
|
return failed ? 1 : 0
|
|
564
565
|
}
|
|
566
|
+
case 'memory-plugin': {
|
|
567
|
+
// Slot-derived provider-plugin install/removal (контракт «Слот памяти»
|
|
568
|
+
// §Плагин провайдера; agreed iapeer-memory + boris 10.06). The plugin
|
|
569
|
+
// identity comes from the slot declaration's v1.1 plugin block — empty
|
|
570
|
+
// slot / v1 declaration → explicit refusal (generic `enable` exists for
|
|
571
|
+
// arbitrary plugins). Forms live in enable/ (ONE home); codex off is
|
|
572
|
+
// host-global (only --all removes; --peer reports it explicitly).
|
|
573
|
+
const state = positionals[0]
|
|
574
|
+
if (state !== 'on' && state !== 'off') return usage(errOut)
|
|
575
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
576
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
577
|
+
const { memoryPluginApply } = await import('../enable/memoryPlugin.ts')
|
|
578
|
+
const r = memoryPluginApply(state, { peer: peerName, all: flags.all === true, env })
|
|
579
|
+
if (r.error) {
|
|
580
|
+
errOut(`memory-plugin: ${r.error}\n`)
|
|
581
|
+
return 1
|
|
582
|
+
}
|
|
583
|
+
out(`plugin: ${r.plugin!.name}@${r.plugin!.marketplace} (provider-declared)\n`)
|
|
584
|
+
for (const o of r.outcomes) {
|
|
585
|
+
out(`${o.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
586
|
+
}
|
|
587
|
+
return r.ok ? 0 : 1
|
|
588
|
+
}
|
|
565
589
|
case 'status': {
|
|
566
590
|
// Host snapshot (контракт «Слот памяти» §status): version + daemon health +
|
|
567
591
|
// the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
|
package/src/enable/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { spawnSync } from 'child_process'
|
|
|
23
23
|
import { existsSync, readFileSync, realpathSync } from 'fs'
|
|
24
24
|
import { basename, join } from 'path'
|
|
25
25
|
import { homedir } from 'os'
|
|
26
|
-
import { MARKETPLACE_NAME } from '../onboard/index.ts'
|
|
26
|
+
import { MARKETPLACE_NAME, isMarketplaceRegisteredAs, refreshMarketplace, registerMarketplace } from '../onboard/index.ts'
|
|
27
27
|
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
28
28
|
import { IapError } from '../core/errors.ts'
|
|
29
29
|
import { normalizeNameCandidate } from '../core/normalize.ts'
|
|
@@ -214,14 +214,20 @@ function isExecutable(bin: string, env: NodeJS.ProcessEnv): boolean {
|
|
|
214
214
|
* live — the same entry lists enabled=false from another dir, true from its own), so
|
|
215
215
|
* the list MUST run with cwd = the peer cwd to read the authoritative enabled-state.
|
|
216
216
|
* maxBuffer is raised — a configured host's plugin list exceeds the 1 MB default. */
|
|
217
|
-
function claudeEntry(
|
|
217
|
+
function claudeEntry(
|
|
218
|
+
bin: string,
|
|
219
|
+
plugin: string,
|
|
220
|
+
marketplace: string,
|
|
221
|
+
peerCwd: string,
|
|
222
|
+
env: NodeJS.ProcessEnv,
|
|
223
|
+
): InstalledEntry | null {
|
|
218
224
|
const list = spawnSync(bin, ['plugin', 'list', '--json'], {
|
|
219
225
|
cwd: peerCwd,
|
|
220
226
|
encoding: 'utf8',
|
|
221
227
|
env: env as Record<string, string>,
|
|
222
228
|
maxBuffer: 64 * 1024 * 1024,
|
|
223
229
|
})
|
|
224
|
-
return findPeerScopedEntry(parseInstalledPlugins(list.stdout ?? ''), plugin,
|
|
230
|
+
return findPeerScopedEntry(parseInstalledPlugins(list.stdout ?? ''), plugin, marketplace, peerCwd)
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
/**
|
|
@@ -232,11 +238,11 @@ function claudeEntry(bin: string, plugin: string, peerCwd: string, env: NodeJS.P
|
|
|
232
238
|
* enabled"). Idempotent + fleet-safe: the entry is keyed by THIS peer's projectPath
|
|
233
239
|
* (realpath), so it never reads or mutates another peer's install.
|
|
234
240
|
*/
|
|
235
|
-
function enableClaude(plugin: string, peerCwd: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
241
|
+
function enableClaude(plugin: string, marketplace: string, peerCwd: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
236
242
|
const bin = claudeBin(env)
|
|
237
243
|
if (!isExecutable(bin, env)) return { runtime: 'claude', state: 'runtime-missing' }
|
|
238
|
-
const id = `${plugin}@${
|
|
239
|
-
const existing = claudeEntry(bin, plugin, peerCwd, env)
|
|
244
|
+
const id = `${plugin}@${marketplace}`
|
|
245
|
+
const existing = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
240
246
|
if (existing?.enabled) return { runtime: 'claude', state: 'already-enabled', installPath: existing.installPath }
|
|
241
247
|
|
|
242
248
|
if (!existing) {
|
|
@@ -251,14 +257,14 @@ function enableClaude(plugin: string, peerCwd: string, env: NodeJS.ProcessEnv):
|
|
|
251
257
|
}
|
|
252
258
|
// the entry now exists but is DISABLED (fresh install) or was pre-existing-disabled →
|
|
253
259
|
// enable it (and ONLY then; an already-enabled entry would error).
|
|
254
|
-
const after = claudeEntry(bin, plugin, peerCwd, env)
|
|
260
|
+
const after = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
255
261
|
if (after && !after.enabled) {
|
|
256
262
|
const en = spawnSync(bin, ['plugin', 'enable', id, '--scope', 'project'], {
|
|
257
263
|
cwd: peerCwd,
|
|
258
264
|
encoding: 'utf8',
|
|
259
265
|
env: env as Record<string, string>,
|
|
260
266
|
})
|
|
261
|
-
const confirmed = claudeEntry(bin, plugin, peerCwd, env)
|
|
267
|
+
const confirmed = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
262
268
|
if (!confirmed?.enabled) {
|
|
263
269
|
return { runtime: 'claude', state: 'failed', detail: (en.stderr || en.stdout || `exit ${en.status}`).trim() }
|
|
264
270
|
}
|
|
@@ -293,10 +299,10 @@ function codexState(bin: string, id: string, env: NodeJS.ProcessEnv): 'enabled'
|
|
|
293
299
|
* ENABLES (verified live: config.toml `[plugins."<id>"] enabled = true`) and is
|
|
294
300
|
* idempotent (re-add exits 0). Idempotency: skip when already enabled. installPath is
|
|
295
301
|
* parsed from the add output so setup-detection works for a codex-only peer. */
|
|
296
|
-
function enableCodex(plugin: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
302
|
+
function enableCodex(plugin: string, marketplace: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
297
303
|
const bin = codexBin(env)
|
|
298
304
|
if (!isExecutable(bin, env)) return { runtime: 'codex', state: 'runtime-missing' }
|
|
299
|
-
const id = `${plugin}@${
|
|
305
|
+
const id = `${plugin}@${marketplace}`
|
|
300
306
|
const before = codexState(bin, id, env)
|
|
301
307
|
if (before === 'enabled') return { runtime: 'codex', state: 'already-enabled' }
|
|
302
308
|
const add = spawnSync(bin, ['plugin', 'add', id], { encoding: 'utf8', env: env as Record<string, string> })
|
|
@@ -338,19 +344,76 @@ function callSetup(
|
|
|
338
344
|
return { ok: true }
|
|
339
345
|
}
|
|
340
346
|
|
|
347
|
+
/** Registry-free enable input — the cwd-level core (контракт §Плагин провайдера:
|
|
348
|
+
* birth-time hook runs BEFORE the registry upsert, so it cannot resolve a peer). */
|
|
349
|
+
export interface CwdEnableOptions {
|
|
350
|
+
plugin: string
|
|
351
|
+
/** Marketplace NAME the plugin id keys on (default: agfpd). */
|
|
352
|
+
marketplace?: string
|
|
353
|
+
/** Source ref: when given, the path ENSURES the marketplace is registered for the
|
|
354
|
+
* runtime (add on missing) before installing — the third-party-provider path. */
|
|
355
|
+
marketplaceRef?: string
|
|
356
|
+
cwd: string
|
|
357
|
+
personality: string
|
|
358
|
+
runtimes: CapabilityRuntime[]
|
|
359
|
+
noSetup?: boolean
|
|
360
|
+
env?: NodeJS.ProcessEnv
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** One install attempt for one runtime, WITH the stale-snapshot resilience the
|
|
364
|
+
* contract requires (просьба iapeer-memory): a failed attempt refreshes the
|
|
365
|
+
* runtime's local marketplace snapshot (claude `marketplace update` / codex
|
|
366
|
+
* `marketplace upgrade`) and retries ONCE — a plugin registered in the
|
|
367
|
+
* marketplace after the host's last pull reads as «unknown plugin» until then. */
|
|
368
|
+
function enableRuntimeWithRetry(
|
|
369
|
+
rt: CapabilityRuntime,
|
|
370
|
+
plugin: string,
|
|
371
|
+
marketplace: string,
|
|
372
|
+
cwd: string,
|
|
373
|
+
env: NodeJS.ProcessEnv,
|
|
374
|
+
): RuntimeEnableResult {
|
|
375
|
+
const attempt = (): RuntimeEnableResult =>
|
|
376
|
+
rt === 'claude' ? enableClaude(plugin, marketplace, cwd, env) : enableCodex(plugin, marketplace, env)
|
|
377
|
+
const first = attempt()
|
|
378
|
+
if (first.state !== 'failed') return first
|
|
379
|
+
const refresh = refreshMarketplace(rt, marketplace, env)
|
|
380
|
+
if (!refresh.ok) {
|
|
381
|
+
return { ...first, detail: `${first.detail ?? 'failed'}; marketplace refresh also failed: ${refresh.detail ?? ''}` }
|
|
382
|
+
}
|
|
383
|
+
const second = attempt()
|
|
384
|
+
return second.state === 'failed'
|
|
385
|
+
? { ...second, detail: `${second.detail ?? 'failed'} (after marketplace refresh)` }
|
|
386
|
+
: second
|
|
387
|
+
}
|
|
388
|
+
|
|
341
389
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
390
|
+
* The registry-FREE enable core: install per-runtime (claude project-scope IN cwd /
|
|
391
|
+
* codex global) + enable + `setup` ONLY if declared — with marketplace ensure
|
|
392
|
+
* (marketplaceRef given + not registered → add) and the stale-snapshot retry.
|
|
393
|
+
* Consumers: enableCapability (peer-resolved verb), provisionPeer's birth-time
|
|
394
|
+
* memory-plugin hook, and the `memory-plugin` verb — ONE home of the install forms.
|
|
346
395
|
*/
|
|
347
|
-
export function
|
|
348
|
-
const env =
|
|
349
|
-
const
|
|
350
|
-
const targetRuntimes = opts.runtimes ?? peer.runtimes
|
|
396
|
+
export function enableCapabilityForCwd(o: CwdEnableOptions): EnableResult {
|
|
397
|
+
const env = o.env ?? process.env
|
|
398
|
+
const marketplace = o.marketplace ?? MARKETPLACE_NAME
|
|
351
399
|
const results: RuntimeEnableResult[] = []
|
|
352
|
-
for (const rt of
|
|
353
|
-
|
|
400
|
+
for (const rt of o.runtimes) {
|
|
401
|
+
// runtime-missing wins FIRST (before any marketplace work): a host without
|
|
402
|
+
// the runtime binary is a clean skip, not a marketplace-add failure.
|
|
403
|
+
if (!isExecutable(rt === 'claude' ? claudeBin(env) : codexBin(env), env)) {
|
|
404
|
+
results.push({ runtime: rt, state: 'runtime-missing' })
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
// Marketplace ensure — only when the caller supplied a source ref (the slot's
|
|
408
|
+
// marketplaceRef); the generic agfpd path is guaranteed by onboard already.
|
|
409
|
+
if (o.marketplaceRef && !isMarketplaceRegisteredAs(rt, marketplace, o.marketplaceRef, env)) {
|
|
410
|
+
const reg = registerMarketplace(rt, env, o.marketplaceRef)
|
|
411
|
+
if (!reg.ok) {
|
|
412
|
+
results.push({ runtime: rt, state: 'failed', detail: `marketplace add ${o.marketplaceRef} failed: ${reg.detail ?? ''}` })
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
results.push(enableRuntimeWithRetry(rt, o.plugin, marketplace, o.cwd, env))
|
|
354
417
|
}
|
|
355
418
|
|
|
356
419
|
// setup: read the manifest from whichever runtime gave us an installPath (the source
|
|
@@ -361,21 +424,98 @@ export function enableCapability(opts: EnableOptions): EnableResult {
|
|
|
361
424
|
const setup = readSetupDescriptor(installPath)
|
|
362
425
|
const anyOk = results.some(r => r.state === 'installed' || r.state === 'enabled' || r.state === 'already-enabled')
|
|
363
426
|
if (setup && anyOk) {
|
|
364
|
-
if (
|
|
427
|
+
if (o.noSetup) {
|
|
365
428
|
setupState = 'skipped'
|
|
366
429
|
} else {
|
|
367
|
-
const s = callSetup(setup, installPath as string,
|
|
430
|
+
const s = callSetup(setup, installPath as string, { personality: o.personality, cwd: o.cwd }, env)
|
|
368
431
|
setupState = s.ok ? 'called' : 'failed'
|
|
369
432
|
setupDetail = s.detail
|
|
370
433
|
}
|
|
371
434
|
}
|
|
372
435
|
|
|
373
436
|
return {
|
|
374
|
-
plugin:
|
|
375
|
-
personality:
|
|
376
|
-
cwd:
|
|
437
|
+
plugin: o.plugin,
|
|
438
|
+
personality: o.personality,
|
|
439
|
+
cwd: o.cwd,
|
|
377
440
|
runtimes: results,
|
|
378
441
|
setup: setupState,
|
|
379
442
|
setupDetail,
|
|
380
443
|
}
|
|
381
444
|
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Enable a capability plugin on a peer (contract Установка §3): install per-runtime
|
|
448
|
+
* (claude project-scope / codex global) + enable + call `setup` ONLY if the plugin
|
|
449
|
+
* declares it. Idempotent and fleet-safe — a peer already enabled is a no-op; the
|
|
450
|
+
* claude path is keyed by the peer's projectPath so it never touches another peer.
|
|
451
|
+
* Thin peer-resolving wrapper over enableCapabilityForCwd (the ONE forms home).
|
|
452
|
+
*/
|
|
453
|
+
export function enableCapability(opts: EnableOptions): EnableResult {
|
|
454
|
+
const env = opts.env ?? process.env
|
|
455
|
+
const peer = resolvePeer(opts)
|
|
456
|
+
return enableCapabilityForCwd({
|
|
457
|
+
plugin: opts.plugin,
|
|
458
|
+
cwd: peer.cwd,
|
|
459
|
+
personality: peer.personality,
|
|
460
|
+
runtimes: opts.runtimes ?? peer.runtimes,
|
|
461
|
+
noSetup: opts.noSetup,
|
|
462
|
+
env,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
467
|
+
// Removal forms (контракт §Плагин провайдера, off-direction) — verb names
|
|
468
|
+
// verified live 10.06: claude `plugin uninstall <id> --scope project` (run in the
|
|
469
|
+
// peer cwd), codex `plugin remove <id>` (host-global).
|
|
470
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
export type RuntimeRemoveState = 'removed' | 'absent' | 'runtime-missing' | 'failed'
|
|
473
|
+
|
|
474
|
+
export interface RuntimeRemoveResult {
|
|
475
|
+
runtime: CapabilityRuntime
|
|
476
|
+
state: RuntimeRemoveState
|
|
477
|
+
detail?: string
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** claude: uninstall the peer-scoped (projectPath-keyed) entry — fleet-safe by the
|
|
481
|
+
* same key enable uses; a peer without an entry is 'absent' (idempotent). */
|
|
482
|
+
export function removeClaudeForCwd(
|
|
483
|
+
plugin: string,
|
|
484
|
+
marketplace: string,
|
|
485
|
+
peerCwd: string,
|
|
486
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
487
|
+
): RuntimeRemoveResult {
|
|
488
|
+
const bin = claudeBin(env)
|
|
489
|
+
if (!isExecutable(bin, env)) return { runtime: 'claude', state: 'runtime-missing' }
|
|
490
|
+
const id = `${plugin}@${marketplace}`
|
|
491
|
+
const existing = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
492
|
+
if (!existing) return { runtime: 'claude', state: 'absent' }
|
|
493
|
+
const r = spawnSync(bin, ['plugin', 'uninstall', id, '--scope', 'project'], {
|
|
494
|
+
cwd: peerCwd,
|
|
495
|
+
encoding: 'utf8',
|
|
496
|
+
env: env as Record<string, string>,
|
|
497
|
+
})
|
|
498
|
+
const after = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
499
|
+
if (after) {
|
|
500
|
+
return { runtime: 'claude', state: 'failed', detail: (r.stderr || r.stdout || `exit ${r.status}`).trim() }
|
|
501
|
+
}
|
|
502
|
+
return { runtime: 'claude', state: 'removed' }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** codex: remove the HOST-GLOBAL plugin (codex has no project scope) — callers gate
|
|
506
|
+
* this on an explicit fleet-wide intent (`memory-plugin off --all`). */
|
|
507
|
+
export function removeCodexGlobal(
|
|
508
|
+
plugin: string,
|
|
509
|
+
marketplace: string,
|
|
510
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
511
|
+
): RuntimeRemoveResult {
|
|
512
|
+
const bin = codexBin(env)
|
|
513
|
+
if (!isExecutable(bin, env)) return { runtime: 'codex', state: 'runtime-missing' }
|
|
514
|
+
const id = `${plugin}@${marketplace}`
|
|
515
|
+
if (codexState(bin, id, env) === 'absent') return { runtime: 'codex', state: 'absent' }
|
|
516
|
+
const r = spawnSync(bin, ['plugin', 'remove', id], { encoding: 'utf8', env: env as Record<string, string> })
|
|
517
|
+
if (codexState(bin, id, env) !== 'absent') {
|
|
518
|
+
return { runtime: 'codex', state: 'failed', detail: (r.stderr || r.stdout || `exit ${r.status}`).trim() }
|
|
519
|
+
}
|
|
520
|
+
return { runtime: 'codex', state: 'removed' }
|
|
521
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// memory-plugin — slot-derived gating + target resolution + codex host-global
|
|
2
|
+
// semantics (контракт §Плагин провайдера). Install forms are NOT exercised here
|
|
3
|
+
// (they spawn runtime CLIs); the bins are pointed at /nonexistent so every
|
|
4
|
+
// runtime resolves 'runtime-missing' — the orchestration around them is what
|
|
5
|
+
// these tests pin.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { upsertPeer } from '../registry/index.ts'
|
|
12
|
+
import { memoryProviderPath } from '../status/index.ts'
|
|
13
|
+
import { memoryPluginApply } from './memoryPlugin.ts'
|
|
14
|
+
|
|
15
|
+
let root: string
|
|
16
|
+
function env(): NodeJS.ProcessEnv {
|
|
17
|
+
const e: NodeJS.ProcessEnv = {
|
|
18
|
+
...process.env,
|
|
19
|
+
IAPEER_ROOT: root,
|
|
20
|
+
IAPEER_CLAUDE_BIN: '/nonexistent/claude',
|
|
21
|
+
IAPEER_CODEX_BIN: '/nonexistent/codex',
|
|
22
|
+
}
|
|
23
|
+
delete e.PEER_PERSONALITY
|
|
24
|
+
delete e.PEER_IDENTITY
|
|
25
|
+
delete e.PEER_RUNTIME
|
|
26
|
+
return e
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-memplugin-'))
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
rmSync(root, { recursive: true, force: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const DECLARATION = {
|
|
37
|
+
provider: 'iapeer-memory',
|
|
38
|
+
package: '@agfpd/iapeer-memory',
|
|
39
|
+
version: '0.1.0',
|
|
40
|
+
registeredAt: '2026-06-10T00:00:00Z',
|
|
41
|
+
}
|
|
42
|
+
const PLUGIN = { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' }
|
|
43
|
+
|
|
44
|
+
function declareSlot(withPlugin: boolean): void {
|
|
45
|
+
writeFileSync(memoryProviderPath(env()), JSON.stringify(withPlugin ? { ...DECLARATION, plugin: PLUGIN } : DECLARATION))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('memory-plugin gating (slot-derived)', () => {
|
|
49
|
+
test('EMPTY slot → explicit refusal, no outcomes', () => {
|
|
50
|
+
const r = memoryPluginApply('on', { all: true, env: env() })
|
|
51
|
+
expect(r.ok).toBe(false)
|
|
52
|
+
expect(r.error).toContain('EMPTY')
|
|
53
|
+
expect(r.outcomes).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('v1 declaration (no plugin block) → explicit refusal pointing at generic enable', () => {
|
|
57
|
+
declareSlot(false)
|
|
58
|
+
const r = memoryPluginApply('on', { all: true, env: env() })
|
|
59
|
+
expect(r.ok).toBe(false)
|
|
60
|
+
expect(r.error).toContain('declares no plugin block')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('unknown --peer → explicit refusal', async () => {
|
|
64
|
+
declareSlot(true)
|
|
65
|
+
const r = memoryPluginApply('on', { peer: 'ghost', env: env() })
|
|
66
|
+
expect(r.ok).toBe(false)
|
|
67
|
+
expect(r.error).toContain('not in the iapeer peers index')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('on --all: plugin derived from the slot; runtime-missing host → clean per-runtime outcomes, ok', async () => {
|
|
71
|
+
declareSlot(true)
|
|
72
|
+
await upsertPeer({ personality: 'alpha', runtime: 'claude', cwd: join(root, 'alpha'), intelligence: 'artificial' }, { rootDir: root })
|
|
73
|
+
await upsertPeer({ personality: 'tim', runtime: 'notifier', cwd: join(root, 'tim'), intelligence: 'absent' }, { rootDir: root })
|
|
74
|
+
const r = memoryPluginApply('on', { all: true, env: env() })
|
|
75
|
+
expect(r.plugin).toEqual(PLUGIN)
|
|
76
|
+
expect(r.ok).toBe(true) // runtime-missing is a skip, not a failure
|
|
77
|
+
const alpha = r.outcomes.find(o => o.personality === 'alpha')
|
|
78
|
+
expect(alpha?.state).toBe('runtime-missing')
|
|
79
|
+
// a peer with NO agentic runtime is skipped explicitly
|
|
80
|
+
const tim = r.outcomes.find(o => o.personality === 'tim')
|
|
81
|
+
expect(tim?.state).toBe('skipped-no-agentic-runtime')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('off --peer on a codex peer → explicit host-global message, NOT a silent skip', async () => {
|
|
85
|
+
declareSlot(true)
|
|
86
|
+
await upsertPeer({ personality: 'cx', runtime: 'codex', cwd: join(root, 'cx'), intelligence: 'artificial' }, { rootDir: root })
|
|
87
|
+
const r = memoryPluginApply('off', { peer: 'cx', env: env() })
|
|
88
|
+
const o = r.outcomes.find(x => x.personality === 'cx' && x.runtime === 'codex')
|
|
89
|
+
expect(o?.state).toBe('skipped-host-global')
|
|
90
|
+
expect(o?.detail).toContain('--all')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('off --all: codex host-global remove runs ONCE across the fleet', async () => {
|
|
94
|
+
declareSlot(true)
|
|
95
|
+
await upsertPeer({ personality: 'cx1', runtime: 'codex', cwd: join(root, 'cx1'), intelligence: 'artificial' }, { rootDir: root })
|
|
96
|
+
await upsertPeer({ personality: 'cx2', runtime: 'codex', cwd: join(root, 'cx2'), intelligence: 'artificial' }, { rootDir: root })
|
|
97
|
+
const r = memoryPluginApply('off', { all: true, env: env() })
|
|
98
|
+
const codexOutcomes = r.outcomes.filter(x => x.runtime === 'codex')
|
|
99
|
+
// one real attempt (runtime-missing on this sandbox) + one 'already' dedupe
|
|
100
|
+
expect(codexOutcomes.filter(x => x.state === 'runtime-missing').length).toBe(1)
|
|
101
|
+
expect(codexOutcomes.filter(x => x.state === 'already').length).toBe(1)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// memory-plugin — the slot-derived operator verb (контракт «Слот памяти»
|
|
2
|
+
// §Плагин провайдера — авто-установка при рождении; agreed iapeer-memory + boris
|
|
3
|
+
// 10.06). By analogy with native-memory: `iapeer memory-plugin <on|off>
|
|
4
|
+
// (--peer <p> | --all)`. The plugin identity is DERIVED from the slot
|
|
5
|
+
// declaration's v1.1 `plugin` block — an empty slot or a v1 declaration without
|
|
6
|
+
// the block is an EXPLICIT refusal (for arbitrary plugins the generic `iapeer
|
|
7
|
+
// enable` exists). Install/removal forms live in enable/index.ts (the ONE home);
|
|
8
|
+
// this module only orchestrates targets and the codex host-global semantics.
|
|
9
|
+
//
|
|
10
|
+
// Consumers: the operator, the provider's init-sweep (`on --all` after writing
|
|
11
|
+
// the declaration), and the provider's uninstall (`off --all` BEFORE deleting
|
|
12
|
+
// the declaration — agreed AUTO-removal: a dead provider's plugin actively
|
|
13
|
+
// errors in every session, unlike the native-memory restore asymmetry).
|
|
14
|
+
|
|
15
|
+
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
16
|
+
import { readMemoryProvider, type MemoryProviderPlugin } from '../status/index.ts'
|
|
17
|
+
import {
|
|
18
|
+
enableCapabilityForCwd,
|
|
19
|
+
removeClaudeForCwd,
|
|
20
|
+
removeCodexGlobal,
|
|
21
|
+
type CapabilityRuntime,
|
|
22
|
+
} from './index.ts'
|
|
23
|
+
|
|
24
|
+
export interface MemoryPluginOutcome {
|
|
25
|
+
personality: string
|
|
26
|
+
runtime: CapabilityRuntime | 'none'
|
|
27
|
+
/** enable states (on) | remove states (off) | orchestration-level skips. */
|
|
28
|
+
state: string
|
|
29
|
+
detail?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MemoryPluginResult {
|
|
33
|
+
ok: boolean
|
|
34
|
+
/** The derived plugin (null when the slot refused — see error). */
|
|
35
|
+
plugin: MemoryProviderPlugin | null
|
|
36
|
+
/** Refusal reason (empty slot / v1 declaration / no targets). */
|
|
37
|
+
error?: string
|
|
38
|
+
outcomes: MemoryPluginOutcome[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MemoryPluginOptions {
|
|
42
|
+
peer?: string
|
|
43
|
+
all?: boolean
|
|
44
|
+
env?: NodeJS.ProcessEnv
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Apply the slot-declared provider plugin across targets. `on` = install per the
|
|
49
|
+
* canonical forms (claude project-scope per peer cwd; codex host-global once —
|
|
50
|
+
* idempotent). `off` = claude per-peer project-scope uninstall; codex is
|
|
51
|
+
* HOST-GLOBAL, so off with `--peer` on a codex runtime reports an explicit
|
|
52
|
+
* «host-global — use --all» (no silent skip, сверка boris) and the global remove
|
|
53
|
+
* runs only under `--all` (once).
|
|
54
|
+
*/
|
|
55
|
+
export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions = {}): MemoryPluginResult {
|
|
56
|
+
const env = opts.env ?? process.env
|
|
57
|
+
const slot = readMemoryProvider(env)
|
|
58
|
+
if (!slot) {
|
|
59
|
+
return { ok: false, plugin: null, error: 'memory slot is EMPTY — no provider declared, nothing to install', outcomes: [] }
|
|
60
|
+
}
|
|
61
|
+
if (!slot.plugin) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
plugin: null,
|
|
65
|
+
error: `provider "${slot.provider}" declares no plugin block (declaration v1) — for arbitrary plugins use \`iapeer enable\``,
|
|
66
|
+
outcomes: [],
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const plugin = slot.plugin
|
|
70
|
+
const index = readPeersIndex({ env })
|
|
71
|
+
const targets = opts.all === true ? index.peers : opts.peer ? (p => (p ? [p] : []))(findPeer(index, opts.peer)) : []
|
|
72
|
+
if (targets.length === 0) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
plugin,
|
|
76
|
+
error: opts.peer ? `peer "${opts.peer}" is not in the iapeer peers index` : 'no targets — pass --peer <p> or --all',
|
|
77
|
+
outcomes: [],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const outcomes: MemoryPluginOutcome[] = []
|
|
82
|
+
let codexHandledGlobally = false
|
|
83
|
+
for (const p of targets) {
|
|
84
|
+
const agentic = p.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
|
|
85
|
+
if (agentic.length === 0) {
|
|
86
|
+
outcomes.push({ personality: p.personality, runtime: 'none', state: 'skipped-no-agentic-runtime' })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (state === 'on') {
|
|
90
|
+
const r = enableCapabilityForCwd({
|
|
91
|
+
plugin: plugin.name,
|
|
92
|
+
marketplace: plugin.marketplace,
|
|
93
|
+
marketplaceRef: plugin.marketplaceRef,
|
|
94
|
+
cwd: p.cwd,
|
|
95
|
+
personality: p.personality,
|
|
96
|
+
runtimes: agentic,
|
|
97
|
+
env,
|
|
98
|
+
})
|
|
99
|
+
for (const rr of r.runtimes) {
|
|
100
|
+
outcomes.push({ personality: p.personality, runtime: rr.runtime, state: rr.state, detail: rr.detail })
|
|
101
|
+
}
|
|
102
|
+
if (r.setup !== 'absent') {
|
|
103
|
+
outcomes.push({ personality: p.personality, runtime: agentic[0]!, state: `setup-${r.setup}`, detail: r.setupDetail })
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
for (const rt of agentic) {
|
|
107
|
+
if (rt === 'claude') {
|
|
108
|
+
const rr = removeClaudeForCwd(plugin.name, plugin.marketplace, p.cwd, env)
|
|
109
|
+
outcomes.push({ personality: p.personality, runtime: 'claude', state: rr.state, detail: rr.detail })
|
|
110
|
+
} else {
|
|
111
|
+
// codex plugin is HOST-GLOBAL: per-peer off is impossible — explicit
|
|
112
|
+
// message (сверка boris), the actual remove runs ONCE under --all.
|
|
113
|
+
if (opts.all !== true) {
|
|
114
|
+
outcomes.push({
|
|
115
|
+
personality: p.personality,
|
|
116
|
+
runtime: 'codex',
|
|
117
|
+
state: 'skipped-host-global',
|
|
118
|
+
detail: 'codex plugin is host-global — use `memory-plugin off --all`',
|
|
119
|
+
})
|
|
120
|
+
} else if (!codexHandledGlobally) {
|
|
121
|
+
codexHandledGlobally = true
|
|
122
|
+
const rr = removeCodexGlobal(plugin.name, plugin.marketplace, env)
|
|
123
|
+
outcomes.push({ personality: p.personality, runtime: 'codex', state: rr.state, detail: rr.detail })
|
|
124
|
+
} else {
|
|
125
|
+
outcomes.push({ personality: p.personality, runtime: 'codex', state: 'already', detail: 'host-global remove already done this run' })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const failed = outcomes.some(o => o.state === 'failed' || o.state === 'setup-failed')
|
|
132
|
+
return { ok: !failed, plugin, outcomes }
|
|
133
|
+
}
|
package/src/onboard/index.ts
CHANGED
|
@@ -105,25 +105,71 @@ export function isMarketplaceRegistered(runtime: OnboardRuntime, env: NodeJS.Pro
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Pure detector over a `plugin marketplace list` output: is
|
|
109
|
-
* Matches the
|
|
110
|
-
*
|
|
108
|
+
* Pure detector over a `plugin marketplace list` output: is a marketplace present?
|
|
109
|
+
* Matches the GitHub source-ref (claude renders it) OR a standalone name entry
|
|
110
|
+
* (both runtimes render the name). The name match is anchored to a line start
|
|
111
111
|
* (optionally after the `❯` selection glyph) and followed by whitespace/EOL, so a
|
|
112
|
-
* different
|
|
112
|
+
* different `<name>-<something>` token never false-positives. Pure → unit-testable
|
|
113
113
|
* against real claude/codex samples (the fleet-guard hinges on it).
|
|
114
114
|
*/
|
|
115
|
+
export function isMarketplaceInList(listOutput: string, name: string, ref?: string): boolean {
|
|
116
|
+
if (ref && new RegExp(ref.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).test(listOutput)) return true
|
|
117
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
|
|
118
|
+
return new RegExp(`(^|\\n)\\s*(❯\\s*)?${esc}(\\s|$)`).test(listOutput)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The OUR-marketplace specialization (the original onboard detector). */
|
|
115
122
|
export function isAgfpdInList(listOutput: string): boolean {
|
|
116
|
-
|
|
117
|
-
return /(^|\n)\s*(❯\s*)?agfpd(\s|$)/.test(listOutput)
|
|
123
|
+
return isMarketplaceInList(listOutput, MARKETPLACE_NAME, MARKETPLACE_REF)
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
/** Register
|
|
121
|
-
|
|
126
|
+
/** Register a marketplace for this runtime (`<runtime> plugin marketplace add <ref>`).
|
|
127
|
+
* Exported for the memory-plugin install path (контракт §Плагин провайдера: a
|
|
128
|
+
* third-party provider's marketplaceRef must self-register on a fresh host). */
|
|
129
|
+
export function registerMarketplace(
|
|
130
|
+
runtime: OnboardRuntime,
|
|
131
|
+
env: NodeJS.ProcessEnv,
|
|
132
|
+
ref: string = MARKETPLACE_REF,
|
|
133
|
+
): { ok: boolean; detail?: string } {
|
|
122
134
|
const bin = runtimeBin(runtime, env)
|
|
123
135
|
// Same hard timeout as the list probe (the pre-main wedge class — known live
|
|
124
136
|
// representative: macOS launch-approval pending after a cask update) — a wedged
|
|
125
137
|
// add degrades to a loud 'failed' line instead of freezing the host phase.
|
|
126
|
-
const r = spawnSync(bin, ['plugin', 'marketplace', 'add',
|
|
138
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', 'add', ref], { encoding: 'utf8', timeout: 120_000 })
|
|
139
|
+
return r.status === 0
|
|
140
|
+
? { ok: true }
|
|
141
|
+
: { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (wedged runtime CLI?)' : `exit ${r.status}`) }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Is a marketplace (by name/ref) registered for this runtime? Generalized form of
|
|
145
|
+
* isMarketplaceRegistered for the memory-plugin install path. */
|
|
146
|
+
export function isMarketplaceRegisteredAs(
|
|
147
|
+
runtime: OnboardRuntime,
|
|
148
|
+
name: string,
|
|
149
|
+
ref: string | undefined,
|
|
150
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
151
|
+
): boolean {
|
|
152
|
+
const bin = runtimeBin(runtime, env)
|
|
153
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', 'list'], { encoding: 'utf8', timeout: 60_000 })
|
|
154
|
+
if (r.status !== 0) return false
|
|
155
|
+
return isMarketplaceInList(`${r.stdout ?? ''}`, name, ref)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Refresh a runtime's local marketplace SNAPSHOT (контракт §Плагин провайдера,
|
|
160
|
+
* просьба iapeer-memory): a plugin registered in the marketplace AFTER the host's
|
|
161
|
+
* last pull reads as «unknown plugin» until the snapshot updates. Verb names
|
|
162
|
+
* verified live 10.06: claude `plugin marketplace update <name>`, codex
|
|
163
|
+
* `plugin marketplace upgrade <name>`.
|
|
164
|
+
*/
|
|
165
|
+
export function refreshMarketplace(
|
|
166
|
+
runtime: OnboardRuntime,
|
|
167
|
+
name: string,
|
|
168
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
169
|
+
): { ok: boolean; detail?: string } {
|
|
170
|
+
const bin = runtimeBin(runtime, env)
|
|
171
|
+
const verb = runtime === 'claude' ? 'update' : 'upgrade'
|
|
172
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', verb, name], { encoding: 'utf8', timeout: 120_000 })
|
|
127
173
|
return r.status === 0
|
|
128
174
|
? { ok: true }
|
|
129
175
|
: { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (wedged runtime CLI?)' : `exit ${r.status}`) }
|
package/src/provision/index.ts
CHANGED
|
@@ -98,7 +98,8 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
98
98
|
// the provision — the operator verb `iapeer native-memory off` is the repair).
|
|
99
99
|
try {
|
|
100
100
|
const { readMemoryProvider } = await import('../status/index.ts')
|
|
101
|
-
|
|
101
|
+
const slot = readMemoryProvider(env)
|
|
102
|
+
if (slot) {
|
|
102
103
|
const { applyNativeMemory, preTrustCodexCwd } = await import('../launch/nativeMemory.ts')
|
|
103
104
|
for (const o of applyNativeMemory(cwd, profile.runtimes, 'off')) {
|
|
104
105
|
if (o.state === 'failed') {
|
|
@@ -120,9 +121,41 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
120
121
|
)
|
|
121
122
|
}
|
|
122
123
|
}
|
|
124
|
+
// Provider PLUGIN auto-install (контракт §Плагин провайдера, declaration
|
|
125
|
+
// v1.1; agreed iapeer-memory + boris 10.06): an occupied slot WITH a plugin
|
|
126
|
+
// block installs the provider's marketplace plugin into the newborn's scope
|
|
127
|
+
// (claude project-scope in cwd; codex host-global, idempotent) — «создал
|
|
128
|
+
// пира — память работает». A v1 declaration (no block) installs nothing.
|
|
129
|
+
// Best-effort with a LOUD warn; repair = `iapeer memory-plugin on --peer`.
|
|
130
|
+
if (slot.plugin) {
|
|
131
|
+
const { enableCapabilityForCwd } = await import('../enable/index.ts')
|
|
132
|
+
const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
133
|
+
if (agentic.length > 0) {
|
|
134
|
+
const r = enableCapabilityForCwd({
|
|
135
|
+
plugin: slot.plugin.name,
|
|
136
|
+
marketplace: slot.plugin.marketplace,
|
|
137
|
+
marketplaceRef: slot.plugin.marketplaceRef,
|
|
138
|
+
cwd,
|
|
139
|
+
personality: profile.personality,
|
|
140
|
+
runtimes: agentic,
|
|
141
|
+
env,
|
|
142
|
+
})
|
|
143
|
+
for (const rr of r.runtimes) {
|
|
144
|
+
if (rr.state === 'failed') {
|
|
145
|
+
opts.warn?.(
|
|
146
|
+
`memory-plugin install (${rr.runtime}) FAILED for "${profile.personality}": ${rr.detail} — ` +
|
|
147
|
+
`repair: iapeer memory-plugin on --peer ${profile.personality}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (r.setup === 'failed') {
|
|
152
|
+
opts.warn?.(`memory-plugin setup FAILED for "${profile.personality}": ${r.setupDetail ?? ''}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
123
156
|
}
|
|
124
157
|
} catch (e) {
|
|
125
|
-
opts.warn?.(`
|
|
158
|
+
opts.warn?.(`memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
126
159
|
}
|
|
127
160
|
|
|
128
161
|
// wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
|
|
@@ -198,4 +198,29 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
198
198
|
expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(false)
|
|
199
199
|
expect(existsSync(join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
200
200
|
})
|
|
201
|
+
|
|
202
|
+
test('slot with v1.1 plugin block → birth hook attempts the plugin install; runtime-missing host stays silent and provision SUCCEEDS', async () => {
|
|
203
|
+
const root = mkTmp()
|
|
204
|
+
const env = envFor(root)
|
|
205
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
provider: 'iapeer-memory',
|
|
210
|
+
package: '@agfpd/iapeer-memory',
|
|
211
|
+
version: '0.1.0',
|
|
212
|
+
registeredAt: 'x',
|
|
213
|
+
plugin: { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' },
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
const cwd = join(root, 'wplug')
|
|
217
|
+
const warns: string[] = []
|
|
218
|
+
// HOME-scoped claude bin + no codex on PATH → both runtimes 'runtime-missing':
|
|
219
|
+
// a clean skip (host without the runtime), NOT a failure warn, and never a throw.
|
|
220
|
+
const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
221
|
+
expect(r.personality).toBe('wplug')
|
|
222
|
+
expect(warns.filter(w => w.includes('memory-plugin install'))).toEqual([])
|
|
223
|
+
// the native lever still applied (same slot-gated block)
|
|
224
|
+
expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
|
|
225
|
+
})
|
|
201
226
|
})
|
package/src/status/index.ts
CHANGED
|
@@ -16,6 +16,22 @@ import { waitForDaemonHealthy } from '../update/index.ts'
|
|
|
16
16
|
/** The slot-declaration filename in the storage root (next to peers-profiles.json). */
|
|
17
17
|
export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
|
|
18
18
|
|
|
19
|
+
/** The provider's marketplace plugin (declaration v1.1, контракт §Плагин
|
|
20
|
+
* провайдера — agreed with iapeer-memory + boris 10.06). Declares WHAT the core
|
|
21
|
+
* auto-installs into a newborn peer's scope (and what `memory-plugin` targets).
|
|
22
|
+
* All three fields are required when the block is present; an invalid block is
|
|
23
|
+
* treated as ABSENT (fail-open, like the whole file) = v1 behavior, no install. */
|
|
24
|
+
export interface MemoryProviderPlugin {
|
|
25
|
+
/** Plugin id in the marketplace (forms `<name>@<marketplace>` for installs). */
|
|
26
|
+
name: string
|
|
27
|
+
/** Marketplace NAME the plugin id keys on (e.g. "agfpd"). */
|
|
28
|
+
marketplace: string
|
|
29
|
+
/** Source ref for `plugin marketplace add` when the marketplace is not yet
|
|
30
|
+
* registered on the host (owner/repo or URL) — the third-party-provider path;
|
|
31
|
+
* for the distribution default it matches what onboard already adds. */
|
|
32
|
+
marketplaceRef: string
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
export interface MemoryProvider {
|
|
20
36
|
/** Provider name occupying the slot (e.g. "iapeer-memory"). */
|
|
21
37
|
provider: string
|
|
@@ -26,6 +42,19 @@ export interface MemoryProvider {
|
|
|
26
42
|
/** Optional liveness proxy: an absolute path whose mtime the provider's daemon
|
|
27
43
|
* refreshes. status reports its age; the core takes NO action on staleness. */
|
|
28
44
|
heartbeat?: string
|
|
45
|
+
/** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin. */
|
|
46
|
+
plugin?: MemoryProviderPlugin
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse the optional v1.1 `plugin` block; anything short of three non-empty
|
|
50
|
+
* strings → undefined (treated as absent — v1 declaration, core installs nothing). */
|
|
51
|
+
function parsePluginBlock(raw: unknown): MemoryProviderPlugin | undefined {
|
|
52
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
53
|
+
const o = raw as Record<string, unknown>
|
|
54
|
+
if (typeof o.name !== 'string' || !o.name.trim()) return undefined
|
|
55
|
+
if (typeof o.marketplace !== 'string' || !o.marketplace.trim()) return undefined
|
|
56
|
+
if (typeof o.marketplaceRef !== 'string' || !o.marketplaceRef.trim()) return undefined
|
|
57
|
+
return { name: o.name.trim(), marketplace: o.marketplace.trim(), marketplaceRef: o.marketplaceRef.trim() }
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
export function memoryProviderPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -44,12 +73,14 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
44
73
|
if (typeof o.provider !== 'string' || !o.provider.trim()) return null
|
|
45
74
|
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
46
75
|
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
76
|
+
const plugin = parsePluginBlock(o.plugin)
|
|
47
77
|
return {
|
|
48
78
|
provider: o.provider.trim(),
|
|
49
79
|
package: o.package.trim(),
|
|
50
80
|
version: o.version.trim(),
|
|
51
81
|
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
52
82
|
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
83
|
+
...(plugin ? { plugin } : {}),
|
|
53
84
|
}
|
|
54
85
|
} catch {
|
|
55
86
|
return null // empty slot — bare core is valid
|
|
@@ -61,6 +61,26 @@ describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
|
61
61
|
expect(readMemoryProvider(env)).toBeNull()
|
|
62
62
|
}
|
|
63
63
|
})
|
|
64
|
+
|
|
65
|
+
test('v1.1 plugin block: parsed when complete; INVALID block = absent (v1 behavior), declaration still valid', () => {
|
|
66
|
+
const root = mkTmp()
|
|
67
|
+
const env = envFor(root)
|
|
68
|
+
const plugin = { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' }
|
|
69
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, plugin }))
|
|
70
|
+
expect(readMemoryProvider(env)?.plugin).toEqual(plugin)
|
|
71
|
+
// incomplete / wrong-shape blocks → treated as ABSENT, the declaration itself stays valid
|
|
72
|
+
for (const bad of [
|
|
73
|
+
{ name: 'x', marketplace: 'agfpd' }, // missing marketplaceRef
|
|
74
|
+
{ name: '', marketplace: 'agfpd', marketplaceRef: 'r' }, // empty name
|
|
75
|
+
'iapeer-memory', // not an object
|
|
76
|
+
['x'], // array
|
|
77
|
+
]) {
|
|
78
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, plugin: bad }))
|
|
79
|
+
const p = readMemoryProvider(env)
|
|
80
|
+
expect(p).not.toBeNull()
|
|
81
|
+
expect(p?.plugin).toBeUndefined()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
64
84
|
})
|
|
65
85
|
|
|
66
86
|
describe('heartbeatAgeSecs', () => {
|