@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -10,6 +10,7 @@ function row(over: Partial<PeerListing>): PeerListing {
10
10
  default_runtime: 'claude',
11
11
  intelligence: 'artificial',
12
12
  description: '',
13
+ cwd: '/tmp/p',
13
14
  runtimes: [{ runtime: 'claude', status: 'asleep' }],
14
15
  ...over,
15
16
  }
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
+ }
@@ -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
- out.push({ identity: s.identity, action: 'reaped-gone', reason: 'session no longer live' })
1011
- trace({ identity: s.identity, action: 'reaped-gone', reason: 'session no longer live', outcome: 'fresh-next-msg' })
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
 
@@ -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
+ })
@@ -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
- return { status: 'failed', from, latest: desired, reason: `\`npx ${IAPEER_PACKAGE}@${desired} install\` failed` }
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