@agfpd/iapeer 0.2.41 → 0.2.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/cli.test.ts +73 -22
- package/src/cli/index.ts +102 -30
- package/src/enable/index.ts +8 -56
- package/src/enable/provisionCommand.ts +5 -2
- package/src/launch/adapters/codex.ts +6 -1
- package/src/launch/launch.test.ts +3 -2
- package/src/lifecycle/index.ts +14 -2
- package/src/onboard/index.ts +3 -3
- package/src/provision/index.ts +10 -39
- package/src/provision/provision.test.ts +7 -8
- package/src/status/index.ts +7 -36
- package/src/status/status.test.ts +8 -12
- package/src/enable/memoryPlugin.test.ts +0 -197
- package/src/enable/memoryPlugin.ts +0 -210
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
7
7
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
10
|
+
import { formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
12
|
import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
@@ -40,9 +40,12 @@ async function register(personality: string, runtime = 'claude', intelligence: '
|
|
|
40
40
|
await upsertPeer({ personality, runtime, cwd: `/tmp/${personality}`, intelligence }, { rootDir: root })
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// ─── v1.2 provision-инверсия at the CLI joints (
|
|
43
|
+
// ─── v1.2 provision-инверсия at the CLI joints (remove → unprovision) ─────────
|
|
44
|
+
// NB: the memory-plugin verb was REMOVED 11.06 (ADR-017 — the plugin form is
|
|
45
|
+
// retired ecosystem-wide; fleet sweeps are the provider's side). The core's
|
|
46
|
+
// remaining v1.2 joints are birth (provision.test.ts) and remove (below).
|
|
44
47
|
|
|
45
|
-
describe('
|
|
48
|
+
describe('remove with a v1.2 provision slot', () => {
|
|
46
49
|
function declareV12Slot(): string {
|
|
47
50
|
const journal = join(root, 'journal.txt')
|
|
48
51
|
const script = join(root, 'fake-provider.sh')
|
|
@@ -61,36 +64,30 @@ describe('memory-plugin / remove with a v1.2 provision slot', () => {
|
|
|
61
64
|
return journal
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
test('memory-plugin
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
let captured = ''
|
|
68
|
-
const origWrite = process.stdout.write
|
|
69
|
-
process.stdout.write = ((s: string | Uint8Array) => {
|
|
70
|
-
captured += typeof s === 'string' ? s : Buffer.from(s).toString('utf8')
|
|
71
|
-
return true
|
|
72
|
-
}) as typeof process.stdout.write
|
|
73
|
-
try {
|
|
74
|
-
const code = await runCli(['memory-plugin', 'on', '--peer', 'alpha'], env())
|
|
75
|
-
expect(code).toBe(0)
|
|
76
|
-
expect(captured).toContain('provision: provider command')
|
|
77
|
-
expect(captured).toContain('alpha (claude): ok')
|
|
78
|
-
} finally {
|
|
79
|
-
process.stdout.write = origWrite
|
|
80
|
-
}
|
|
67
|
+
test('memory-plugin verb is GONE — unknown verb exits with usage (removal 11.06)', async () => {
|
|
68
|
+
const code = await runCli(['memory-plugin', 'on', '--all'], env())
|
|
69
|
+
expect(code).toBe(2) // usage exit — the verb no longer exists
|
|
81
70
|
})
|
|
82
71
|
|
|
83
|
-
test('remove: unprovision runs with occasion=remove BEFORE the purge, outcome reported', async () => {
|
|
72
|
+
test('remove: unprovision runs with occasion=remove BEFORE the purge, outcome reported; cmdlog dir dies with the record', async () => {
|
|
84
73
|
const journal = declareV12Slot()
|
|
85
74
|
await register('beta')
|
|
75
|
+
// identity-keyed tmux command-log dir (0.2.40) — must die with the registry
|
|
76
|
+
// record (the leak class noted at the 0.2.40 acceptance)
|
|
77
|
+
const { mkdirSync: mkd } = await import('fs')
|
|
78
|
+
const cmdDir = join(root, 'logs', 'iapeer', 'tmux-cmdlog', 'claude-beta')
|
|
79
|
+
mkd(cmdDir, { recursive: true })
|
|
80
|
+
writeFileSync(join(cmdDir, 'tmux-server-1.log'), 'x')
|
|
86
81
|
const o = await removePeerCli('beta', { env: env() })
|
|
87
82
|
expect(o.action).toBe('removed')
|
|
88
83
|
expect(o.unprovision).toEqual(['claude:ok'])
|
|
89
|
-
const { readFileSync } = await import('fs')
|
|
84
|
+
const { readFileSync, existsSync: ex } = await import('fs')
|
|
90
85
|
const j = readFileSync(journal, 'utf8')
|
|
91
86
|
expect(j).toContain('unprovision-peer')
|
|
92
87
|
expect(j).toContain('--cwd\n/tmp/beta')
|
|
93
88
|
expect(j).toContain('remove')
|
|
89
|
+
expect(ex(cmdDir)).toBe(false)
|
|
90
|
+
expect(o.purgedState).toContain('tmux-cmdlog/claude-beta')
|
|
94
91
|
})
|
|
95
92
|
})
|
|
96
93
|
|
|
@@ -400,3 +397,57 @@ describe('parseArgs (audit #27 — value beginning with --)', () => {
|
|
|
400
397
|
expect(parseArgs(['x', '--description=']).flags.description).toBe('')
|
|
401
398
|
})
|
|
402
399
|
})
|
|
400
|
+
|
|
401
|
+
// ─── new — the UNCONDITIONAL fresh-restart control (docs/Control-команды §new) ─
|
|
402
|
+
|
|
403
|
+
describe('new (unconditional fresh restart)', () => {
|
|
404
|
+
test('happy path: fresh-slate (un-park + stale markers cleared) → fresh wake with resume:false task:"" → action fresh', async () => {
|
|
405
|
+
await register('alpha')
|
|
406
|
+
const e = env()
|
|
407
|
+
const cfg = loadLifecycleConfig(e)
|
|
408
|
+
// a parked peer with stale markers — /new must override ALL of it
|
|
409
|
+
setStopped(cfg, 'claude-alpha')
|
|
410
|
+
setIdleReaped(cfg, 'claude-alpha')
|
|
411
|
+
const calls: Array<{ personality: string; runtime: string; task: string; resume: boolean }> = []
|
|
412
|
+
const o = await newPeer('alpha', undefined, {
|
|
413
|
+
env: e,
|
|
414
|
+
wakeFn: async args => {
|
|
415
|
+
calls.push(args)
|
|
416
|
+
return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
expect(o.action).toBe('fresh')
|
|
420
|
+
expect(o.runtime).toBe('claude')
|
|
421
|
+
expect(calls).toEqual([{ personality: 'alpha', runtime: 'claude', task: '', resume: false }])
|
|
422
|
+
expect(isStopped(cfg, 'claude-alpha')).toBe(false) // un-parked: /new outranks the C1 stop
|
|
423
|
+
expect(hasIdleReaped(cfg, 'claude-alpha')).toBe(false) // stale marker cleared
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('wake FAILED → action failed with the reason (exit-1 contract for the bot)', async () => {
|
|
427
|
+
await register('beta2')
|
|
428
|
+
const o = await newPeer('beta2', undefined, {
|
|
429
|
+
env: env(),
|
|
430
|
+
wakeFn: async () => ({ status: 'FAILED', woke: false, reason: 'boot hang' }),
|
|
431
|
+
})
|
|
432
|
+
expect(o.action).toBe('failed')
|
|
433
|
+
expect(o.reason).toBe('boot hang')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('refusals are explicit: foreign launchd / infra runtime / undeclared runtime / unknown peer', async () => {
|
|
437
|
+
const e = env()
|
|
438
|
+
// foreign PP plist → fleet guard
|
|
439
|
+
await register('boris')
|
|
440
|
+
writeFileSync(launchdPlistPath('boris', e), '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.boris</string></dict></plist>\n')
|
|
441
|
+
expect((await newPeer('boris', undefined, { env: e })).action).toBe('refused-foreign-launchd')
|
|
442
|
+
// infra runtime → launchd-held, kickstart hint
|
|
443
|
+
await register('tg', 'telegram', 'natural')
|
|
444
|
+
const infra = await newPeer('tg', undefined, { env: e })
|
|
445
|
+
expect(infra.action).toBe('refused-infra')
|
|
446
|
+
expect(infra.reason).toContain('kickstart')
|
|
447
|
+
// undeclared runtime → explicit refusal, no silent launch
|
|
448
|
+
await register('gamma2')
|
|
449
|
+
expect((await newPeer('gamma2', 'codex', { env: e })).action).toBe('refused-undeclared-runtime')
|
|
450
|
+
// unknown peer → throws (same contract as stop/start)
|
|
451
|
+
await expect(newPeer('ghost-nobody', undefined, { env: e })).rejects.toThrow('not registered')
|
|
452
|
+
})
|
|
453
|
+
})
|
package/src/cli/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../regist
|
|
|
27
27
|
import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
|
|
28
28
|
import {
|
|
29
29
|
attachPeer,
|
|
30
|
+
clearIdleReaped,
|
|
31
|
+
clearNewEager,
|
|
30
32
|
clearStopped,
|
|
31
33
|
folderLaunch,
|
|
32
34
|
isLaunchdManaged,
|
|
@@ -41,6 +43,7 @@ import {
|
|
|
41
43
|
setNewEager,
|
|
42
44
|
setStopped,
|
|
43
45
|
wakeOrSpawn,
|
|
46
|
+
type WakeResult,
|
|
44
47
|
} from '../lifecycle/index.ts'
|
|
45
48
|
import { getAdapter } from '../launch/index.ts'
|
|
46
49
|
import { isFoundationOwnedPlist, kickstartDaemon, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
|
|
@@ -269,6 +272,90 @@ export function startPeer(personality: string, runtime: string | undefined, opts
|
|
|
269
272
|
return out
|
|
270
273
|
}
|
|
271
274
|
|
|
275
|
+
export interface NewPeerOutcome {
|
|
276
|
+
personality: string
|
|
277
|
+
runtime: string
|
|
278
|
+
action: 'fresh' | 'refused-foreign-launchd' | 'refused-infra' | 'refused-undeclared-runtime' | 'failed'
|
|
279
|
+
reason?: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* new <peer> [runtime] — the UNCONDITIONAL fresh-restart control command
|
|
284
|
+
* (docs/Control-команды §new; постановка boris 01:32 12.06, санкция Артура).
|
|
285
|
+
*
|
|
286
|
+
* The emergency lever for a HUNG or dead agent session: /alias-new covers only
|
|
287
|
+
* the COOPERATIVE path (a live peer reads the expanded prompt and runs
|
|
288
|
+
* `iapeer self-fresh`); a stuck/raving/dead peer never reads a prompt — this
|
|
289
|
+
* command restarts it MECHANICALLY, bypassing the peer entirely:
|
|
290
|
+
* fresh-slate markers (un-park C1 stop, clear stale .idle-reaped/.new-eager) →
|
|
291
|
+
* canary-clean teardown of any live session (killSession — a deliberate kill,
|
|
292
|
+
* never a death class; session-state dropped so supervise stays silent) →
|
|
293
|
+
* wakeOrSpawn(resume:false, task:'') — fresh BY CONSTRUCTION, the same recipe
|
|
294
|
+
* as the eager relaunch (the C2 initial_prompt seeds the first turn).
|
|
295
|
+
*
|
|
296
|
+
* exit-0 contract (сверка telegram-runtime): success ⟺ the fresh session is UP
|
|
297
|
+
* and READY (verified by the wake's ready gate, not merely scheduled) — for a
|
|
298
|
+
* sleeping, dead AND hung target alike. Duration is a real TUI boot: typically
|
|
299
|
+
* 5–30 s, bounded by cfg.bootDeadlineSecs. Idempotent in effect: each repeat
|
|
300
|
+
* leaves exactly one fresh live session (concurrent calls serialize on the
|
|
301
|
+
* wake lock). REFUSES foreign-launchd peers (fleet guard) and infra runtimes
|
|
302
|
+
* (launchd-held — their restart is `launchctl kickstart`'s domain), like
|
|
303
|
+
* stop/start; an explicitly-passed runtime the peer does not declare is an
|
|
304
|
+
* explicit refusal (never a silent launch of an undeclared runtime).
|
|
305
|
+
*/
|
|
306
|
+
export async function newPeer(
|
|
307
|
+
personality: string,
|
|
308
|
+
runtime: string | undefined,
|
|
309
|
+
opts: CliEnvOptions & { wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: false }) => Promise<WakeResult> } = {},
|
|
310
|
+
): Promise<NewPeerOutcome> {
|
|
311
|
+
const env = opts.env ?? process.env
|
|
312
|
+
const cfg = loadLifecycleConfig(env)
|
|
313
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
314
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
315
|
+
if (isForeignLaunchd(personality, env)) {
|
|
316
|
+
return {
|
|
317
|
+
personality,
|
|
318
|
+
runtime: peer.runtime,
|
|
319
|
+
action: 'refused-foreign-launchd',
|
|
320
|
+
reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not restart it`,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
324
|
+
const rt: Runtime = (runtime as Runtime | undefined) ?? peer.runtime
|
|
325
|
+
if (!peer.runtimes.includes(rt)) {
|
|
326
|
+
return {
|
|
327
|
+
personality,
|
|
328
|
+
runtime: rt,
|
|
329
|
+
action: 'refused-undeclared-runtime',
|
|
330
|
+
reason: `peer "${personality}" does not declare runtime "${rt}" (declared: ${peer.runtimes.join(', ')})`,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (isInfraRuntime(rt)) {
|
|
334
|
+
return {
|
|
335
|
+
personality,
|
|
336
|
+
runtime: rt,
|
|
337
|
+
action: 'refused-infra',
|
|
338
|
+
reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${launchdLabel(personality)}`,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const identity = buildProcessAddress(rt, personality)
|
|
342
|
+
const sock = buildSocketPath(rt, personality, cfg.sockDir)
|
|
343
|
+
// Fresh slate: an explicit operator /new outranks a C1 park (it demands a live
|
|
344
|
+
// fresh session NOW) and any stale death/fresh markers (the wake below is fresh
|
|
345
|
+
// by construction — markers must not leak into a LATER wake's decision).
|
|
346
|
+
clearStopped(cfg, identity)
|
|
347
|
+
clearIdleReaped(cfg, identity)
|
|
348
|
+
clearNewEager(cfg, identity)
|
|
349
|
+
killSession(sock, identity) // canary-clean deliberate teardown; no-op when dead
|
|
350
|
+
removeSessionState(cfg, identity) // never a death class — supervise stays silent
|
|
351
|
+
const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
|
|
352
|
+
const r = await wake({ personality, runtime: rt, task: '', resume: false })
|
|
353
|
+
if (r.status !== 'READY') {
|
|
354
|
+
return { personality, runtime: rt, action: 'failed', reason: r.reason ?? 'wake failed' }
|
|
355
|
+
}
|
|
356
|
+
return { personality, runtime: rt, action: 'fresh' }
|
|
357
|
+
}
|
|
358
|
+
|
|
272
359
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
360
|
// remove — delete a peer's record from the registry through the LOCKED writer
|
|
274
361
|
// (registry.removePeer). Direct edits of peers-profiles.json are refused at
|
|
@@ -565,13 +652,13 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
565
652
|
<runtime> launch the cwd's peer (ALWAYS fresh)
|
|
566
653
|
connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
|
|
567
654
|
enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
|
|
655
|
+
new <peer> [runtime] UNCONDITIONAL fresh restart: canary-clean kill + fresh wake (emergency lever for a hung/dead session — bypasses the peer; exit 0 = fresh session up+ready)
|
|
568
656
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
569
657
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
570
658
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
571
659
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
572
660
|
self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
|
|
573
661
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
574
|
-
memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
|
|
575
662
|
trust-hooks <hooks.json> [--check] pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report
|
|
576
663
|
`
|
|
577
664
|
|
|
@@ -702,35 +789,6 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
702
789
|
}
|
|
703
790
|
return failed ? 1 : 0
|
|
704
791
|
}
|
|
705
|
-
case 'memory-plugin': {
|
|
706
|
-
// Slot-derived provider-plugin install/removal (контракт «Слот памяти»
|
|
707
|
-
// §Плагин провайдера; agreed iapeer-memory + boris 10.06). The plugin
|
|
708
|
-
// identity comes from the slot declaration's v1.1 plugin block — empty
|
|
709
|
-
// slot / v1 declaration → explicit refusal (generic `enable` exists for
|
|
710
|
-
// arbitrary plugins). Forms live in enable/ (ONE home); codex off is
|
|
711
|
-
// host-global (only --all removes; --peer reports it explicitly).
|
|
712
|
-
const state = positionals[0]
|
|
713
|
-
if (state !== 'on' && state !== 'off') return usage(errOut)
|
|
714
|
-
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
715
|
-
if (flags.all !== true && !peerName) return usage(errOut)
|
|
716
|
-
const { memoryPluginApply } = await import('../enable/memoryPlugin.ts')
|
|
717
|
-
const r = memoryPluginApply(state, { peer: peerName, all: flags.all === true, env })
|
|
718
|
-
if (r.error) {
|
|
719
|
-
errOut(`memory-plugin: ${r.error}\n`)
|
|
720
|
-
return 1
|
|
721
|
-
}
|
|
722
|
-
// v1.2: a provision-declaring slot routes through the provider's commands —
|
|
723
|
-
// r.plugin is null then (there is no marketplace plugin in play to print).
|
|
724
|
-
out(
|
|
725
|
-
r.plugin
|
|
726
|
-
? `plugin: ${r.plugin.name}@${r.plugin.marketplace} (provider-declared)\n`
|
|
727
|
-
: `provision: provider command (declaration v1.2, occasion=${state === 'on' ? 'sweep-on' : flags.all === true ? 'off-all' : 'off-peer'})\n`,
|
|
728
|
-
)
|
|
729
|
-
for (const o of r.outcomes) {
|
|
730
|
-
out(`${o.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
731
|
-
}
|
|
732
|
-
return r.ok ? 0 : 1
|
|
733
|
-
}
|
|
734
792
|
case 'trust-hooks': {
|
|
735
793
|
// Deterministic pre-seed of the codex hooks trust state for a FILE-form
|
|
736
794
|
// hooks.json (user- or project-local layer) — the headless replacement for
|
|
@@ -978,6 +1036,20 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
978
1036
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
979
1037
|
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
980
1038
|
}
|
|
1039
|
+
case 'new': {
|
|
1040
|
+
// UNCONDITIONAL fresh restart (control, system class — docs/Control-команды
|
|
1041
|
+
// §new): the emergency lever for a hung/dead session, bypasses the peer.
|
|
1042
|
+
// Source: operator CLI, or telegram-runtime's clean-/new detect (their bot
|
|
1043
|
+
// shells `iapeer new <peer> <runtime>` — exit 0 ⟺ fresh session up+ready).
|
|
1044
|
+
if (!positionals[0]) return usage(errOut)
|
|
1045
|
+
const o = await newPeer(positionals[0], positionals[1], { env })
|
|
1046
|
+
if (o.action === 'fresh') {
|
|
1047
|
+
out(`new: ${o.runtime}-${o.personality} fresh session up\n`)
|
|
1048
|
+
return 0
|
|
1049
|
+
}
|
|
1050
|
+
errOut(`new: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
1051
|
+
return 1
|
|
1052
|
+
}
|
|
981
1053
|
case 'start': {
|
|
982
1054
|
// --all re-enables every REGISTERED peer (mirror of `stop --all` — the 11.06
|
|
983
1055
|
// incident: bringing a stopped fleet back required a manual loop over ~25
|
package/src/enable/index.ts
CHANGED
|
@@ -398,8 +398,9 @@ function enableRuntimeWithRetry(
|
|
|
398
398
|
* The registry-FREE enable core: install per-runtime (claude project-scope IN cwd /
|
|
399
399
|
* codex global) + enable + `setup` ONLY if declared — with marketplace ensure
|
|
400
400
|
* (marketplaceRef given + not registered → add) and the stale-snapshot retry.
|
|
401
|
-
*
|
|
402
|
-
*
|
|
401
|
+
* Consumer: enableCapability (the peer-resolved generic `iapeer enable` verb) —
|
|
402
|
+
* ONE home of the install forms. (The memory-plugin birth-hook and verb consumers
|
|
403
|
+
* were removed 11.06 — the provider plugin form is retired, ADR-017.)
|
|
403
404
|
*/
|
|
404
405
|
export function enableCapabilityForCwd(o: CwdEnableOptions): EnableResult {
|
|
405
406
|
const env = o.env ?? process.env
|
|
@@ -472,58 +473,9 @@ export function enableCapability(opts: EnableOptions): EnableResult {
|
|
|
472
473
|
}
|
|
473
474
|
|
|
474
475
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
475
|
-
// Removal forms
|
|
476
|
-
//
|
|
477
|
-
//
|
|
476
|
+
// Removal forms — REMOVED 11.06 with the memory-plugin verb (their only caller;
|
|
477
|
+
// ADR-017 retired the provider plugin form). Historical verb names, verified
|
|
478
|
+
// live 10.06, for a future generic `disable`: claude `plugin uninstall <id>
|
|
479
|
+
// --scope project` (run in the peer cwd), codex `plugin remove <id>`
|
|
480
|
+
// (host-global). See git history ≤v0.2.41 for the implementations.
|
|
478
481
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
479
|
-
|
|
480
|
-
export type RuntimeRemoveState = 'removed' | 'absent' | 'runtime-missing' | 'failed'
|
|
481
|
-
|
|
482
|
-
export interface RuntimeRemoveResult {
|
|
483
|
-
runtime: CapabilityRuntime
|
|
484
|
-
state: RuntimeRemoveState
|
|
485
|
-
detail?: string
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/** claude: uninstall the peer-scoped (projectPath-keyed) entry — fleet-safe by the
|
|
489
|
-
* same key enable uses; a peer without an entry is 'absent' (idempotent). */
|
|
490
|
-
export function removeClaudeForCwd(
|
|
491
|
-
plugin: string,
|
|
492
|
-
marketplace: string,
|
|
493
|
-
peerCwd: string,
|
|
494
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
495
|
-
): RuntimeRemoveResult {
|
|
496
|
-
const bin = claudeBin(env)
|
|
497
|
-
if (!isExecutable(bin, env)) return { runtime: 'claude', state: 'runtime-missing' }
|
|
498
|
-
const id = `${plugin}@${marketplace}`
|
|
499
|
-
const existing = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
500
|
-
if (!existing) return { runtime: 'claude', state: 'absent' }
|
|
501
|
-
const r = spawnSync(bin, ['plugin', 'uninstall', id, '--scope', 'project'], {
|
|
502
|
-
cwd: peerCwd,
|
|
503
|
-
encoding: 'utf8',
|
|
504
|
-
env: env as Record<string, string>,
|
|
505
|
-
})
|
|
506
|
-
const after = claudeEntry(bin, plugin, marketplace, peerCwd, env)
|
|
507
|
-
if (after) {
|
|
508
|
-
return { runtime: 'claude', state: 'failed', detail: (r.stderr || r.stdout || `exit ${r.status}`).trim() }
|
|
509
|
-
}
|
|
510
|
-
return { runtime: 'claude', state: 'removed' }
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/** codex: remove the HOST-GLOBAL plugin (codex has no project scope) — callers gate
|
|
514
|
-
* this on an explicit fleet-wide intent (`memory-plugin off --all`). */
|
|
515
|
-
export function removeCodexGlobal(
|
|
516
|
-
plugin: string,
|
|
517
|
-
marketplace: string,
|
|
518
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
519
|
-
): RuntimeRemoveResult {
|
|
520
|
-
const bin = codexBin(env)
|
|
521
|
-
if (!isExecutable(bin, env)) return { runtime: 'codex', state: 'runtime-missing' }
|
|
522
|
-
const id = `${plugin}@${marketplace}`
|
|
523
|
-
if (codexState(bin, id, env) === 'absent') return { runtime: 'codex', state: 'absent' }
|
|
524
|
-
const r = spawnSync(bin, ['plugin', 'remove', id], { encoding: 'utf8', env: env as Record<string, string> })
|
|
525
|
-
if (codexState(bin, id, env) !== 'absent') {
|
|
526
|
-
return { runtime: 'codex', state: 'failed', detail: (r.stderr || r.stdout || `exit ${r.status}`).trim() }
|
|
527
|
-
}
|
|
528
|
-
return { runtime: 'codex', state: 'removed' }
|
|
529
|
-
}
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
// 2. `command` is ABSOLUTE (parser refuses relative — launchd minimal PATH).
|
|
9
9
|
// 3. Timeout 120 s; best-effort semantics live at the CALL SITES (LOUD warn,
|
|
10
10
|
// the birth/remove flow continues) — this module only reports structurally.
|
|
11
|
-
// 4. {occasion} vocabulary: birth | sweep-on |
|
|
12
|
-
// ref-counting of host-global surfaces is the
|
|
11
|
+
// 4. {occasion} vocabulary (финален, contract §Provision): birth | sweep-on |
|
|
12
|
+
// off-peer | off-all | remove — ref-counting of host-global surfaces is the
|
|
13
|
+
// PROVIDER's business. Since the memory-plugin verb removal (11.06, ADR-017)
|
|
14
|
+
// the CORE emits only `birth` and `remove`; the sweep occasions remain part
|
|
15
|
+
// of the provider-command interface (their CLI may use them internally).
|
|
13
16
|
// Idempotency is the provider's obligation by construction; the provider holds
|
|
14
17
|
// its own lock against concurrent provisions.
|
|
15
18
|
|
|
@@ -319,11 +319,16 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
319
319
|
* interrupts the turn — NOT a double. (Codex's own footer says "esc to
|
|
320
320
|
* interrupt"; one Escape yields "■ Conversation interrupted". The contract's
|
|
321
321
|
* open "×1/×2?" is resolved to ×1, same as claude.) Session + context intact.
|
|
322
|
-
* - compact →
|
|
322
|
+
* - compact → type '/compact' then Enter. SNAPPED LIVE 12.06 (codex-cli
|
|
323
|
+
* 0.138.0): the slash EXISTS now — autocomplete documents it («summarize
|
|
324
|
+
* conversation to prevent hitting the context limit») and a live run
|
|
325
|
+
* yields «• Context compacted». The 0.136-era "codex has no /compact"
|
|
326
|
+
* finding is stale; the mapping is keyboard-identical to claude's.
|
|
323
327
|
* - anything else → null.
|
|
324
328
|
*/
|
|
325
329
|
executeControl(command: ControlCommand): ControlPlan | null {
|
|
326
330
|
if (command.name === 'interrupt') return { sequence: [['Escape']] }
|
|
331
|
+
if (command.name === 'compact') return { sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 }
|
|
327
332
|
return null
|
|
328
333
|
},
|
|
329
334
|
}
|
|
@@ -296,9 +296,10 @@ describe('executeControl (Ф-E control commands)', () => {
|
|
|
296
296
|
expect(claudeAdapter.executeControl({ name: 'compact' })).toEqual({ sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 })
|
|
297
297
|
expect(claudeAdapter.executeControl({ name: 'bogus' })).toBeNull()
|
|
298
298
|
})
|
|
299
|
-
test('codex: interrupt → [Escape] (×1
|
|
299
|
+
test('codex: interrupt → [Escape] (×1, snapped live); compact → /compact + Enter (snapped live 12.06, codex-cli 0.138)', () => {
|
|
300
300
|
expect(codexAdapter.executeControl({ name: 'interrupt' })).toEqual({ sequence: [['Escape']] })
|
|
301
|
-
expect(codexAdapter.executeControl({ name: 'compact' })).
|
|
301
|
+
expect(codexAdapter.executeControl({ name: 'compact' })).toEqual({ sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 })
|
|
302
|
+
expect(codexAdapter.executeControl({ name: 'bogus' })).toBeNull()
|
|
302
303
|
})
|
|
303
304
|
test('routers (telegram/notifier) refuse all control (no TUI turn)', () => {
|
|
304
305
|
expect(telegramAdapter.executeControl({ name: 'interrupt' })).toBeNull()
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from '../core/constants.ts'
|
|
27
27
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
28
28
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
|
-
import { capCmdLogs } from '../launch/cmdlog.ts'
|
|
29
|
+
import { capCmdLogs, cmdLogDirFor } from '../launch/cmdlog.ts'
|
|
30
30
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
31
31
|
import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
|
|
32
32
|
import {
|
|
@@ -422,7 +422,7 @@ export function purgeIdentityState(cfg: LifecycleConfig, identity: string): stri
|
|
|
422
422
|
try {
|
|
423
423
|
entries = readdirSync(cfg.stateDir)
|
|
424
424
|
} catch {
|
|
425
|
-
|
|
425
|
+
entries = [] // no state dir → no markers; the cmdlog purge below still runs
|
|
426
426
|
}
|
|
427
427
|
for (const name of entries) {
|
|
428
428
|
if (!name.startsWith(`${identity}.`)) continue
|
|
@@ -433,6 +433,18 @@ export function purgeIdentityState(cfg: LifecycleConfig, identity: string): stri
|
|
|
433
433
|
/* best-effort — a locked/vanished entry must not fail the remove */
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
|
+
// tmux command-log dir (0.2.40 observability) — identity-keyed like the state
|
|
437
|
+
// markers above, so it must die with the registry record too (the leak class
|
|
438
|
+
// noted at the 0.2.40 acceptance: a removed peer left its cmdlog dir forever).
|
|
439
|
+
try {
|
|
440
|
+
const cmdDir = cmdLogDirFor(cfg.eventLogDir, identity)
|
|
441
|
+
if (existsSync(cmdDir)) {
|
|
442
|
+
rmSync(cmdDir, { recursive: true, force: true })
|
|
443
|
+
removed.push(`tmux-cmdlog/${identity}`)
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
/* best-effort */
|
|
447
|
+
}
|
|
436
448
|
return removed
|
|
437
449
|
}
|
|
438
450
|
|
package/src/onboard/index.ts
CHANGED
|
@@ -124,8 +124,8 @@ export function isAgfpdInList(listOutput: string): boolean {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** Register a marketplace for this runtime (`<runtime> plugin marketplace add <ref>`).
|
|
127
|
-
* Exported for the
|
|
128
|
-
*
|
|
127
|
+
* Exported for the generic capability-enable path (a third-party marketplaceRef
|
|
128
|
+
* must self-register on a fresh host). */
|
|
129
129
|
export function registerMarketplace(
|
|
130
130
|
runtime: OnboardRuntime,
|
|
131
131
|
env: NodeJS.ProcessEnv,
|
|
@@ -142,7 +142,7 @@ export function registerMarketplace(
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/** Is a marketplace (by name/ref) registered for this runtime? Generalized form of
|
|
145
|
-
* isMarketplaceRegistered for the
|
|
145
|
+
* isMarketplaceRegistered for the generic capability-enable path. */
|
|
146
146
|
export function isMarketplaceRegisteredAs(
|
|
147
147
|
runtime: OnboardRuntime,
|
|
148
148
|
name: string,
|
package/src/provision/index.ts
CHANGED
|
@@ -125,11 +125,11 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
125
125
|
// v1.2 — ADR-009 inversion; решение Артура 10.06, схема сверена с
|
|
126
126
|
// iapeer-memory 11.06): an occupied slot WITH a provision block shells into
|
|
127
127
|
// the PROVIDER's command per agentic runtime (occasion=birth) — the core
|
|
128
|
-
// knows nothing about surface forms.
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
128
|
+
// knows nothing about surface forms. A failure is NEVER masked (a broken
|
|
129
|
+
// provider must not hide) — LOUD warn; repair is provider-side (the core is
|
|
130
|
+
// provider-agnostic and names no command). Runs AFTER preTrustCodexCwd
|
|
131
|
+
// above, so the provider's per-peer codex surfaces land in an already-
|
|
132
|
+
// trusted cwd.
|
|
133
133
|
if (slot.provision) {
|
|
134
134
|
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
135
135
|
const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
|
|
@@ -161,44 +161,15 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
161
161
|
opts.warn?.(
|
|
162
162
|
`memory provision (${rt}) ${o.state.toUpperCase()} for "${profile.personality}"` +
|
|
163
163
|
`${o.detail ? `: ${o.detail}` : ''} — память пира не подключена; ` +
|
|
164
|
-
`repair:
|
|
164
|
+
`repair: re-run the provider's provision for this peer (the provider's own CLI — verify/repair/init)`,
|
|
165
165
|
)
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// project-scope in cwd; codex host-global, idempotent) — «создал пира —
|
|
174
|
-
// память работает». A v1 declaration (no block) installs nothing.
|
|
175
|
-
// Best-effort with a LOUD warn; repair = `iapeer memory-plugin on --peer`.
|
|
176
|
-
else if (slot.plugin) {
|
|
177
|
-
const { enableCapabilityForCwd } = await import('../enable/index.ts')
|
|
178
|
-
const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
|
|
179
|
-
if (agentic.length > 0) {
|
|
180
|
-
const r = enableCapabilityForCwd({
|
|
181
|
-
plugin: slot.plugin.name,
|
|
182
|
-
marketplace: slot.plugin.marketplace,
|
|
183
|
-
marketplaceRef: slot.plugin.marketplaceRef,
|
|
184
|
-
cwd,
|
|
185
|
-
personality: profile.personality,
|
|
186
|
-
runtimes: agentic,
|
|
187
|
-
env,
|
|
188
|
-
})
|
|
189
|
-
for (const rr of r.runtimes) {
|
|
190
|
-
if (rr.state === 'failed') {
|
|
191
|
-
opts.warn?.(
|
|
192
|
-
`memory-plugin install (${rr.runtime}) FAILED for "${profile.personality}": ${rr.detail} — ` +
|
|
193
|
-
`repair: iapeer memory-plugin on --peer ${profile.personality}`,
|
|
194
|
-
)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (r.setup === 'failed') {
|
|
198
|
-
opts.warn?.(`memory-plugin setup FAILED for "${profile.personality}": ${r.setupDetail ?? ''}`)
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
169
|
+
// NB: the v1.1 plugin auto-install branch (an occupied slot WITH a `plugin`
|
|
170
|
+
// block) was REMOVED 11.06 — the plugin form is retired ecosystem-wide
|
|
171
|
+
// (provider's ADR-017; contract §Плагин провайдера is a tombstone). A v1.2
|
|
172
|
+
// declaration without a provision block provisions nothing here.
|
|
202
173
|
}
|
|
203
174
|
} catch (e) {
|
|
204
175
|
opts.warn?.(`memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
@@ -199,7 +199,7 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
199
199
|
expect(existsSync(join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
200
200
|
})
|
|
201
201
|
|
|
202
|
-
test('
|
|
202
|
+
test('LEGACY v1.1 plugin block in the declaration → ignored entirely (form removed 11.06, ADR-017); native lever still applies', async () => {
|
|
203
203
|
const root = mkTmp()
|
|
204
204
|
const env = envFor(root)
|
|
205
205
|
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
@@ -210,21 +210,20 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
210
210
|
package: '@agfpd/iapeer-memory',
|
|
211
211
|
version: '0.1.0',
|
|
212
212
|
registeredAt: 'x',
|
|
213
|
+
// a stale v1.1 declaration on a legacy host — the core no longer parses it
|
|
213
214
|
plugin: { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' },
|
|
214
215
|
}),
|
|
215
216
|
)
|
|
216
217
|
const cwd = join(root, 'wplug')
|
|
217
218
|
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
219
|
const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
221
220
|
expect(r.personality).toBe('wplug')
|
|
222
|
-
expect(warns
|
|
221
|
+
expect(warns).toEqual([]) // no install attempt, no warn — the block is unknown noise
|
|
223
222
|
// the native lever still applied (same slot-gated block)
|
|
224
223
|
expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
|
|
225
224
|
})
|
|
226
225
|
|
|
227
|
-
test('v1.2: slot with provision command → birth shells into it per runtime (occasion=birth)
|
|
226
|
+
test('v1.2: slot with provision command → birth shells into it per runtime (occasion=birth); a legacy plugin block alongside is inert', async () => {
|
|
228
227
|
const root = mkTmp()
|
|
229
228
|
const env = envFor(root)
|
|
230
229
|
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
@@ -240,7 +239,7 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
240
239
|
package: '@agfpd/iapeer-memory',
|
|
241
240
|
version: '0.2.0',
|
|
242
241
|
registeredAt: 'x',
|
|
243
|
-
//
|
|
242
|
+
// legacy plugin block alongside (stale declaration) — must be inert
|
|
244
243
|
plugin: { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' },
|
|
245
244
|
provision: {
|
|
246
245
|
command: script,
|
|
@@ -258,7 +257,7 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
258
257
|
expect(j.slice(1, 9)).toEqual(['--cwd', cwd, '--runtime', 'claude', '--personality', 'wprov', '--occasion', 'birth'])
|
|
259
258
|
})
|
|
260
259
|
|
|
261
|
-
test('v1.2: provision command FAILS → LOUD warn
|
|
260
|
+
test('v1.2: provision command FAILS → LOUD warn (provider-side repair), provision of the peer still SUCCEEDS (best-effort)', async () => {
|
|
262
261
|
const root = mkTmp()
|
|
263
262
|
const env = envFor(root)
|
|
264
263
|
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
@@ -284,6 +283,6 @@ describe('provisionPeer birth-time native-memory lever', () => {
|
|
|
284
283
|
expect(w).toBeDefined()
|
|
285
284
|
expect(w).toContain('FAILED')
|
|
286
285
|
expect(w).toContain('vault offline')
|
|
287
|
-
expect(w).toContain(
|
|
286
|
+
expect(w).toContain("re-run the provider's provision") // provider-side repair, no core verb named
|
|
288
287
|
})
|
|
289
288
|
})
|
package/src/status/index.ts
CHANGED
|
@@ -17,21 +17,10 @@ import { iapeerBinPath } from '../install/index.ts'
|
|
|
17
17
|
/** The slot-declaration filename in the storage root (next to peers-profiles.json). */
|
|
18
18
|
export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* treated as ABSENT (fail-open, like the whole file) = v1 behavior, no install. */
|
|
25
|
-
export interface MemoryProviderPlugin {
|
|
26
|
-
/** Plugin id in the marketplace (forms `<name>@<marketplace>` for installs). */
|
|
27
|
-
name: string
|
|
28
|
-
/** Marketplace NAME the plugin id keys on (e.g. "agfpd"). */
|
|
29
|
-
marketplace: string
|
|
30
|
-
/** Source ref for `plugin marketplace add` when the marketplace is not yet
|
|
31
|
-
* registered on the host (owner/repo or URL) — the third-party-provider path;
|
|
32
|
-
* for the distribution default it matches what onboard already adds. */
|
|
33
|
-
marketplaceRef: string
|
|
34
|
-
}
|
|
20
|
+
// NB: the v1.1 `plugin` block (MemoryProviderPlugin + its parser) was REMOVED
|
|
21
|
+
// 11.06 (provider's ADR-017 retired the plugin form ecosystem-wide; contract
|
|
22
|
+
// §Плагин провайдера is a tombstone). An unknown `plugin` key in a legacy
|
|
23
|
+
// declaration is simply ignored — the same fail-open as any unknown block.
|
|
35
24
|
|
|
36
25
|
/** The provider's provision command (declaration v1.2, контракт §Provision
|
|
37
26
|
* провайдера — ADR-009 inversion, решение Артура 10.06 + дизайн-консенсус
|
|
@@ -58,30 +47,14 @@ export interface MemoryProvider {
|
|
|
58
47
|
/** Optional liveness proxy: an absolute path whose mtime the provider's daemon
|
|
59
48
|
* refreshes. status reports its age; the core takes NO action on staleness. */
|
|
60
49
|
heartbeat?: string
|
|
61
|
-
/** Optional
|
|
62
|
-
* DEPRECATED by v1.2: when `provision` is also present, provision WINS and
|
|
63
|
-
* the plugin block is ignored (no runtime fallback — an explicit precedence,
|
|
64
|
-
* agreed iapeer-memory 11.06). */
|
|
65
|
-
plugin?: MemoryProviderPlugin
|
|
66
|
-
/** Optional v1.2 provision command (peer-birth / sweep-on joints). */
|
|
50
|
+
/** Optional v1.2 provision command (the peer-birth joint). */
|
|
67
51
|
provision?: MemoryProviderProvision
|
|
68
|
-
/** Optional v1.2 unprovision command (
|
|
52
|
+
/** Optional v1.2 unprovision command (the remove joint). */
|
|
69
53
|
unprovision?: MemoryProviderProvision
|
|
70
54
|
}
|
|
71
55
|
|
|
72
|
-
/** Parse the optional v1.1 `plugin` block; anything short of three non-empty
|
|
73
|
-
* strings → undefined (treated as absent — v1 declaration, core installs nothing). */
|
|
74
|
-
function parsePluginBlock(raw: unknown): MemoryProviderPlugin | undefined {
|
|
75
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
76
|
-
const o = raw as Record<string, unknown>
|
|
77
|
-
if (typeof o.name !== 'string' || !o.name.trim()) return undefined
|
|
78
|
-
if (typeof o.marketplace !== 'string' || !o.marketplace.trim()) return undefined
|
|
79
|
-
if (typeof o.marketplaceRef !== 'string' || !o.marketplaceRef.trim()) return undefined
|
|
80
|
-
return { name: o.name.trim(), marketplace: o.marketplace.trim(), marketplaceRef: o.marketplaceRef.trim() }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
56
|
/** Parse an optional v1.2 `provision`/`unprovision` block. Fail-open like the
|
|
84
|
-
*
|
|
57
|
+
* whole file: anything short of an ABSOLUTE command string + an array of
|
|
85
58
|
* strings → undefined (treated as absent). A relative command is INVALID by
|
|
86
59
|
* contract (launchd minimal PATH would resolve it differently per caller). */
|
|
87
60
|
function parseProvisionBlock(raw: unknown): MemoryProviderProvision | undefined {
|
|
@@ -108,7 +81,6 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
108
81
|
if (typeof o.provider !== 'string' || !o.provider.trim()) return null
|
|
109
82
|
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
110
83
|
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
111
|
-
const plugin = parsePluginBlock(o.plugin)
|
|
112
84
|
const provision = parseProvisionBlock(o.provision)
|
|
113
85
|
const unprovision = parseProvisionBlock(o.unprovision)
|
|
114
86
|
return {
|
|
@@ -117,7 +89,6 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
117
89
|
version: o.version.trim(),
|
|
118
90
|
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
119
91
|
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
120
|
-
...(plugin ? { plugin } : {}),
|
|
121
92
|
...(provision ? { provision } : {}),
|
|
122
93
|
...(unprovision ? { unprovision } : {}),
|
|
123
94
|
}
|
|
@@ -62,23 +62,19 @@ describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
|
62
62
|
}
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
test('v1.1 plugin block
|
|
65
|
+
test('LEGACY v1.1 plugin block (any shape) → ignored as unknown noise; the declaration itself stays valid (form removed 11.06, ADR-017)', () => {
|
|
66
66
|
const root = mkTmp()
|
|
67
67
|
const env = envFor(root)
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{ name: 'x', marketplace: 'agfpd' }, // missing marketplaceRef
|
|
74
|
-
{ name: '', marketplace: 'agfpd', marketplaceRef: 'r' }, // empty name
|
|
75
|
-
'iapeer-memory', // not an object
|
|
76
|
-
['x'], // array
|
|
68
|
+
for (const legacy of [
|
|
69
|
+
{ name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' }, // a complete v1.1 block
|
|
70
|
+
{ name: 'x', marketplace: 'agfpd' },
|
|
71
|
+
'iapeer-memory',
|
|
72
|
+
['x'],
|
|
77
73
|
]) {
|
|
78
|
-
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, plugin:
|
|
74
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, plugin: legacy }))
|
|
79
75
|
const p = readMemoryProvider(env)
|
|
80
76
|
expect(p).not.toBeNull()
|
|
81
|
-
expect(p?.plugin).toBeUndefined()
|
|
77
|
+
expect((p as Record<string, unknown> | null)?.plugin).toBeUndefined() // not parsed at all
|
|
82
78
|
}
|
|
83
79
|
})
|
|
84
80
|
|
|
@@ -1,197 +0,0 @@
|
|
|
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
|
-
})
|
|
104
|
-
|
|
105
|
-
// ─── v1.2 provision-инверсия (контракт §Provision провайдера, ADR-009) ───────
|
|
106
|
-
|
|
107
|
-
import { chmodSync, readFileSync, existsSync } from 'fs'
|
|
108
|
-
|
|
109
|
-
/** Declare a v1.2 slot whose provision/unprovision are fake journaling scripts. */
|
|
110
|
-
function declareProvisionSlot(o: { provision?: boolean; unprovision?: boolean; alsoPlugin?: boolean; failing?: boolean }): {
|
|
111
|
-
journal: string
|
|
112
|
-
} {
|
|
113
|
-
const journal = join(root, 'journal.txt')
|
|
114
|
-
const script = join(root, 'fake-provider.sh')
|
|
115
|
-
writeFileSync(
|
|
116
|
-
script,
|
|
117
|
-
o.failing ? `#!/bin/sh\necho provider broken >&2\nexit 7\n` : `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`,
|
|
118
|
-
)
|
|
119
|
-
chmodSync(script, 0o755)
|
|
120
|
-
const block = (occ: string) => ({
|
|
121
|
-
command: script,
|
|
122
|
-
args: [occ, '--cwd', '{cwd}', '--runtime', '{runtime}', '--personality', '{personality}', '--occasion', '{occasion}'],
|
|
123
|
-
})
|
|
124
|
-
writeFileSync(
|
|
125
|
-
memoryProviderPath(env()),
|
|
126
|
-
JSON.stringify({
|
|
127
|
-
...DECLARATION,
|
|
128
|
-
...(o.alsoPlugin ? { plugin: PLUGIN } : {}),
|
|
129
|
-
...(o.provision !== false ? { provision: block('provision-peer') } : {}),
|
|
130
|
-
...(o.unprovision !== false ? { unprovision: block('unprovision-peer') } : {}),
|
|
131
|
-
}),
|
|
132
|
-
)
|
|
133
|
-
return { journal }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
describe('memory-plugin v1.2 provision routing', () => {
|
|
137
|
-
test('on --peer: provision command invoked per agentic runtime with occasion=sweep-on, substituted argv', async () => {
|
|
138
|
-
const { journal } = declareProvisionSlot({})
|
|
139
|
-
await upsertPeer({ personality: 'alpha', runtime: 'claude', cwd: join(root, 'alpha'), intelligence: 'artificial' }, { rootDir: root })
|
|
140
|
-
const r = memoryPluginApply('on', { peer: 'alpha', env: env() })
|
|
141
|
-
expect(r.ok).toBe(true)
|
|
142
|
-
expect(r.outcomes).toEqual([{ personality: 'alpha', runtime: 'claude', state: 'ok', detail: undefined }])
|
|
143
|
-
const j = readFileSync(journal, 'utf8').split('\n')
|
|
144
|
-
expect(j[0]).toBe('provision-peer')
|
|
145
|
-
expect(j.slice(1, 9)).toEqual(['--cwd', join(root, 'alpha'), '--runtime', 'claude', '--personality', 'alpha', '--occasion', 'sweep-on'])
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
test('PRECEDENCE: slot with BOTH blocks routes to provision; the plugin path is never touched', async () => {
|
|
149
|
-
const { journal } = declareProvisionSlot({ alsoPlugin: true })
|
|
150
|
-
await upsertPeer({ personality: 'beta', runtime: 'claude', cwd: join(root, 'beta'), intelligence: 'artificial' }, { rootDir: root })
|
|
151
|
-
const r = memoryPluginApply('on', { peer: 'beta', env: env() })
|
|
152
|
-
expect(r.ok).toBe(true)
|
|
153
|
-
// plugin path on this sandbox would yield 'runtime-missing' (bins at /nonexistent);
|
|
154
|
-
// 'ok' from the journaling script proves the provision command ran instead.
|
|
155
|
-
expect(r.outcomes[0]?.state).toBe('ok')
|
|
156
|
-
expect(readFileSync(journal, 'utf8')).toContain('provision-peer')
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
test('provision failure → ok=false with detail, NO fallback to the plugin path', async () => {
|
|
160
|
-
declareProvisionSlot({ failing: true, alsoPlugin: true })
|
|
161
|
-
await upsertPeer({ personality: 'gamma', runtime: 'claude', cwd: join(root, 'gamma'), intelligence: 'artificial' }, { rootDir: root })
|
|
162
|
-
const r = memoryPluginApply('on', { peer: 'gamma', env: env() })
|
|
163
|
-
expect(r.ok).toBe(false)
|
|
164
|
-
expect(r.outcomes[0]?.state).toBe('failed')
|
|
165
|
-
expect(r.outcomes[0]?.detail).toContain('provider broken')
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
test('off --peer → unprovision occasion=off-peer; off --all → off-all; codex has NO host-global special-casing', async () => {
|
|
169
|
-
const { journal } = declareProvisionSlot({})
|
|
170
|
-
await upsertPeer({ personality: 'cx', runtime: 'codex', cwd: join(root, 'cx'), intelligence: 'artificial' }, { rootDir: root })
|
|
171
|
-
const r1 = memoryPluginApply('off', { peer: 'cx', env: env() })
|
|
172
|
-
expect(r1.ok).toBe(true)
|
|
173
|
-
expect(r1.outcomes[0]).toEqual({ personality: 'cx', runtime: 'codex', state: 'ok', detail: undefined })
|
|
174
|
-
const r2 = memoryPluginApply('off', { all: true, env: env() })
|
|
175
|
-
expect(r2.ok).toBe(true)
|
|
176
|
-
const j = readFileSync(journal, 'utf8')
|
|
177
|
-
expect(j).toContain('off-peer')
|
|
178
|
-
expect(j).toContain('off-all')
|
|
179
|
-
expect(j).toContain('unprovision-peer')
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
test('missing direction block → explicit refusal (no silent skip, no plugin fallback)', async () => {
|
|
183
|
-
declareProvisionSlot({ unprovision: false, alsoPlugin: true })
|
|
184
|
-
await upsertPeer({ personality: 'delta', runtime: 'claude', cwd: join(root, 'delta'), intelligence: 'artificial' }, { rootDir: root })
|
|
185
|
-
const r = memoryPluginApply('off', { peer: 'delta', env: env() })
|
|
186
|
-
expect(r.ok).toBe(false)
|
|
187
|
-
expect(r.error).toContain('declares no unprovision command')
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
test('non-agentic peer → explicit skip (provision never invoked)', async () => {
|
|
191
|
-
const { journal } = declareProvisionSlot({})
|
|
192
|
-
await upsertPeer({ personality: 'tim2', runtime: 'notifier', cwd: join(root, 'tim2'), intelligence: 'absent' }, { rootDir: root })
|
|
193
|
-
const r = memoryPluginApply('on', { peer: 'tim2', env: env() })
|
|
194
|
-
expect(r.outcomes[0]?.state).toBe('skipped-no-agentic-runtime')
|
|
195
|
-
expect(existsSync(journal)).toBe(false)
|
|
196
|
-
})
|
|
197
|
-
})
|
|
@@ -1,210 +0,0 @@
|
|
|
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 { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
16
|
-
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
17
|
-
import { readMemoryProvider, type MemoryProviderPlugin, type MemoryProviderProvision } from '../status/index.ts'
|
|
18
|
-
import { pluginLogsDir } from '../storage/index.ts'
|
|
19
|
-
import {
|
|
20
|
-
enableCapabilityForCwd,
|
|
21
|
-
removeClaudeForCwd,
|
|
22
|
-
removeCodexGlobal,
|
|
23
|
-
type CapabilityRuntime,
|
|
24
|
-
} from './index.ts'
|
|
25
|
-
import { runProvisionCommand, type ProvisionOccasion } from './provisionCommand.ts'
|
|
26
|
-
|
|
27
|
-
export interface MemoryPluginOutcome {
|
|
28
|
-
personality: string
|
|
29
|
-
runtime: CapabilityRuntime | 'none'
|
|
30
|
-
/** enable states (on) | remove states (off) | orchestration-level skips. */
|
|
31
|
-
state: string
|
|
32
|
-
detail?: string
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface MemoryPluginResult {
|
|
36
|
-
ok: boolean
|
|
37
|
-
/** The derived plugin (null when the slot refused — see error). */
|
|
38
|
-
plugin: MemoryProviderPlugin | null
|
|
39
|
-
/** Refusal reason (empty slot / v1 declaration / no targets). */
|
|
40
|
-
error?: string
|
|
41
|
-
outcomes: MemoryPluginOutcome[]
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface MemoryPluginOptions {
|
|
45
|
-
peer?: string
|
|
46
|
-
all?: boolean
|
|
47
|
-
env?: NodeJS.ProcessEnv
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Apply the slot-declared provider plugin across targets. `on` = install per the
|
|
52
|
-
* canonical forms (claude project-scope per peer cwd; codex host-global once —
|
|
53
|
-
* idempotent). `off` = claude per-peer project-scope uninstall; codex is
|
|
54
|
-
* HOST-GLOBAL, so off with `--peer` on a codex runtime reports an explicit
|
|
55
|
-
* «host-global — use --all» (no silent skip, сверка boris) and the global remove
|
|
56
|
-
* runs only under `--all` (once).
|
|
57
|
-
*/
|
|
58
|
-
export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions = {}): MemoryPluginResult {
|
|
59
|
-
const env = opts.env ?? process.env
|
|
60
|
-
const slot = readMemoryProvider(env)
|
|
61
|
-
if (!slot) {
|
|
62
|
-
return { ok: false, plugin: null, error: 'memory slot is EMPTY — no provider declared, nothing to install', outcomes: [] }
|
|
63
|
-
}
|
|
64
|
-
// v1.2 PRECEDENCE (контракт §Provision провайдера): a provision-declaring slot
|
|
65
|
-
// routes BOTH verb directions through the provider's commands; the deprecated
|
|
66
|
-
// v1.1 plugin block is ignored entirely (no fallback — masking a provision
|
|
67
|
-
// failure would hide a broken provider behind a stale plugin install).
|
|
68
|
-
if (slot.provision || slot.unprovision) {
|
|
69
|
-
return memoryProvisionApply(state, slot.provision, slot.unprovision, slot.provider, opts, env)
|
|
70
|
-
}
|
|
71
|
-
if (!slot.plugin) {
|
|
72
|
-
return {
|
|
73
|
-
ok: false,
|
|
74
|
-
plugin: null,
|
|
75
|
-
error: `provider "${slot.provider}" declares no plugin block (declaration v1) — for arbitrary plugins use \`iapeer enable\``,
|
|
76
|
-
outcomes: [],
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const plugin = slot.plugin
|
|
80
|
-
const index = readPeersIndex({ env })
|
|
81
|
-
const targets = opts.all === true ? index.peers : opts.peer ? (p => (p ? [p] : []))(findPeer(index, opts.peer)) : []
|
|
82
|
-
if (targets.length === 0) {
|
|
83
|
-
return {
|
|
84
|
-
ok: false,
|
|
85
|
-
plugin,
|
|
86
|
-
error: opts.peer ? `peer "${opts.peer}" is not in the iapeer peers index` : 'no targets — pass --peer <p> or --all',
|
|
87
|
-
outcomes: [],
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const outcomes: MemoryPluginOutcome[] = []
|
|
92
|
-
let codexHandledGlobally = false
|
|
93
|
-
for (const p of targets) {
|
|
94
|
-
const agentic = p.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
|
|
95
|
-
if (agentic.length === 0) {
|
|
96
|
-
outcomes.push({ personality: p.personality, runtime: 'none', state: 'skipped-no-agentic-runtime' })
|
|
97
|
-
continue
|
|
98
|
-
}
|
|
99
|
-
if (state === 'on') {
|
|
100
|
-
const r = enableCapabilityForCwd({
|
|
101
|
-
plugin: plugin.name,
|
|
102
|
-
marketplace: plugin.marketplace,
|
|
103
|
-
marketplaceRef: plugin.marketplaceRef,
|
|
104
|
-
cwd: p.cwd,
|
|
105
|
-
personality: p.personality,
|
|
106
|
-
runtimes: agentic,
|
|
107
|
-
env,
|
|
108
|
-
})
|
|
109
|
-
for (const rr of r.runtimes) {
|
|
110
|
-
outcomes.push({ personality: p.personality, runtime: rr.runtime, state: rr.state, detail: rr.detail })
|
|
111
|
-
}
|
|
112
|
-
if (r.setup !== 'absent') {
|
|
113
|
-
outcomes.push({ personality: p.personality, runtime: agentic[0]!, state: `setup-${r.setup}`, detail: r.setupDetail })
|
|
114
|
-
}
|
|
115
|
-
} else {
|
|
116
|
-
for (const rt of agentic) {
|
|
117
|
-
if (rt === 'claude') {
|
|
118
|
-
const rr = removeClaudeForCwd(plugin.name, plugin.marketplace, p.cwd, env)
|
|
119
|
-
outcomes.push({ personality: p.personality, runtime: 'claude', state: rr.state, detail: rr.detail })
|
|
120
|
-
} else {
|
|
121
|
-
// codex plugin is HOST-GLOBAL: per-peer off is impossible — explicit
|
|
122
|
-
// message (сверка boris), the actual remove runs ONCE under --all.
|
|
123
|
-
if (opts.all !== true) {
|
|
124
|
-
outcomes.push({
|
|
125
|
-
personality: p.personality,
|
|
126
|
-
runtime: 'codex',
|
|
127
|
-
state: 'skipped-host-global',
|
|
128
|
-
detail: 'codex plugin is host-global — use `memory-plugin off --all`',
|
|
129
|
-
})
|
|
130
|
-
} else if (!codexHandledGlobally) {
|
|
131
|
-
codexHandledGlobally = true
|
|
132
|
-
const rr = removeCodexGlobal(plugin.name, plugin.marketplace, env)
|
|
133
|
-
outcomes.push({ personality: p.personality, runtime: 'codex', state: rr.state, detail: rr.detail })
|
|
134
|
-
} else {
|
|
135
|
-
outcomes.push({ personality: p.personality, runtime: 'codex', state: 'already', detail: 'host-global remove already done this run' })
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const failed = outcomes.some(o => o.state === 'failed' || o.state === 'setup-failed')
|
|
142
|
-
return { ok: !failed, plugin, outcomes }
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* v1.2 verb routing through the provider's provision/unprovision commands
|
|
147
|
-
* (occasion: on → sweep-on; off --peer → off-peer; off --all → off-all). One
|
|
148
|
-
* invocation per target peer × agentic runtime — NO host-global special-casing
|
|
149
|
-
* here: ref-counting of host-global surfaces (codex) moved to the PROVIDER with
|
|
150
|
-
* the inversion (contract requirement 4). A direction whose command block is
|
|
151
|
-
* absent is an explicit refusal (never silently skipped, never plugin-fallback).
|
|
152
|
-
*/
|
|
153
|
-
function memoryProvisionApply(
|
|
154
|
-
state: 'on' | 'off',
|
|
155
|
-
provision: MemoryProviderProvision | undefined,
|
|
156
|
-
unprovision: MemoryProviderProvision | undefined,
|
|
157
|
-
provider: string,
|
|
158
|
-
opts: MemoryPluginOptions,
|
|
159
|
-
env: NodeJS.ProcessEnv,
|
|
160
|
-
): MemoryPluginResult {
|
|
161
|
-
const block = state === 'on' ? provision : unprovision
|
|
162
|
-
if (!block) {
|
|
163
|
-
return {
|
|
164
|
-
ok: false,
|
|
165
|
-
plugin: null,
|
|
166
|
-
error:
|
|
167
|
-
`provider "${provider}" declares no ${state === 'on' ? 'provision' : 'unprovision'} command ` +
|
|
168
|
-
`(declaration v1.2 requires both directions) — fix the provider's declaration`,
|
|
169
|
-
outcomes: [],
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const index = readPeersIndex({ env })
|
|
173
|
-
const targets = opts.all === true ? index.peers : opts.peer ? (p => (p ? [p] : []))(findPeer(index, opts.peer)) : []
|
|
174
|
-
if (targets.length === 0) {
|
|
175
|
-
return {
|
|
176
|
-
ok: false,
|
|
177
|
-
plugin: null,
|
|
178
|
-
error: opts.peer ? `peer "${opts.peer}" is not in the iapeer peers index` : 'no targets — pass --peer <p> or --all',
|
|
179
|
-
outcomes: [],
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
const occasion: ProvisionOccasion = state === 'on' ? 'sweep-on' : opts.all === true ? 'off-all' : 'off-peer'
|
|
183
|
-
const outcomes: MemoryPluginOutcome[] = []
|
|
184
|
-
for (const p of targets) {
|
|
185
|
-
const agentic = p.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
|
|
186
|
-
if (agentic.length === 0) {
|
|
187
|
-
outcomes.push({ personality: p.personality, runtime: 'none', state: 'skipped-no-agentic-runtime' })
|
|
188
|
-
continue
|
|
189
|
-
}
|
|
190
|
-
for (const rt of agentic) {
|
|
191
|
-
const o = runProvisionCommand({ block, cwd: p.cwd, runtime: rt, personality: p.personality, occasion, env })
|
|
192
|
-
appendLifecycleEvent(
|
|
193
|
-
pluginLogsDir('iapeer', { env }),
|
|
194
|
-
{
|
|
195
|
-
ev: 'memory-provision',
|
|
196
|
-
identity: `${rt}-${p.personality}`,
|
|
197
|
-
occasion,
|
|
198
|
-
state: o.state,
|
|
199
|
-
exit: o.exitCode ?? undefined,
|
|
200
|
-
ms: o.durationMs,
|
|
201
|
-
detail: o.detail,
|
|
202
|
-
},
|
|
203
|
-
{ env },
|
|
204
|
-
)
|
|
205
|
-
outcomes.push({ personality: p.personality, runtime: rt, state: o.state, detail: o.detail })
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
const failed = outcomes.some(o => o.state !== 'ok' && o.state !== 'skipped-no-agentic-runtime')
|
|
209
|
-
return { ok: !failed, plugin: null, outcomes }
|
|
210
|
-
}
|