@agfpd/iapeer 0.2.23 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
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
@@ -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(bin: string, plugin: string, peerCwd: string, env: NodeJS.ProcessEnv): InstalledEntry | null {
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, MARKETPLACE_NAME, peerCwd)
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}@${MARKETPLACE_NAME}`
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}@${MARKETPLACE_NAME}`
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
- * Enable a capability plugin on a peer (contract Установка §3): install per-runtime
343
- * (claude project-scope / codex global) + enable + call `setup` ONLY if the plugin
344
- * declares it. Idempotent and fleet-safe a peer already enabled is a no-op; the
345
- * claude path is keyed by the peer's projectPath so it never touches another peer.
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 enableCapability(opts: EnableOptions): EnableResult {
348
- const env = opts.env ?? process.env
349
- const peer = resolvePeer(opts)
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 targetRuntimes) {
353
- results.push(rt === 'claude' ? enableClaude(opts.plugin, peer.cwd, env) : enableCodex(opts.plugin, env))
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 (opts.noSetup) {
427
+ if (o.noSetup) {
365
428
  setupState = 'skipped'
366
429
  } else {
367
- const s = callSetup(setup, installPath as string, peer, env)
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: opts.plugin,
375
- personality: peer.personality,
376
- cwd: peer.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
+ }
@@ -17,6 +17,7 @@ import {
17
17
  signalCanaryClean,
18
18
  } from './canary.ts'
19
19
  import { killSession } from '../lifecycle/index.ts'
20
+ import { teardownAlwaysOnSession } from './launchdRun.ts'
20
21
 
21
22
  const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
22
23
 
@@ -92,7 +93,7 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
92
93
  afterAll(() => {
93
94
  for (const sock of socks) {
94
95
  // teardown is DELIBERATE → signal each canary before killing its server
95
- for (const id of ['claude-canadirty', 'claude-canaclean', 'claude-canakill']) {
96
+ for (const id of ['claude-canadirty', 'claude-canaclean', 'claude-canakill', 'notifier-canatear']) {
96
97
  signalCanaryClean(sock, id)
97
98
  }
98
99
  spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
@@ -152,6 +153,32 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
152
153
  20000,
153
154
  )
154
155
 
156
+ test(
157
+ 'teardownAlwaysOnSession (signal-exit of runAlwaysOn) kills session+server, canary stays silent',
158
+ async () => {
159
+ const identity = 'notifier-canatear'
160
+ const { sock, logDir } = bringUp(identity)
161
+ expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
162
+ expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
163
+ await sleep(500)
164
+
165
+ teardownAlwaysOnSession(sock, identity) // the bootout/shutdown path
166
+ // the whole server must be gone (the poller dies WITH the watcher — грабля closed)
167
+ expect(
168
+ await waitFor(
169
+ () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status !== 0,
170
+ 3000,
171
+ ),
172
+ ).toBe(true)
173
+ expect(await waitFor(() => !canaryRunning(identity), 3000)).toBe(true)
174
+ await sleep(300)
175
+ expect(existsSync(exitLogPath(logDir)) && readFileSync(exitLogPath(logDir), 'utf8').includes('ev=server-exit')).toBe(
176
+ false,
177
+ )
178
+ },
179
+ 20000,
180
+ )
181
+
155
182
  test(
156
183
  'killSession (lifecycle clean reap) signals the canary before kill-server → no record',
157
184
  async () => {
@@ -23,6 +23,7 @@ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
23
23
  import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
24
24
  import { readPeerProfile } from '../identity/index.ts'
25
25
  import { getAdapter, launch } from './index.ts'
26
+ import { signalCanaryClean } from './canary.ts'
26
27
  import type { LaunchConfig, LaunchSpec } from './types.ts'
27
28
 
28
29
  /** Block-watch poll cadence — seconds, deliberately NOT a tight loop (the session
@@ -39,6 +40,29 @@ function sessionAlive(sock: string, identity: string): boolean {
39
40
  return spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status === 0
40
41
  }
41
42
 
43
+ /**
44
+ * Tear down the always-on session WITH its tmux server (when this session was the
45
+ * last one) — the signal-exit counterpart of lifecycle.killSession, local to avoid
46
+ * a launch ⇆ lifecycle import. Canary-signaled first: this is a DELIBERATE stop.
47
+ *
48
+ * Closes the live грабля «bootout не убивает поллера» (boris 10.06, second
49
+ * strike): `launchctl bootout` TERMs THIS watcher process, but the detached tmux
50
+ * server — and the runtime poller inside it — survived holding STALE in-memory
51
+ * state (e.g. a notifier trigger's old target after a same-id replace), so a
52
+ * plain bootout+bootstrap was NOT a real restart. With the teardown, the session
53
+ * dies with its watcher: bootout = full stop, bootstrap = fresh bring-up that
54
+ * re-reads durable state. `iapeer stop` (bootout + killSession) is unchanged —
55
+ * both paths now converge on the same end state.
56
+ */
57
+ export function teardownAlwaysOnSession(sock: string, identity: string): void {
58
+ signalCanaryClean(sock, identity)
59
+ spawnSync('tmux', ['-S', sock, 'kill-session', '-t', identity], { stdio: 'ignore' })
60
+ const ls = spawnSync('tmux', ['-S', sock, 'list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' })
61
+ if (!(ls.stdout ?? '').trim()) {
62
+ spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
63
+ }
64
+ }
65
+
42
66
  /**
43
67
  * Build the always-on LaunchSpec for an infra peer, reading intelligence from the
44
68
  * local peer-profile.json. launchd sets WorkingDirectory = peer cwd, so that file
@@ -159,6 +183,12 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
159
183
  })
160
184
  interrupt = null
161
185
  }
186
+ // Signal-initiated exit (bootout / shutdown / kickstart -k) tears the session
187
+ // down WITH this watcher — without it the detached tmux poller outlived bootout
188
+ // holding stale in-memory state (см. teardownAlwaysOnSession). A natural session
189
+ // death (stop=false) skips this: there is nothing to tear down, exit 0 →
190
+ // KeepAlive respawns a fresh bring-up.
191
+ if (stop) teardownAlwaysOnSession(sock, identity)
162
192
  return 0
163
193
  }
164
194
 
@@ -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 OUR marketplace present?
109
- * Matches the agfpd GitHub source-ref (claude renders it) OR a standalone agfpd name
110
- * entry (both runtimes render the name). The name match is anchored to a line start
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 `agfpd-<something>` token never false-positives. Pure → unit-testable
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
- if (new RegExp(MARKETPLACE_REF.replace('/', '\\/')).test(listOutput)) return true
117
- return /(^|\n)\s*(❯\s*)?agfpd(\s|$)/.test(listOutput)
123
+ return isMarketplaceInList(listOutput, MARKETPLACE_NAME, MARKETPLACE_REF)
118
124
  }
119
125
 
120
- /** Register OUR marketplace for this runtime (`<runtime> plugin marketplace add <ref>`). */
121
- function registerMarketplace(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): { ok: boolean; detail?: string } {
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', MARKETPLACE_REF], { encoding: 'utf8', timeout: 120_000 })
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}`) }
@@ -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
- if (readMemoryProvider(env)) {
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?.(`native-memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
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
  })
@@ -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', () => {