@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 +1 -1
- package/src/cli/cli.test.ts +64 -1
- package/src/cli/index.ts +18 -0
- package/src/cli/listTui.test.ts +1 -0
- package/src/lifecycle/index.ts +27 -2
- package/src/lifecycle/lifecycle.test.ts +80 -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)
|
|
@@ -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)
|
package/src/cli/listTui.test.ts
CHANGED
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/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
|