@agfpd/iapeer 0.2.12 → 0.2.14
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 +64 -1
- package/src/cli/index.ts +50 -0
- package/src/cli/listTui.test.ts +1 -0
- package/src/index.ts +2 -0
- package/src/launch/nativeMemory.test.ts +150 -0
- package/src/launch/nativeMemory.ts +185 -0
- package/src/lifecycle/index.ts +27 -2
- package/src/lifecycle/lifecycle.test.ts +80 -0
- package/src/provision/index.ts +34 -0
- package/src/provision/provision.test.ts +41 -0
- package/src/update/index.ts +12 -1
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 { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import { formatListTable, listPeers, parseArgs, removePeerCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
10
|
+
import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
12
|
import { isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
@@ -132,6 +132,69 @@ describe('send validation', () => {
|
|
|
132
132
|
})
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
describe('--help/-h global intercept (CLI hygiene — usage printed, NOTHING executed)', () => {
|
|
136
|
+
let captured: string
|
|
137
|
+
let origWrite: typeof process.stdout.write
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
captured = ''
|
|
140
|
+
origWrite = process.stdout.write
|
|
141
|
+
process.stdout.write = ((s: string | Uint8Array) => {
|
|
142
|
+
captured += typeof s === 'string' ? s : Buffer.from(s).toString('utf8')
|
|
143
|
+
return true
|
|
144
|
+
}) as typeof process.stdout.write
|
|
145
|
+
})
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
process.stdout.write = origWrite
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('every verb with --help prints usage to stdout and exits 0', async () => {
|
|
151
|
+
// The full verb surface — including verbs with REAL side effects (onboard ran on
|
|
152
|
+
// prod swallowing --help; stop would set a durable flag; remove would delete).
|
|
153
|
+
const verbs = [
|
|
154
|
+
'onboard', 'install', 'update', 'rollback', 'version', 'daemon', 'status',
|
|
155
|
+
'install-runtime', 'init', 'create', 'list', 'stop', 'start', 'remove', 'send',
|
|
156
|
+
'enable', 'attach', 'interrupt', 'compact', 'self-fresh', 'native-memory', 'run-infra',
|
|
157
|
+
]
|
|
158
|
+
for (const v of verbs) {
|
|
159
|
+
captured = ''
|
|
160
|
+
const code = await runCli([v, '--help'], env())
|
|
161
|
+
expect({ verb: v, code }).toEqual({ verb: v, code: 0 })
|
|
162
|
+
expect(captured).toContain('usage: iapeer')
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
test('-h works like --help, anywhere on the line', async () => {
|
|
166
|
+
expect(await runCli(['stop', 'somebody', '-h'], env())).toBe(0)
|
|
167
|
+
expect(captured).toContain('usage: iapeer')
|
|
168
|
+
})
|
|
169
|
+
test('bare `iapeer --help` / `-h` / `help` print usage', async () => {
|
|
170
|
+
for (const a of [['--help'], ['-h'], ['help']]) {
|
|
171
|
+
captured = ''
|
|
172
|
+
expect(await runCli(a, env())).toBe(0)
|
|
173
|
+
expect(captured).toContain('usage: iapeer')
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
test('--help does NOT execute the verb: `stop <peer> --help` leaves no durable stop flag', async () => {
|
|
177
|
+
await register('helpcheck')
|
|
178
|
+
const e = env()
|
|
179
|
+
expect(await runCli(['stop', 'helpcheck', '--help'], e)).toBe(0)
|
|
180
|
+
expect(isStopped(loadLifecycleConfig(e), 'claude-helpcheck')).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
test('--help does NOT execute the verb: `remove <peer> --help` keeps the registry record', async () => {
|
|
183
|
+
await register('keepme')
|
|
184
|
+
const e = env()
|
|
185
|
+
expect(await runCli(['remove', 'keepme', '--help'], e)).toBe(0)
|
|
186
|
+
expect(findPeer(readPeersIndex({ env: e }), 'keepme')).not.toBeNull()
|
|
187
|
+
})
|
|
188
|
+
test('version --help shows usage, not the version number', async () => {
|
|
189
|
+
expect(await runCli(['version', '--help'], env())).toBe(0)
|
|
190
|
+
expect(captured).toContain('usage: iapeer')
|
|
191
|
+
expect(captured.trim().split('\n').length).toBeGreaterThan(3) // usage, not a bare semver line
|
|
192
|
+
})
|
|
193
|
+
test('a literal "--help" value stays expressible via --key=--help (not intercepted)', () => {
|
|
194
|
+
expect(parseArgs(['boris', '--message=--help']).flags.message).toBe('--help')
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
135
198
|
describe('parseArgs (audit #27 — value beginning with --)', () => {
|
|
136
199
|
test('--key=value preserves a value that starts with --', () => {
|
|
137
200
|
expect(parseArgs(['send', 'boris', '--message=--look', '--topic=re: x']).flags).toMatchObject({
|
package/src/cli/index.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface PeerListing {
|
|
|
60
60
|
last_active_runtime?: Runtime
|
|
61
61
|
intelligence: Intelligence
|
|
62
62
|
description: string
|
|
63
|
+
/** The peer's working directory (registry fact). Machine-readable so host-local
|
|
64
|
+
* tooling (e.g. the memory provider's init/verify rendering doctrine into
|
|
65
|
+
* <cwd>/.iapeer/) keys on the REGISTRY instead of copying the layout default —
|
|
66
|
+
* a layout change must not silently strand consumers (iapeer-memory ask, 10.06). */
|
|
67
|
+
cwd: string
|
|
63
68
|
runtimes: RuntimeStatus[]
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -104,6 +109,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
|
|
|
104
109
|
last_active_runtime: lastActive,
|
|
105
110
|
intelligence: peer.intelligence,
|
|
106
111
|
description: peer.description,
|
|
112
|
+
cwd: peer.cwd,
|
|
107
113
|
runtimes,
|
|
108
114
|
}
|
|
109
115
|
})
|
|
@@ -351,6 +357,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
351
357
|
update [version] [--force] pull latest (or an exact version) of @agfpd/iapeer from npm + restart the daemon
|
|
352
358
|
rollback revert to the previous binary (.prev) + restart the daemon
|
|
353
359
|
version | --version | -v print the installed binary's version
|
|
360
|
+
help | --help | -h print this usage (works appended to any verb; executes nothing)
|
|
354
361
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
355
362
|
onboard [--dry-run] [--infra <csv>] [--no-memory] [--memory <pkg>] register the agfpd marketplace (+ infra runtimes; + default memory provider, default YES)
|
|
356
363
|
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
@@ -368,10 +375,22 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
368
375
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
369
376
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
370
377
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
378
|
+
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
371
379
|
`
|
|
372
380
|
|
|
373
381
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
374
382
|
const [verb, ...rest] = argv
|
|
383
|
+
// CLI hygiene (design «Onboard костяка» §CLI-гигиена): an explicit help request —
|
|
384
|
+
// `--help`/`-h` ANYWHERE on the line, or the bare `help` verb — prints usage and
|
|
385
|
+
// executes NOTHING. Checked on the RAW argv BEFORE the switch: parseArgs would
|
|
386
|
+
// bury `--help` in flags no case reads (a cold-start `onboard --help` EXECUTED on
|
|
387
|
+
// prod — idempotency saved it), and `-h` would land in positionals. Token-exact
|
|
388
|
+
// match is safe: the look-ahead parser never consumes a `--`-token as a value, so
|
|
389
|
+
// a LITERAL "--help" value is only expressible as `--key=--help` (not intercepted).
|
|
390
|
+
if (verb === 'help' || argv.includes('--help') || argv.includes('-h')) {
|
|
391
|
+
process.stdout.write(USAGE)
|
|
392
|
+
return 0
|
|
393
|
+
}
|
|
375
394
|
const { positionals, flags } = parseArgs(rest)
|
|
376
395
|
const out = (s: string) => process.stdout.write(s)
|
|
377
396
|
const errOut = (s: string) => process.stderr.write(s)
|
|
@@ -422,6 +441,37 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
422
441
|
out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
|
|
423
442
|
return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
424
443
|
}
|
|
444
|
+
case 'native-memory': {
|
|
445
|
+
// The canonized runtime-memory lever (контракт «Слот памяти» §Native-память):
|
|
446
|
+
// off = explicit disable merged into the peer's runtime config files; on =
|
|
447
|
+
// remove the key (restore the runtime's own default). Consumers: the operator
|
|
448
|
+
// and the memory provider's install-time sweep (`--all`). NOT slot-gated here —
|
|
449
|
+
// an explicit operator/provider action; only the BIRTH-time hook is slot-gated.
|
|
450
|
+
const state = positionals[0]
|
|
451
|
+
if (state !== 'off' && state !== 'on') return usage(errOut)
|
|
452
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
453
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
454
|
+
const { applyNativeMemory } = await import('../launch/nativeMemory.ts')
|
|
455
|
+
const index = readPeersIndex({ env })
|
|
456
|
+
const targets = flags.all === true ? index.peers : index.peers.filter(p => p.personality === peerName)
|
|
457
|
+
if (targets.length === 0) {
|
|
458
|
+
errOut(`peer "${peerName ?? ''}" is not in the iapeer peers index\n`)
|
|
459
|
+
return 1
|
|
460
|
+
}
|
|
461
|
+
let failed = false
|
|
462
|
+
for (const p of targets) {
|
|
463
|
+
const outcomes = applyNativeMemory(p.cwd, p.runtimes, state)
|
|
464
|
+
if (outcomes.length === 0) {
|
|
465
|
+
out(`${p.personality}: no claude/codex runtime — skipped\n`)
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
for (const o of outcomes) {
|
|
469
|
+
out(`${p.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
470
|
+
if (o.state === 'failed') failed = true
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return failed ? 1 : 0
|
|
474
|
+
}
|
|
425
475
|
case 'status': {
|
|
426
476
|
// Host snapshot (контракт «Слот памяти» §status): version + daemon health +
|
|
427
477
|
// the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
|
package/src/cli/listTui.test.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -28,5 +28,7 @@ export * from './onboard/index.ts'
|
|
|
28
28
|
// Memory slot — the declarative provider slot (контракт «Слот памяти»): status read + onboard step.
|
|
29
29
|
export * from './status/index.ts'
|
|
30
30
|
export * from './onboard/memory.ts'
|
|
31
|
+
// Native runtime-memory levers (slot contract §Native-память): canonized forms + verb mechanics.
|
|
32
|
+
export * from './launch/nativeMemory.ts'
|
|
31
33
|
export { composeSystemPrompt, gatherPromptInput } from './launch/composeSystemPrompt.ts'
|
|
32
34
|
export type { GatherPromptOptions } from './launch/composeSystemPrompt.ts'
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Native runtime-memory levers — the canonized forms (slot contract §Native-
|
|
2
|
+
// память). The HARD requirement pinned here: NO-CLOBBER — both target files
|
|
3
|
+
// carry foreign blocks (plugin enables, statusline wrappers) that every lever
|
|
4
|
+
// write must preserve byte-meaningfully.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import {
|
|
11
|
+
applyNativeMemory,
|
|
12
|
+
claudeSettingsPath,
|
|
13
|
+
codexProjectConfigPath,
|
|
14
|
+
codexGlobalConfigPath,
|
|
15
|
+
preTrustCodexCwd,
|
|
16
|
+
} from './nativeMemory.ts'
|
|
17
|
+
|
|
18
|
+
const dirs: string[] = []
|
|
19
|
+
function mkTmp(): string {
|
|
20
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-natmem-'))
|
|
21
|
+
dirs.push(d)
|
|
22
|
+
return d
|
|
23
|
+
}
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('claude lever (settings.json merge)', () => {
|
|
29
|
+
test('absent file → created with ONLY the lever key', () => {
|
|
30
|
+
const cwd = mkTmp()
|
|
31
|
+
const [o] = applyNativeMemory(cwd, ['claude'], 'off')
|
|
32
|
+
expect(o?.state).toBe('written')
|
|
33
|
+
expect(JSON.parse(readFileSync(claudeSettingsPath(cwd), 'utf8'))).toEqual({ autoMemoryEnabled: false })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('NO-CLOBBER: foreign keys (plugins, statusline) survive off AND on', () => {
|
|
37
|
+
const cwd = mkTmp()
|
|
38
|
+
mkdirSync(join(cwd, '.claude'), { recursive: true })
|
|
39
|
+
const foreign = { enabledPlugins: { 'MergeMind@agfpd': true }, statusLine: { type: 'command', command: 'x' } }
|
|
40
|
+
writeFileSync(claudeSettingsPath(cwd), JSON.stringify(foreign))
|
|
41
|
+
applyNativeMemory(cwd, ['claude'], 'off')
|
|
42
|
+
const afterOff = JSON.parse(readFileSync(claudeSettingsPath(cwd), 'utf8'))
|
|
43
|
+
expect(afterOff).toEqual({ ...foreign, autoMemoryEnabled: false })
|
|
44
|
+
const [on] = applyNativeMemory(cwd, ['claude'], 'on')
|
|
45
|
+
expect(on?.state).toBe('written')
|
|
46
|
+
expect(JSON.parse(readFileSync(claudeSettingsPath(cwd), 'utf8'))).toEqual(foreign) // key REMOVED, default restored
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('idempotent: already-false → already; on with no key → already', () => {
|
|
50
|
+
const cwd = mkTmp()
|
|
51
|
+
applyNativeMemory(cwd, ['claude'], 'off')
|
|
52
|
+
expect(applyNativeMemory(cwd, ['claude'], 'off')[0]?.state).toBe('already')
|
|
53
|
+
applyNativeMemory(cwd, ['claude'], 'on')
|
|
54
|
+
expect(applyNativeMemory(cwd, ['claude'], 'on')[0]?.state).toBe('already')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('non-object settings.json → failed (refuses to clobber), file untouched', () => {
|
|
58
|
+
const cwd = mkTmp()
|
|
59
|
+
mkdirSync(join(cwd, '.claude'), { recursive: true })
|
|
60
|
+
writeFileSync(claudeSettingsPath(cwd), '"just a string"')
|
|
61
|
+
const [o] = applyNativeMemory(cwd, ['claude'], 'off')
|
|
62
|
+
expect(o?.state).toBe('failed')
|
|
63
|
+
expect(readFileSync(claudeSettingsPath(cwd), 'utf8')).toBe('"just a string"')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('codex lever (config.toml [features] merge)', () => {
|
|
68
|
+
test('absent file → [features] section with the lever line', () => {
|
|
69
|
+
const cwd = mkTmp()
|
|
70
|
+
const [o] = applyNativeMemory(cwd, ['codex'], 'off')
|
|
71
|
+
expect(o?.state).toBe('written')
|
|
72
|
+
expect(readFileSync(codexProjectConfigPath(cwd), 'utf8')).toBe('[features]\nmemories = false\n')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('NO-CLOBBER: plugin blocks survive; [features] appended after them (fleet-file shape)', () => {
|
|
76
|
+
const cwd = mkTmp()
|
|
77
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
78
|
+
const fleet = '[plugins."Inter-Agent-Protocol@agfpd"]\nenabled = true\n\n[plugins."MergeMind@agfpd"]\nenabled = true\n'
|
|
79
|
+
writeFileSync(codexProjectConfigPath(cwd), fleet)
|
|
80
|
+
applyNativeMemory(cwd, ['codex'], 'off')
|
|
81
|
+
const text = readFileSync(codexProjectConfigPath(cwd), 'utf8')
|
|
82
|
+
expect(text).toContain('[plugins."Inter-Agent-Protocol@agfpd"]\nenabled = true')
|
|
83
|
+
expect(text).toContain('[plugins."MergeMind@agfpd"]\nenabled = true')
|
|
84
|
+
expect(text).toContain('[features]\nmemories = false')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('existing [features] WITHOUT memories (followed by another section) → inserted INSIDE the section', () => {
|
|
88
|
+
const cwd = mkTmp()
|
|
89
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
90
|
+
writeFileSync(codexProjectConfigPath(cwd), '[features]\nother = true\n\n[plugins."X@y"]\nenabled = true\n')
|
|
91
|
+
applyNativeMemory(cwd, ['codex'], 'off')
|
|
92
|
+
const text = readFileSync(codexProjectConfigPath(cwd), 'utf8')
|
|
93
|
+
// the lever line lands in [features], NOT in [plugins."X@y"]
|
|
94
|
+
expect(text.indexOf('memories = false')).toBeLessThan(text.indexOf('[plugins."X@y"]'))
|
|
95
|
+
expect(text).toContain('other = true')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('memories = true → replaced with false; already false → already', () => {
|
|
99
|
+
const cwd = mkTmp()
|
|
100
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
101
|
+
writeFileSync(codexProjectConfigPath(cwd), '[features]\nmemories = true\n')
|
|
102
|
+
expect(applyNativeMemory(cwd, ['codex'], 'off')[0]?.state).toBe('written')
|
|
103
|
+
expect(readFileSync(codexProjectConfigPath(cwd), 'utf8')).toContain('memories = false')
|
|
104
|
+
expect(applyNativeMemory(cwd, ['codex'], 'off')[0]?.state).toBe('already')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('on → the memories line is REMOVED (default restored), section + foreign lines kept', () => {
|
|
108
|
+
const cwd = mkTmp()
|
|
109
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
110
|
+
writeFileSync(codexProjectConfigPath(cwd), '[features]\nmemories = false\nother = true\n')
|
|
111
|
+
const [o] = applyNativeMemory(cwd, ['codex'], 'on')
|
|
112
|
+
expect(o?.state).toBe('written')
|
|
113
|
+
const text = readFileSync(codexProjectConfigPath(cwd), 'utf8')
|
|
114
|
+
expect(text).not.toContain('memories')
|
|
115
|
+
expect(text).toContain('other = true')
|
|
116
|
+
expect(applyNativeMemory(cwd, ['codex'], 'on')[0]?.state).toBe('already')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('applyNativeMemory runtime dispatch', () => {
|
|
121
|
+
test('both runtimes → both levers; router-only runtimes → no outcomes', () => {
|
|
122
|
+
const cwd = mkTmp()
|
|
123
|
+
const both = applyNativeMemory(cwd, ['claude', 'codex'], 'off')
|
|
124
|
+
expect(both.map(o => o.runtime).sort()).toEqual(['claude', 'codex'])
|
|
125
|
+
expect(applyNativeMemory(mkTmp(), ['telegram', 'notifier'], 'off')).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('preTrustCodexCwd (birth-time trust, codex global config)', () => {
|
|
130
|
+
test('appends a [projects] block once; NO-CLOBBER of existing content; idempotent', () => {
|
|
131
|
+
const home = mkTmp()
|
|
132
|
+
const env = { HOME: home } as NodeJS.ProcessEnv
|
|
133
|
+
mkdirSync(join(home, '.codex'), { recursive: true })
|
|
134
|
+
const existing = '[projects."/already/trusted"]\ntrust_level = "trusted"\n'
|
|
135
|
+
writeFileSync(codexGlobalConfigPath(env), existing)
|
|
136
|
+
const first = preTrustCodexCwd('/peers/newborn', env)
|
|
137
|
+
expect(first.state).toBe('written')
|
|
138
|
+
const text = readFileSync(codexGlobalConfigPath(env), 'utf8')
|
|
139
|
+
expect(text).toContain(existing.trim())
|
|
140
|
+
expect(text).toContain('[projects."/peers/newborn"]\ntrust_level = "trusted"')
|
|
141
|
+
expect(preTrustCodexCwd('/peers/newborn', env).state).toBe('already')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('absent global config → created with just the block', () => {
|
|
145
|
+
const home = mkTmp()
|
|
146
|
+
const env = { HOME: home } as NodeJS.ProcessEnv
|
|
147
|
+
expect(preTrustCodexCwd('/peers/first', env).state).toBe('written')
|
|
148
|
+
expect(readFileSync(codexGlobalConfigPath(env), 'utf8')).toBe('[projects."/peers/first"]\ntrust_level = "trusted"\n')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Native runtime-memory levers (контракт «Слот памяти» §Native-память рантаймов
|
|
2
|
+
// при занятом слоте, agreed 10.06). The CORE canonizes the lever FORMS — runtime
|
|
3
|
+
// knowledge lives in the runtime layer, like deliveryMarkers/buildArgv:
|
|
4
|
+
//
|
|
5
|
+
// claude: merge `"autoMemoryEnabled": false` into `<cwd>/.claude/settings.json`
|
|
6
|
+
// codex: merge `memories = false` into the `[features]` section of
|
|
7
|
+
// `<cwd>/.codex/config.toml`
|
|
8
|
+
//
|
|
9
|
+
// Both files CARRY FOREIGN BLOCKS (plugin enables, statusline wrappers, …) —
|
|
10
|
+
// every write here is a NO-CLOBBER merge (parse/patch + atomic temp+rename),
|
|
11
|
+
// never a rewrite. Forms verified live on the fleet (12/12 + behavioral smoke
|
|
12
|
+
// 09.06; codex reads project-local config when the cwd is TRUSTED — see
|
|
13
|
+
// preTrustCodexCwd).
|
|
14
|
+
//
|
|
15
|
+
// `off` writes the explicit disable; `on` REMOVES the key (restores the
|
|
16
|
+
// runtime's own default rather than baking an explicit enable — the core has
|
|
17
|
+
// no opinion beyond "parallel store off while a memory provider owns the host").
|
|
18
|
+
//
|
|
19
|
+
// Consumers: the operator verb `iapeer native-memory <off|on>`, the provider's
|
|
20
|
+
// install-time sweep (calls the verb), and provisionPeer's birth-time hook
|
|
21
|
+
// (slot-gated, see provision/index.ts).
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs'
|
|
24
|
+
import { dirname, join } from 'path'
|
|
25
|
+
import { homedir } from 'os'
|
|
26
|
+
import type { Runtime } from '../core/constants.ts'
|
|
27
|
+
import { writeFileAtomic } from '../storage/index.ts'
|
|
28
|
+
|
|
29
|
+
export type NativeMemoryState = 'off' | 'on'
|
|
30
|
+
|
|
31
|
+
export interface LeverOutcome {
|
|
32
|
+
runtime: 'claude' | 'codex'
|
|
33
|
+
path: string
|
|
34
|
+
state: 'written' | 'already' | 'failed'
|
|
35
|
+
detail?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── claude: <cwd>/.claude/settings.json — JSON merge ───────────────────────
|
|
39
|
+
|
|
40
|
+
export function claudeSettingsPath(cwd: string): string {
|
|
41
|
+
return join(cwd, '.claude', 'settings.json')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function applyClaudeLever(cwd: string, state: NativeMemoryState): LeverOutcome {
|
|
45
|
+
const path = claudeSettingsPath(cwd)
|
|
46
|
+
try {
|
|
47
|
+
let obj: Record<string, unknown> = {}
|
|
48
|
+
if (existsSync(path)) {
|
|
49
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
50
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
51
|
+
return { runtime: 'claude', path, state: 'failed', detail: 'settings.json is not a JSON object — refusing to clobber' }
|
|
52
|
+
}
|
|
53
|
+
obj = raw as Record<string, unknown>
|
|
54
|
+
}
|
|
55
|
+
if (state === 'off') {
|
|
56
|
+
if (obj.autoMemoryEnabled === false) return { runtime: 'claude', path, state: 'already' }
|
|
57
|
+
obj.autoMemoryEnabled = false
|
|
58
|
+
} else {
|
|
59
|
+
if (!('autoMemoryEnabled' in obj)) return { runtime: 'claude', path, state: 'already' }
|
|
60
|
+
delete obj.autoMemoryEnabled // restore the runtime's own default
|
|
61
|
+
}
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
63
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`)
|
|
64
|
+
return { runtime: 'claude', path, state: 'written' }
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { runtime: 'claude', path, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── codex: <cwd>/.codex/config.toml — section-scoped line merge ─────────────
|
|
71
|
+
|
|
72
|
+
export function codexProjectConfigPath(cwd: string): string {
|
|
73
|
+
return join(cwd, '.codex', 'config.toml')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Locate the `[features]` section bounds: [startLine, endLine) of its BODY
|
|
77
|
+
* (after the header, up to the next `[…]` header or EOF). null = no section. */
|
|
78
|
+
function featuresBounds(lines: string[]): { header: number; start: number; end: number } | null {
|
|
79
|
+
const header = lines.findIndex(l => l.trim() === '[features]')
|
|
80
|
+
if (header < 0) return null
|
|
81
|
+
let end = lines.length
|
|
82
|
+
for (let i = header + 1; i < lines.length; i++) {
|
|
83
|
+
if (/^\s*\[/.test(lines[i]!)) {
|
|
84
|
+
end = i
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { header, start: header + 1, end }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyCodexLever(cwd: string, state: NativeMemoryState): LeverOutcome {
|
|
92
|
+
const path = codexProjectConfigPath(cwd)
|
|
93
|
+
try {
|
|
94
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
95
|
+
const lines = text.length ? text.split('\n') : []
|
|
96
|
+
const bounds = featuresBounds(lines)
|
|
97
|
+
const memRe = /^\s*memories\s*=/
|
|
98
|
+
if (state === 'off') {
|
|
99
|
+
if (bounds) {
|
|
100
|
+
const idx = lines.slice(bounds.start, bounds.end).findIndex(l => memRe.test(l))
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
const abs = bounds.start + idx
|
|
103
|
+
if (/^\s*memories\s*=\s*false\s*$/.test(lines[abs]!)) return { runtime: 'codex', path, state: 'already' }
|
|
104
|
+
lines[abs] = 'memories = false'
|
|
105
|
+
} else {
|
|
106
|
+
lines.splice(bounds.start, 0, 'memories = false')
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
if (lines.length && lines[lines.length - 1]!.trim() !== '') lines.push('')
|
|
110
|
+
lines.push('[features]', 'memories = false')
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
if (!bounds) return { runtime: 'codex', path, state: 'already' }
|
|
114
|
+
const idx = lines.slice(bounds.start, bounds.end).findIndex(l => memRe.test(l))
|
|
115
|
+
if (idx < 0) return { runtime: 'codex', path, state: 'already' }
|
|
116
|
+
lines.splice(bounds.start + idx, 1) // restore the runtime's own default
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
119
|
+
const outText = lines.join('\n')
|
|
120
|
+
writeFileAtomic(path, outText.endsWith('\n') ? outText : `${outText}\n`)
|
|
121
|
+
return { runtime: 'codex', path, state: 'written' }
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { runtime: 'codex', path, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── codex trust (birth-time only) ───────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** The codex GLOBAL config (`~/.codex/config.toml`) — where trust lives as
|
|
130
|
+
* `[projects."<path>"] trust_level = "trusted"` blocks. */
|
|
131
|
+
export function codexGlobalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
132
|
+
const home = env.HOME?.trim() || homedir()
|
|
133
|
+
return join(home, '.codex', 'config.toml')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Pre-trust a NEWBORN codex peer's cwd by appending its `[projects."<cwd>"]`
|
|
138
|
+
* block to the codex global config (no-clobber: only when no block for this
|
|
139
|
+
* path exists). Closes the contract requirement «рычаг действует с ПЕРВОЙ
|
|
140
|
+
* сессии» DETERMINISTICALLY: codex reads project-local config only for a
|
|
141
|
+
* trusted cwd, and without pre-trust the first session's trust is granted
|
|
142
|
+
* mid-boot by the adapter's dialog auto-accept — whether the project config is
|
|
143
|
+
* (re)read after that grant within the same run is codex-internal. Birth-time
|
|
144
|
+
* only — existing peers are already trusted through their boot history.
|
|
145
|
+
*/
|
|
146
|
+
export function preTrustCodexCwd(cwd: string, env: NodeJS.ProcessEnv = process.env): LeverOutcome {
|
|
147
|
+
const path = codexGlobalConfigPath(env)
|
|
148
|
+
try {
|
|
149
|
+
// Codex keys trust on the RESOLVED real path (live fact, 10.06 acceptance: the
|
|
150
|
+
// /tmp → /private/tmp symlink made a literal-path entry MISS and left the lever
|
|
151
|
+
// inert — trust entries written by codex itself are all /private/tmp/...).
|
|
152
|
+
let real = cwd
|
|
153
|
+
try {
|
|
154
|
+
real = realpathSync(cwd)
|
|
155
|
+
} catch {
|
|
156
|
+
/* cwd not on disk yet → keep as given */
|
|
157
|
+
}
|
|
158
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
159
|
+
if (text.includes(`[projects."${real}"]`)) return { runtime: 'codex', path, state: 'already' }
|
|
160
|
+
const block = `${text.length && !text.endsWith('\n') ? '\n' : ''}${text.trim().length ? '\n' : ''}[projects."${real}"]\ntrust_level = "trusted"\n`
|
|
161
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
162
|
+
writeFileAtomic(path, text + block)
|
|
163
|
+
return { runtime: 'codex', path, state: 'written' }
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { runtime: 'codex', path, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── public entry ────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Apply the native-memory lever for one peer cwd across its declared runtimes
|
|
173
|
+
* (∩ {claude, codex} — other runtimes carry no native memory). Idempotent;
|
|
174
|
+
* every outcome is reported (a 'failed' lever never throws).
|
|
175
|
+
*/
|
|
176
|
+
export function applyNativeMemory(
|
|
177
|
+
cwd: string,
|
|
178
|
+
runtimes: readonly Runtime[],
|
|
179
|
+
state: NativeMemoryState,
|
|
180
|
+
): LeverOutcome[] {
|
|
181
|
+
const out: LeverOutcome[] = []
|
|
182
|
+
if (runtimes.includes('claude')) out.push(applyClaudeLever(cwd, state))
|
|
183
|
+
if (runtimes.includes('codex')) out.push(applyCodexLever(cwd, state))
|
|
184
|
+
return out
|
|
185
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -572,6 +572,26 @@ function sessionAlive(sock: string, identity: string): boolean {
|
|
|
572
572
|
return tmux(sock, 'has-session', '-t', identity).ok
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
/** Death-class tag for a gone session (live case: iapeer-memory 10.06 — the WHOLE
|
|
576
|
+
* tmux server died by SIGKILL-class and exits.log stayed empty, because the
|
|
577
|
+
* pane-died hook needs a living tmux event loop). Two distinguishable classes:
|
|
578
|
+
* - `session-gone` — the server on the socket still ANSWERS but the session is not
|
|
579
|
+
* there: a pane died inside a living server → the pane-died hook had its chance,
|
|
580
|
+
* exits.log should carry the cause.
|
|
581
|
+
* - `server-dead` — the server itself is gone: the socket file is missing, or it
|
|
582
|
+
* exists but nothing serves it (stale socket — the SIGKILL/OOM class). pane-died
|
|
583
|
+
* could never fire, so the lifecycle.log line is the only durable trace. */
|
|
584
|
+
export function classifyGoneSession(sock: string): { death: 'server-dead' | 'session-gone'; reason: string } {
|
|
585
|
+
if (!existsSync(sock)) {
|
|
586
|
+
return { death: 'server-dead', reason: 'tmux server gone (socket file missing)' }
|
|
587
|
+
}
|
|
588
|
+
// Ask the SERVER, not the session: any server-level command answering (exit 0)
|
|
589
|
+
// proves the server is alive and merely lost this session.
|
|
590
|
+
return tmux(sock, 'list-sessions').ok
|
|
591
|
+
? { death: 'session-gone', reason: 'session gone, tmux server alive (exit cause should be in exits.log)' }
|
|
592
|
+
: { death: 'server-dead', reason: 'tmux server dead — stale socket (SIGKILL/OOM class; exits.log has no entry)' }
|
|
593
|
+
}
|
|
594
|
+
|
|
575
595
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
576
596
|
// System-prompt composition for a woken peer (delegates the jq doctrine-merge to
|
|
577
597
|
// launch/composeSystemPrompt). The tmux launch + boot/ready + activity-proxy all
|
|
@@ -1007,8 +1027,13 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1007
1027
|
}
|
|
1008
1028
|
// Crash / self-close: NO marker written, NO eager relaunch — the peer stays
|
|
1009
1029
|
// asleep and wakes FRESH lazily on the next message (resolveWakeMode branch 3a).
|
|
1010
|
-
|
|
1011
|
-
|
|
1030
|
+
// The death-class tag (classifyGoneSession) makes the two gone-classes
|
|
1031
|
+
// distinguishable in lifecycle.log: `session-gone` (pane died, server alive →
|
|
1032
|
+
// exits.log should have the cause) vs `server-dead` (whole tmux server died →
|
|
1033
|
+
// exits.log structurally empty; this line is the only durable trace).
|
|
1034
|
+
const gone = classifyGoneSession(sock)
|
|
1035
|
+
out.push({ identity: s.identity, action: 'reaped-gone', reason: gone.reason })
|
|
1036
|
+
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1012
1037
|
continue
|
|
1013
1038
|
}
|
|
1014
1039
|
// Idle accounting via the runtime adapter's activity proxy (claude transcript
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from 'os'
|
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import {
|
|
6
6
|
attachPeer,
|
|
7
|
+
classifyGoneSession,
|
|
7
8
|
clearEphemeralArmed,
|
|
8
9
|
clearNewEager,
|
|
9
10
|
clearStopped,
|
|
@@ -229,6 +230,8 @@ describe('superviseTick H4 guard', () => {
|
|
|
229
230
|
const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
230
231
|
expect(logged).toContain(`ev=supervise identity=${id} action=reaped-gone`)
|
|
231
232
|
expect(logged).toContain('outcome=fresh-next-msg')
|
|
233
|
+
// death-class tag: no socket file at all in this sandbox → the server is gone
|
|
234
|
+
expect(logged).toContain('death=server-dead')
|
|
232
235
|
})
|
|
233
236
|
|
|
234
237
|
test('empty state dir → no outcomes', () => {
|
|
@@ -621,6 +624,83 @@ describe('ephemeral-armed marker + config', () => {
|
|
|
621
624
|
})
|
|
622
625
|
})
|
|
623
626
|
|
|
627
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
628
|
+
// classifyGoneSession — the death-class tag for reaped-gone (server-dead vs
|
|
629
|
+
// session-gone). Live case: iapeer-memory 10.06 — the whole tmux server died
|
|
630
|
+
// (SIGKILL class), exits.log stayed empty; lifecycle.log must carry the class.
|
|
631
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
describe('classifyGoneSession (death-class tag)', () => {
|
|
634
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
635
|
+
|
|
636
|
+
test('missing socket file → server-dead', () => {
|
|
637
|
+
const r = classifyGoneSession(join(tmpdir(), 'iapeer-no-such-sock-ever.sock'))
|
|
638
|
+
expect(r.death).toBe('server-dead')
|
|
639
|
+
expect(r.reason).toContain('socket file missing')
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test('stale socket (file exists, nothing serves it) → server-dead', () => {
|
|
643
|
+
// The SIGKILL/OOM class: the killed server never unlinks its socket file.
|
|
644
|
+
const dir = mkdtempSync(join(tmpdir(), 'iapeer-stale-sock-'))
|
|
645
|
+
const sock = join(dir, 'tmux-iap-claude-stale.sock')
|
|
646
|
+
try {
|
|
647
|
+
writeFileSync(sock, '') // a plain file — tmux cannot connect to it
|
|
648
|
+
const r = classifyGoneSession(sock)
|
|
649
|
+
expect(r.death).toBe('server-dead')
|
|
650
|
+
expect(r.reason).toContain('stale socket')
|
|
651
|
+
} finally {
|
|
652
|
+
rmSync(dir, { recursive: true, force: true })
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
test.if(tmuxAvailable)('server alive but the session is not there → session-gone', () => {
|
|
657
|
+
const dir = mkdtempSync(join(tmpdir(), 'iapeer-sg-sock-'))
|
|
658
|
+
const sock = join(dir, 'tmux-iap-claude-sg.sock')
|
|
659
|
+
try {
|
|
660
|
+
// a LIVING server on the socket holding some OTHER session
|
|
661
|
+
spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'other-session', 'sleep', '60'])
|
|
662
|
+
const r = classifyGoneSession(sock)
|
|
663
|
+
expect(r.death).toBe('session-gone')
|
|
664
|
+
expect(r.reason).toContain('server alive')
|
|
665
|
+
} finally {
|
|
666
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
667
|
+
rmSync(dir, { recursive: true, force: true })
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
test.if(tmuxAvailable)('superviseTick logs death=session-gone when the pane died inside a living server', () => {
|
|
672
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-sg-root-'))
|
|
673
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-sg-la-')) // empty → not launchd-managed
|
|
674
|
+
const env = {
|
|
675
|
+
...process.env,
|
|
676
|
+
IAPEER_ROOT: root,
|
|
677
|
+
IAPEER_LAUNCHAGENTS_DIR: laDir,
|
|
678
|
+
IAPEER_SOCK_DIR: join(root, 'socks'),
|
|
679
|
+
}
|
|
680
|
+
const cfg = loadLifecycleConfig(env)
|
|
681
|
+
const identity = 'claude-sg'
|
|
682
|
+
const sock = join(root, 'socks', 'tmux-iap-claude-sg.sock')
|
|
683
|
+
try {
|
|
684
|
+
mkdirSync(join(root, 'socks'), { recursive: true })
|
|
685
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
686
|
+
// the server LIVES (another session holds it) but claude-sg's session is gone
|
|
687
|
+
spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'placeholder', 'sleep', '60'])
|
|
688
|
+
writeFileSync(
|
|
689
|
+
join(cfg.stateDir, `${identity}.session`),
|
|
690
|
+
JSON.stringify({ identity, runtime: 'claude', personality: 'sg', cwd: '/tmp/none', wokeAt: 0 }),
|
|
691
|
+
)
|
|
692
|
+
const out = superviseTick(cfg, { env })
|
|
693
|
+
expect(out.find(x => x.identity === identity)?.action).toBe('reaped-gone')
|
|
694
|
+
const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
695
|
+
expect(logged).toContain(`identity=${identity} action=reaped-gone death=session-gone`)
|
|
696
|
+
} finally {
|
|
697
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
698
|
+
rmSync(root, { recursive: true, force: true })
|
|
699
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
700
|
+
}
|
|
701
|
+
})
|
|
702
|
+
})
|
|
703
|
+
|
|
624
704
|
describe('superviseTick quiet-reap (M2 die-after-reply, real tmux)', () => {
|
|
625
705
|
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
626
706
|
|
package/src/provision/index.ts
CHANGED
|
@@ -91,6 +91,40 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
91
91
|
runtimeBin,
|
|
92
92
|
warn: opts.warn,
|
|
93
93
|
})
|
|
94
|
+
// Birth-time native-memory lever (контракт «Слот памяти» §Native-память рантаймов):
|
|
95
|
+
// an OCCUPIED memory slot gates the AUTOMATIC lever at peer birth — zero window of
|
|
96
|
+
// a parallel, uncurated native store (the criterion). Empty slot → native memory is
|
|
97
|
+
// legitimate, untouched. Best-effort with a LOUD warn (a lever hiccup must not kill
|
|
98
|
+
// the provision — the operator verb `iapeer native-memory off` is the repair).
|
|
99
|
+
try {
|
|
100
|
+
const { readMemoryProvider } = await import('../status/index.ts')
|
|
101
|
+
if (readMemoryProvider(env)) {
|
|
102
|
+
const { applyNativeMemory, preTrustCodexCwd } = await import('../launch/nativeMemory.ts')
|
|
103
|
+
for (const o of applyNativeMemory(cwd, profile.runtimes, 'off')) {
|
|
104
|
+
if (o.state === 'failed') {
|
|
105
|
+
opts.warn?.(
|
|
106
|
+
`native-memory lever (${o.runtime}) FAILED for "${profile.personality}": ${o.detail} — ` +
|
|
107
|
+
`the peer may accumulate a parallel native store; repair: iapeer native-memory off --peer ${profile.personality}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Codex reads project-local config only for a TRUSTED cwd — pre-trust the
|
|
112
|
+
// newborn so the lever acts from the FIRST session (contract requirement),
|
|
113
|
+
// not from whenever the boot dialog's trust grant takes effect.
|
|
114
|
+
if (profile.runtimes.includes('codex')) {
|
|
115
|
+
const t = preTrustCodexCwd(cwd, env)
|
|
116
|
+
if (t.state === 'failed') {
|
|
117
|
+
opts.warn?.(
|
|
118
|
+
`codex pre-trust FAILED for "${profile.personality}": ${t.detail} — ` +
|
|
119
|
+
`the lever may be inert until the first boot's trust dialog`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
opts.warn?.(`native-memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
// wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
|
|
95
129
|
// lives in the hand-editable local profile, provision just surfaces the mismatch):
|
|
96
130
|
// • + interfaces.telegram: ephemeral WINS in resolveWakeMode (explicit policy
|
|
@@ -158,3 +158,44 @@ describe('provisionPeer ephemeral warnings', () => {
|
|
|
158
158
|
expect(warns.some(w => /ephemeral/.test(w) && /inert|launchd/i.test(w))).toBe(true)
|
|
159
159
|
})
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
// Birth-time native-memory lever (slot contract §Native-память): an OCCUPIED
|
|
164
|
+
// slot gates the automatic lever at provision; empty slot → untouched.
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('provisionPeer birth-time native-memory lever', () => {
|
|
168
|
+
test('slot OCCUPIED → claude+codex levers written + codex cwd pre-trusted', async () => {
|
|
169
|
+
const root = mkTmp()
|
|
170
|
+
const env = envFor(root)
|
|
171
|
+
// occupy the slot (as the provider's init would)
|
|
172
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
173
|
+
writeFileSync(
|
|
174
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
175
|
+
JSON.stringify({ provider: 'iapeer-memory', package: '@agfpd/iapeer-memory', version: '0.1.0', registeredAt: 'x' }),
|
|
176
|
+
)
|
|
177
|
+
const cwd = join(root, 'wnat')
|
|
178
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
179
|
+
writeFileSync(
|
|
180
|
+
peerProfilePath(cwd),
|
|
181
|
+
JSON.stringify({ personality: 'wnat', runtime: 'claude', runtimes: ['claude', 'codex'] }),
|
|
182
|
+
)
|
|
183
|
+
await provisionPeer({ cwd, runtime: 'claude', env })
|
|
184
|
+
expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
|
|
185
|
+
expect(readFileSync(join(cwd, '.codex', 'config.toml'), 'utf8')).toContain('memories = false')
|
|
186
|
+
// codex newborn pre-trusted in the GLOBAL codex config (HOME-scoped → temp root).
|
|
187
|
+
// Codex keys trust on the RESOLVED real path (macOS /var → /private/var), so the
|
|
188
|
+
// entry must carry realpath(cwd) — the literal-path miss was a live-caught bug.
|
|
189
|
+
const { realpathSync } = await import('fs')
|
|
190
|
+
expect(readFileSync(join(root, '.codex', 'config.toml'), 'utf8')).toContain(`[projects."${realpathSync(cwd)}"]`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('slot EMPTY → native memory untouched (legitimate without a provider)', async () => {
|
|
194
|
+
const root = mkTmp()
|
|
195
|
+
const env = envFor(root)
|
|
196
|
+
const cwd = join(root, 'wbare')
|
|
197
|
+
await provisionPeer({ cwd, runtime: 'claude', env })
|
|
198
|
+
expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(false)
|
|
199
|
+
expect(existsSync(join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
})
|
package/src/update/index.ts
CHANGED
|
@@ -226,7 +226,18 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
|
|
|
226
226
|
|
|
227
227
|
const runInstall = deps.runInstall ?? defaultRunInstall
|
|
228
228
|
if (!runInstall(desired, env)) {
|
|
229
|
-
|
|
229
|
+
// NB: the installer is the DETERMINISTIC pack+build path (no npx — see
|
|
230
|
+
// defaultRunInstall); the most common cause right after a publish is the npm
|
|
231
|
+
// CDN tarball lagging the version metadata (live-hit 10.06: `npm view` already
|
|
232
|
+
// showed the version, `npm pack` still failed; a retry ~1 min later succeeded).
|
|
233
|
+
return {
|
|
234
|
+
status: 'failed',
|
|
235
|
+
from,
|
|
236
|
+
latest: desired,
|
|
237
|
+
reason:
|
|
238
|
+
`deterministic install of ${IAPEER_PACKAGE}@${desired} failed (npm pack/deps/build) — ` +
|
|
239
|
+
`if just published, the registry tarball may still be propagating; retry in ~1 min`,
|
|
240
|
+
}
|
|
230
241
|
}
|
|
231
242
|
|
|
232
243
|
const restart = deps.restartDaemon ?? kickstartDaemon
|