@agfpd/iapeer 0.2.24 → 0.2.26

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.24",
3
+ "version": "0.2.26",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -130,6 +130,17 @@ describe('remove (registry record via the locked writer)', () => {
130
130
  expect((await removePeerCli('twice', { env: e })).action).toBe('removed')
131
131
  expect((await removePeerCli('twice', { env: e })).action).toBe('absent')
132
132
  })
133
+ test('self-done arms the caller\'s own quiet-reap (non-waking silent finish); refuses without PEER_IDENTITY', async () => {
134
+ const e = env()
135
+ // no PEER_IDENTITY → self-call refusal
136
+ expect(await runCli(['self-done'], e)).toBe(1)
137
+ // with PEER_IDENTITY → marker set, exit 0, nobody contacted
138
+ const e2 = { ...e, PEER_IDENTITY: 'claude-silentworker' }
139
+ expect(await runCli(['self-done'], e2)).toBe(0)
140
+ const { hasEphemeralArmed } = await import('../lifecycle/index.ts')
141
+ expect(hasEphemeralArmed(loadLifecycleConfig(e2), 'claude-silentworker')).toBe(true)
142
+ })
143
+
133
144
  test('purges identity-keyed lifecycle state with the record — a namesake newborn must not inherit a dead peer\'s parking (boris 10.06)', async () => {
134
145
  await register('reborn')
135
146
  const e = env()
package/src/cli/index.ts CHANGED
@@ -30,11 +30,13 @@ import {
30
30
  clearStopped,
31
31
  folderLaunch,
32
32
  isLaunchdManaged,
33
+ isEphemeralPeer,
33
34
  isStopped,
34
35
  killSession,
35
36
  loadLifecycleConfig,
36
37
  purgeIdentityState,
37
38
  removeSessionState,
39
+ setEphemeralArmed,
38
40
  setIdleReaped,
39
41
  setNewEager,
40
42
  setStopped,
@@ -348,8 +350,9 @@ export async function sendMessage(
348
350
  // unawaited in-process wake would die with it; the daemon's supervise-tick
349
351
  // drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
350
352
  // kicks, not a new mechanism.
351
- const { makeEphemeralRouteDeps } = await import('../daemon/main.ts')
353
+ const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps } = await import('../daemon/main.ts')
352
354
  const cfg = loadLifecycleConfig(env)
355
+ const t0 = Date.now()
353
356
  const result = await routeSend(
354
357
  caller,
355
358
  {
@@ -361,7 +364,39 @@ export async function sendMessage(
361
364
  },
362
365
  { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}) },
363
366
  )
367
+ // delivery.log sink — CLI-path parity (boris's observability gap 10.06: enqueues
368
+ // routed through the CLI left to=<peer> at ZERO for the day while real wakes
369
+ // happened; the daemon tool-path logs, this path was blind). Same fields, plus
370
+ // path=cli so the two entry points are distinguishable. Both branches logged.
371
+ const { appendDeliveryEvent } = await import('../daemon/deliverylog.ts')
372
+ appendDeliveryEvent(cfg.eventLogDir, {
373
+ ev: 'delivery',
374
+ path: 'cli',
375
+ caller: caller.address,
376
+ to: opts.target,
377
+ rt: opts.runtime,
378
+ ok: String(result.ok),
379
+ via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
380
+ woke: result.ok ? String(result.value.woke) : undefined,
381
+ queued: result.ok && result.value.queued ? 'true' : undefined,
382
+ qd: result.ok ? result.value.queueDepth : undefined,
383
+ ms: Date.now() - t0,
384
+ len: opts.message.length,
385
+ att: opts.attachments?.length || undefined,
386
+ topic: opts.topic,
387
+ err: result.ok ? undefined : result.error.message,
388
+ })
364
389
  if (!result.ok) throw new Error(result.error.message)
390
+ // M2 arm-on-outbound — CLI-path parity (live gap 10.06: an ephemeral worker's
391
+ // final reply sent through the CLI fallback — e.g. inside a daemon-restart
392
+ // window, four deploys that day — never armed, so the worker idled to the
393
+ // unarmed bound and stalled its FIFO). Same hook the daemon path uses; ONLY on
394
+ // an ok outcome, errors swallowed (arming is best-effort, never fails the send).
395
+ try {
396
+ makeArmEphemeralOnDelivered(cfg)(caller)
397
+ } catch {
398
+ /* best-effort */
399
+ }
365
400
  return {
366
401
  ok: true,
367
402
  delivered_to: result.value.delivered_to,
@@ -432,7 +467,9 @@ const USAGE = `usage: iapeer <verb> [args]
432
467
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
433
468
  compact <peer> [runtime] compact the peer's context (/compact)
434
469
  self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
470
+ self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
435
471
  native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
472
+ memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
436
473
  `
437
474
 
438
475
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
@@ -562,6 +599,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
562
599
  }
563
600
  return failed ? 1 : 0
564
601
  }
602
+ case 'memory-plugin': {
603
+ // Slot-derived provider-plugin install/removal (контракт «Слот памяти»
604
+ // §Плагин провайдера; agreed iapeer-memory + boris 10.06). The plugin
605
+ // identity comes from the slot declaration's v1.1 plugin block — empty
606
+ // slot / v1 declaration → explicit refusal (generic `enable` exists for
607
+ // arbitrary plugins). Forms live in enable/ (ONE home); codex off is
608
+ // host-global (only --all removes; --peer reports it explicitly).
609
+ const state = positionals[0]
610
+ if (state !== 'on' && state !== 'off') return usage(errOut)
611
+ const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
612
+ if (flags.all !== true && !peerName) return usage(errOut)
613
+ const { memoryPluginApply } = await import('../enable/memoryPlugin.ts')
614
+ const r = memoryPluginApply(state, { peer: peerName, all: flags.all === true, env })
615
+ if (r.error) {
616
+ errOut(`memory-plugin: ${r.error}\n`)
617
+ return 1
618
+ }
619
+ out(`plugin: ${r.plugin!.name}@${r.plugin!.marketplace} (provider-declared)\n`)
620
+ for (const o of r.outcomes) {
621
+ out(`${o.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
622
+ }
623
+ return r.ok ? 0 : 1
624
+ }
565
625
  case 'status': {
566
626
  // Host snapshot (контракт «Слот памяти» §status): version + daemon health +
567
627
  // the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
@@ -889,6 +949,37 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
889
949
  if (!positionals[0] || !positionals[1]) return usage(errOut)
890
950
  return await runAlwaysOn(positionals[0], positionals[1], process.cwd())
891
951
  }
952
+ case 'self-done': {
953
+ // SILENT-FINISH self-call for an ephemeral worker (контракт ЖЦ §wake_policy;
954
+ // развилка boris 10.06): a worker whose task produced NOTHING to send must
955
+ // still release its M3 FIFO — but an EMPTY report would violate Артур's
956
+ // invariant «событие-всё-отфильтровано = тишина» (no empty wakes of the
957
+ // target). This verb is the non-waking arm: it sets the worker's OWN
958
+ // .ephemeral-armed (same marker the ok-outbound hook sets), so the quiet
959
+ // window reaps it within seconds and the drain feeds the next task — nobody
960
+ // is woken. Doctrine for silent finishers: «нечего отправлять → iapeer
961
+ // self-done вместо ответа». The unarmed idle bound (ephemeralUnarmedIdleSecs)
962
+ // remains the backstop for workers that do neither. On a NON-ephemeral peer
963
+ // the marker is inert (quiet-reap keys on wake_policy) — warn, exit 0.
964
+ const identity = env.PEER_IDENTITY?.trim()
965
+ if (!identity) {
966
+ errOut('self-done: PEER_IDENTITY is not set — this verb is an agent self-call from inside a session\n')
967
+ return 1
968
+ }
969
+ if (!parseSessionName(identity)) {
970
+ errOut(`self-done: invalid PEER_IDENTITY "${identity}" — expected <runtime>-<personality>\n`)
971
+ return 1
972
+ }
973
+ const cfg = loadLifecycleConfig(env)
974
+ setEphemeralArmed(cfg, identity)
975
+ const ephemeral = isEphemeralPeer(process.cwd())
976
+ out(
977
+ `self-done: armed ${identity} for the quiet-window reap (no one woken)` +
978
+ (ephemeral ? '' : ' — NOTE: this peer is not wake_policy:ephemeral, the marker is inert') +
979
+ '\n',
980
+ )
981
+ return 0
982
+ }
892
983
  case 'self-fresh': {
893
984
  // /new AGENT-FACING TRIGGER (TARGET redesign). Run BY the agent itself as the
894
985
  // FINAL step of a /new graceful wind-down (the owner triggers it via a per-peer
@@ -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
+ }
@@ -79,6 +79,16 @@ export interface LifecycleConfig {
79
79
  * transcript mtime is a LIVENESS proxy — "no longer writing" — not a semantic
80
80
  * "done" signal. */
81
81
  ephemeralQuietSecs: number
82
+ /** wake_policy:ephemeral — the UNARMED idle bound (seconds): an ephemeral session
83
+ * that never armed (finished silently / lost its arm to a daemon-restart window)
84
+ * is reaped after this much activity-proxy silence. Live case (scriber 10.06):
85
+ * a worker that ended «тихо» without its final outbound stalled its M3 FIFO for
86
+ * the FULL generic idleSecs (1 h) — with serial drain that blocks the whole
87
+ * conveyor per silent worker. Bound chosen ≫ the legitimate silent-tool case
88
+ * (sleep-180) and ≪ idleSecs. DOCUMENTED RISK: an ephemeral worker whose tool
89
+ * stays silent longer than this MID-TASK is reaped and its consumed queue item
90
+ * is lost — ephemeral workers must emit activity (or their reply) within it. */
91
+ ephemeralUnarmedIdleSecs: number
82
92
  }
83
93
 
84
94
  export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): LifecycleConfig {
@@ -102,6 +112,7 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
102
112
  crashLoopMax: num(env.IAPEER_CRASHLOOP_MAX, 3),
103
113
  crashLoopWindowSecs: num(env.IAPEER_CRASHLOOP_WINDOW_SECS, 300),
104
114
  ephemeralQuietSecs: num(env.IAPEER_EPHEMERAL_QUIET_SECS, 20),
115
+ ephemeralUnarmedIdleSecs: num(env.IAPEER_EPHEMERAL_UNARMED_IDLE_SECS, 600),
105
116
  }
106
117
  }
107
118
 
@@ -1140,6 +1151,30 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1140
1151
  trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
1141
1152
  continue
1142
1153
  }
1154
+ // UNARMED ephemeral idle bound (live case scriber 10.06): a worker that ended
1155
+ // SILENTLY (no final outbound → never armed; or its arm was lost to a CLI/
1156
+ // daemon-restart window) used to wait out the FULL generic idleSecs (1 h) —
1157
+ // and the M3 serial drain waits for the session's death, so ONE silent worker
1158
+ // stalled its whole conveyor. This bound is the defense-in-depth backstop:
1159
+ // ≫ the legitimate silent-tool case (sleep-180), ≪ idleSecs. The ШТАТНЫЙ
1160
+ // silent-finish path is `iapeer self-done` (arm without waking anyone —
1161
+ // Артур's invariant «нет пустых пробуждений» stays intact); this branch only
1162
+ // bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
1163
+ // (ephemeral never resumes), NO recordDeath (the ring counts faults).
1164
+ if (isEphemeralPeer(s.cwd) && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1165
+ killSession(sock, s.identity)
1166
+ clearEphemeralArmed(cfg, s.identity)
1167
+ removeSessionState(cfg, s.identity)
1168
+ out.push({
1169
+ identity: s.identity,
1170
+ action: 'reaped-ephemeral',
1171
+ reason: `unarmed idle ${ageSecs}s (silent-finish backstop; штатный путь — iapeer self-done)`,
1172
+ personality: s.personality,
1173
+ runtime: s.runtime,
1174
+ })
1175
+ trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
1176
+ continue
1177
+ }
1143
1178
  if (ageSecs > cfg.idleSecs) {
1144
1179
  // THE ONLY place .idle-reaped is written: this is the one death the daemon
1145
1180
  // INITIATES. Its presence on the next wake = the session was parked cleanly =
@@ -802,6 +802,69 @@ describe('superviseTick quiet-reap (M2 die-after-reply, real tmux)', () => {
802
802
  },
803
803
  30000,
804
804
  )
805
+
806
+ test.if(tmuxAvailable)(
807
+ 'UNARMED ephemeral past the unarmed idle bound → reaped-ephemeral (silent-finish backstop; live case scriber 10.06)',
808
+ () => {
809
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-eu-root-'))
810
+ const laDir = mkdtempSync(join(tmpdir(), 'iapeer-eu-la-'))
811
+ const cwd = profileCwd(false, true) // ephemeral worker profile
812
+ const env = {
813
+ ...process.env,
814
+ IAPEER_ROOT: root,
815
+ IAPEER_LAUNCHAGENTS_DIR: laDir,
816
+ IAPEER_SOCK_DIR: join(root, 'socks'),
817
+ IAPEER_EPHEMERAL_UNARMED_IDLE_SECS: '30', // ≪ the 60s age below, ≫ quiet 20s
818
+ }
819
+ const cfg = loadLifecycleConfig(env)
820
+ const identity = 'claude-eu'
821
+ const sock = join(root, 'socks', 'tmux-iap-claude-eu.sock')
822
+ const alive = () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity]).status === 0
823
+ try {
824
+ mkdirSync(join(root, 'socks'), { recursive: true })
825
+ spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '300'])
826
+ expect(alive()).toBe(true)
827
+ mkdirSync(cfg.stateDir, { recursive: true })
828
+ writeFileSync(
829
+ join(cfg.stateDir, `${identity}.session`),
830
+ JSON.stringify({ identity, runtime: 'claude', personality: 'eu', cwd, wokeAt: Date.now() - 60_000 }),
831
+ )
832
+ // NOT armed (the worker ended silently) — past the unarmed bound → policy reap
833
+ const o = superviseTick(cfg, { env }).find(x => x.identity === identity)
834
+ expect(o?.action).toBe('reaped-ephemeral')
835
+ expect(o?.reason).toContain('unarmed idle')
836
+ expect(o?.personality).toBe('eu') // M3 drain fields present → queue feeds next
837
+ expect(alive()).toBe(false)
838
+ // policy death: no resume-eligibility, no crash-loop count
839
+ expect(hasIdleReaped(cfg, identity)).toBe(false)
840
+ expect(readDeaths(cfg, identity).length).toBe(0)
841
+ const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
842
+ expect(logged).toContain('outcome=ephemeral-unarmed-bound')
843
+ // a NON-ephemeral peer with the same age is untouched by this bound
844
+ // (its session lives on ITS OWN identity-derived socket — the tick keys
845
+ // sockets on runtime-personality, not on the test's prior sock)
846
+ const plainCwd = profileCwd(false, false)
847
+ const eupSock = join(root, 'socks', 'tmux-iap-claude-eup.sock')
848
+ try {
849
+ writeFileSync(
850
+ join(cfg.stateDir, `claude-eup.session`),
851
+ JSON.stringify({ identity: 'claude-eup', runtime: 'claude', personality: 'eup', cwd: plainCwd, wokeAt: Date.now() - 60_000 }),
852
+ )
853
+ spawnSync('tmux', ['-S', eupSock, 'new-session', '-d', '-s', 'claude-eup', 'sleep', '300'])
854
+ expect(superviseTick(cfg, { env }).find(x => x.identity === 'claude-eup')?.action).toBe('alive')
855
+ } finally {
856
+ spawnSync('tmux', ['-S', eupSock, 'kill-server'], { stdio: 'ignore' })
857
+ rmSync(plainCwd, { recursive: true, force: true })
858
+ }
859
+ } finally {
860
+ spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
861
+ rmSync(root, { recursive: true, force: true })
862
+ rmSync(laDir, { recursive: true, force: true })
863
+ rmSync(cwd, { recursive: true, force: true })
864
+ }
865
+ },
866
+ 30000,
867
+ )
805
868
  })
806
869
 
807
870
  // ─────────────────────────────────────────────────────────────────────────────
@@ -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', () => {