@agfpd/iapeer 0.2.13 → 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.13",
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)
@@ -373,6 +380,17 @@ const USAGE = `usage: iapeer <verb> [args]
373
380
 
374
381
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
375
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
+ }
376
394
  const { positionals, flags } = parseArgs(rest)
377
395
  const out = (s: string) => process.stdout.write(s)
378
396
  const errOut = (s: string) => process.stderr.write(s)
@@ -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
  }
@@ -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
 
@@ -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