@agfpd/iapeer 0.2.13 → 0.2.15

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.15",
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,8 +357,10 @@ 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
- onboard [--dry-run] [--infra <csv>] [--no-memory] [--memory <pkg>] register the agfpd marketplace (+ infra runtimes; + default memory provider, default YES)
362
+ onboard [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]
363
+ backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
356
364
  status host snapshot: version, daemon health, memory slot (<provider> | none)
357
365
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
358
366
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
@@ -373,6 +381,17 @@ const USAGE = `usage: iapeer <verb> [args]
373
381
 
374
382
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
375
383
  const [verb, ...rest] = argv
384
+ // CLI hygiene (design «Onboard костяка» §CLI-гигиена): an explicit help request —
385
+ // `--help`/`-h` ANYWHERE on the line, or the bare `help` verb — prints usage and
386
+ // executes NOTHING. Checked on the RAW argv BEFORE the switch: parseArgs would
387
+ // bury `--help` in flags no case reads (a cold-start `onboard --help` EXECUTED on
388
+ // prod — idempotency saved it), and `-h` would land in positionals. Token-exact
389
+ // match is safe: the look-ahead parser never consumes a `--`-token as a value, so
390
+ // a LITERAL "--help" value is only expressible as `--key=--help` (not intercepted).
391
+ if (verb === 'help' || argv.includes('--help') || argv.includes('-h')) {
392
+ process.stdout.write(USAGE)
393
+ return 0
394
+ }
376
395
  const { positionals, flags } = parseArgs(rest)
377
396
  const out = (s: string) => process.stdout.write(s)
378
397
  const errOut = (s: string) => process.stderr.write(s)
@@ -392,6 +411,39 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
392
411
  out(r.noop ? 'onboard: no marketplace changes (already configured / dry-run)\n' : 'onboard: marketplace(s) registered\n')
393
412
  let infraFailed = false
394
413
  const infra = typeof flags.infra === 'string' ? flags.infra.split(',').map(s => s.trim()).filter(Boolean) : []
414
+ // Backbone default-yes steps (design «Onboard костяка», FINAL 10.06). ORDER is
415
+ // significant: notifier → TELEGRAM (creates the human peer) → memory below
416
+ // (its --human resolves from the natural peer that just appeared). Each step
417
+ // is soft-skip on unavailability; only a REAL deploy/create break fails.
418
+ const { onboardNotifierStep, onboardTelegramStep } = await import('./../onboard/steps.ts')
419
+ // An explicit `--infra notifier` takes the fail-closed explicit path below —
420
+ // the default-yes (soft-skip) step then stands aside to avoid double-deploy.
421
+ const ns = await onboardNotifierStep({
422
+ skip: flags['no-notifier'] === true || infra.includes('notifier'),
423
+ dryRun: flags['dry-run'] === true,
424
+ env,
425
+ warn: m => errOut(`warn: ${m}\n`),
426
+ })
427
+ if (ns.state !== 'skipped-flag') {
428
+ const peersLine = ns.peers.length ? ` — ${ns.peers.map(p => `${p.personality} (bootstrap ${p.bootstrap ?? 'n/a'})`).join(', ')}` : ''
429
+ out(`notifier: ${ns.state}${ns.detail ? ` — ${ns.detail}` : ''}${peersLine}\n`)
430
+ if (ns.state === 'deploy-failed') infraFailed = true
431
+ }
432
+ const ts = await onboardTelegramStep({
433
+ skip: flags['no-telegram'] === true,
434
+ human: typeof flags['telegram-human'] === 'string' ? flags['telegram-human'] : undefined,
435
+ userId: typeof flags['telegram-user-id'] === 'string' ? flags['telegram-user-id'] : undefined,
436
+ dryRun: flags['dry-run'] === true,
437
+ env,
438
+ warn: m => errOut(`warn: ${m}\n`),
439
+ })
440
+ if (ts.state !== 'skipped-flag') {
441
+ const line = `telegram: ${ts.state}${ts.personality ? ` — ${ts.personality}` : ''}${ts.detail ? ` — ${ts.detail}` : ''}\n`
442
+ // a refusal/failure goes to stderr (loud), the rest to stdout
443
+ if (ts.state === 'refused-non-tty' || ts.state === 'invalid-input' || ts.state === 'create-failed') errOut(line)
444
+ else out(line)
445
+ if (ts.state === 'invalid-input' || ts.state === 'create-failed') infraFailed = true
446
+ }
395
447
  if (infra.length && flags['dry-run'] !== true) {
396
448
  const { onboardRuntime } = await import('../runtime/deploy.ts')
397
449
  for (const rt of infra) {
@@ -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
  }
@@ -39,7 +39,8 @@ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, Runt
39
39
  * (claude-start.sh:337), shown when PEER_START_ARGS carries
40
40
  * --dangerously-load-development-channels.
41
41
  * - 'Resume from summary' — the resume compact-picker (default cursor =
42
- * "summary (recommended)", which compacts; bootDialogKeys picks "full" instead).
42
+ * "summary (recommended)", which compacts; bootDialogKeys steps the cursor to
43
+ * "full" ONE key per iteration and confirms only after SEEING it there).
43
44
  * - 'Resuming the full session' — the post-select load state (still not ready).
44
45
  */
45
46
  const CLAUDE_BOOT_DIALOG_MARKERS = [
@@ -146,21 +147,30 @@ export const claudeAdapter: RuntimeAdapter = {
146
147
  /**
147
148
  * Map a visible startup dialog to the keys that clear it correctly:
148
149
  *
149
- * - RESUME COMPACT-PICKER ('Resume from summary …') → ['Down','Enter'].
150
+ * - RESUME COMPACT-PICKER ('Resume from summary …') → CURSOR-VERIFIED two-step.
150
151
  * This menu's cursor DEFAULTS to "1. Resume from summary (recommended)",
151
152
  * and selecting it COMPACTS the session (claude implements that choice as an
152
- * internal /compact). A bare Enter would therefore silently compact a warm
153
- * peer on EVERY idle-reap→resume losing its full context. Move the cursor
154
- * DOWN one to "2. Resume full session as-is" and confirm, keeping full
155
- * context. (Verified against the live picker: on option 1, ↑↓ navigation,
156
- * "Enter to confirm".)
153
+ * internal /compact) the owner's invariant is «на resume не должно быть
154
+ * компакта». The original blind ['Down','Enter'] burst (0e67e1f) put BOTH
155
+ * keys into one pty chunk; under a heavy resume (boris 10.06, 313k-token
156
+ * session) the TUI dropped the Down and the Enter confirmed the DEFAULT
157
+ * silent /compact (proven: pane log shows the picker frame with ❯ on
158
+ * option 1 followed by "❯ /compact"; transcript compactMetadata
159
+ * trigger=manual). Fix: ONE key per boot-iteration — Down while the cursor
160
+ * is NOT yet seen on "2. Resume full session", Enter ONLY after the
161
+ * captured pane PROVES it ("❯ 2."). A swallowed Down self-heals on the
162
+ * next 2 s iteration; Enter can never hit the compacting default. If the
163
+ * picker layout ever changes, the loop Downs until the boot deadline and
164
+ * the wake fails LOUD — never a silent compact.
157
165
  * - OTHER MODALS (folder-trust / external-import / dev-channels) → ['Enter']:
158
166
  * their default-highlighted option IS the proceed path, so a bare Enter clears
159
167
  * each (claude-start.sh:341).
160
168
  * - anything else (incl. the post-select "Resuming…" load state) → null (wait).
161
169
  */
162
170
  bootDialogKeys(pane: string): string[] | null {
163
- if (pane.includes('Resume from summary')) return ['Down', 'Enter']
171
+ if (pane.includes('Resume from summary')) {
172
+ return /❯\s*2\./.test(pane) ? ['Enter'] : ['Down']
173
+ }
164
174
  if (
165
175
  pane.includes('trust this folder') ||
166
176
  pane.includes('Allow external CLAUDE.md file imports?') ||
@@ -172,12 +172,25 @@ describe('claudeAdapter', () => {
172
172
  expect(claudeAdapter.bootDialogKeys('❯ ready')).toBeNull()
173
173
  })
174
174
 
175
- test('bootDialogKeys: resume compact-picker [Down, Enter] (pick "full", NOT the recommended summary)', () => {
176
- // Default cursor is "1. Resume from summary (recommended)"; bare Enter would compact.
177
- // Move DOWN to "2. Resume full session as-is" and confirm full context preserved.
178
- expect(
179
- claudeAdapter.bootDialogKeys('❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is'),
180
- ).toEqual(['Down', 'Enter'])
175
+ test('bootDialogKeys: resume compact-picker is CURSOR-VERIFIED Enter ONLY after is seen on "2. full"', () => {
176
+ // Regression (boris 10.06, 313k resume): the blind ['Down','Enter'] burst lost the
177
+ // Down in one pty chunk and Enter confirmed the DEFAULT "1. Resume from summary"
178
+ // → silent /compact. Owner's invariant: NO compact on resume. One key per boot
179
+ // iteration; confirm only on a PROVEN cursor position.
180
+ const cursorOn1 = '❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is'
181
+ const cursorOn2 = ' 1. Resume from summary (recommended)\n❯ 2. Resume full session as-is'
182
+ // cursor on the compacting default → step down, do NOT confirm
183
+ expect(claudeAdapter.bootDialogKeys(cursorOn1)).toEqual(['Down'])
184
+ // a swallowed Down self-heals: the unchanged pane just gets another Down
185
+ expect(claudeAdapter.bootDialogKeys(cursorOn1)).toEqual(['Down'])
186
+ // cursor PROVEN on "2. Resume full session" → now (and only now) confirm
187
+ expect(claudeAdapter.bootDialogKeys(cursorOn2)).toEqual(['Enter'])
188
+ // Enter can never reach the compacting default: option-1 cursor never maps to Enter
189
+ expect(claudeAdapter.bootDialogKeys(cursorOn1)).not.toContain('Enter')
190
+ // the 2.1.170 live layout (3rd item present) behaves the same
191
+ const live170 =
192
+ "This session is 7h 3m old and 314.2k tokens.\n❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is\n 3. Don't ask me again\nEnter to confirm · Esc to cancel"
193
+ expect(claudeAdapter.bootDialogKeys(live170)).toEqual(['Down'])
181
194
  })
182
195
 
183
196
  test('permissionDialog: proceed prompt → active, [Enter]', () => {
@@ -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
 
@@ -0,0 +1,201 @@
1
+ // Backbone default-yes onboard steps (design «Onboard костяка» FINAL 10.06) —
2
+ // notifier (zero questions, soft-skip on unavailability) and telegram (human peer
3
+ // via TELEGRAM_USER_ID env → the package's self-config hook; idempotent vs an
4
+ // existing natural peer). All sandboxed: IAPEER_ROOT / IAPEER_LAUNCHAGENTS_DIR
5
+ // temp dirs, IAPEER_TEST_SANDBOX skips real launchctl, injected npx/ask.
6
+
7
+ import { afterEach, describe, expect, test } from 'bun:test'
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
9
+ import { tmpdir } from 'os'
10
+ import { join } from 'path'
11
+ import { findNaturalPeer, onboardNotifierStep, onboardTelegramStep } from './steps.ts'
12
+ import { writeRuntimeManifest, type RuntimeManifest } from '../runtime/index.ts'
13
+ import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
14
+
15
+ const roots: string[] = []
16
+ function mkTmp(): string {
17
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-steps-'))
18
+ roots.push(d)
19
+ return d
20
+ }
21
+ afterEach(() => {
22
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
23
+ })
24
+
25
+ function envFor(root: string, path?: string): NodeJS.ProcessEnv {
26
+ return {
27
+ IAPEER_ROOT: join(root, 'iapeer'),
28
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
29
+ IAPEER_TEST_SANDBOX: '1',
30
+ HOME: root,
31
+ ...(path ? { PATH: path } : {}),
32
+ } as NodeJS.ProcessEnv
33
+ }
34
+
35
+ /** Stub launcher + recording self-config hook on a PATH dir. */
36
+ function stubBins(bin: string): { dir: string; hook: string } {
37
+ const dir = mkTmp()
38
+ writeFileSync(join(dir, bin), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
39
+ const hook = join(dir, 'sc.sh')
40
+ // records personality + the TELEGRAM_USER_ID it received → proves env passthrough
41
+ writeFileSync(
42
+ hook,
43
+ '#!/bin/sh\nprintf "%s|%s" "$IAPEER_PEER_PERSONALITY" "$TELEGRAM_USER_ID" > "$IAPEER_ROOT/sc-$IAPEER_PEER_PERSONALITY"\nexit 0\n',
44
+ { mode: 0o755 },
45
+ )
46
+ return { dir, hook }
47
+ }
48
+
49
+ describe('onboardNotifierStep (а — default-yes, zero questions)', () => {
50
+ test('npx self-deploy → declared set provisioned', async () => {
51
+ const root = mkTmp()
52
+ const { dir, hook } = stubBins('notifier-runtime')
53
+ const env = envFor(root, dir)
54
+ const runNpx = (_pkg: string, e: NodeJS.ProcessEnv) => {
55
+ const m: RuntimeManifest = {
56
+ runtime: 'notifier',
57
+ selfConfig: hook,
58
+ peers: [
59
+ { personality: 'timer', intelligence: 'absent' },
60
+ { personality: 'watcher', intelligence: 'absent' },
61
+ ],
62
+ }
63
+ writeRuntimeManifest(m, { env: e })
64
+ return { ok: true }
65
+ }
66
+ const r = await onboardNotifierStep({ env, runNpx })
67
+ expect(r.state).toBe('deployed')
68
+ expect(r.peers.map(p => p.personality).sort()).toEqual(['timer', 'watcher'])
69
+ expect(findPeer(readPeersIndex({ env }), 'timer')).not.toBeNull()
70
+ })
71
+
72
+ test('npx failure (no bun / unpublished / no network) → SOFT skip, not a failure', async () => {
73
+ const env = envFor(mkTmp())
74
+ const r = await onboardNotifierStep({ env, runNpx: () => ({ ok: false, detail: 'bun: command not found' }) })
75
+ expect(r.state).toBe('skipped-unavailable')
76
+ expect(r.detail).toContain('bun: command not found')
77
+ expect(r.detail).toContain('install later')
78
+ })
79
+
80
+ test('package installed but manifest missing → deploy-failed (REAL break, not unavailability)', async () => {
81
+ const env = envFor(mkTmp())
82
+ // npx "succeeds" but self-deploys nothing — the package broke its contract
83
+ const r = await onboardNotifierStep({ env, runNpx: () => ({ ok: true }) })
84
+ expect(r.state).toBe('deploy-failed')
85
+ expect(r.detail).toContain('manifest')
86
+ })
87
+
88
+ test('--no-notifier → skipped-flag; dry-run reports intent without touching anything', async () => {
89
+ const env = envFor(mkTmp())
90
+ expect((await onboardNotifierStep({ skip: true, env })).state).toBe('skipped-flag')
91
+ const dry = await onboardNotifierStep({ dryRun: true, env })
92
+ expect(dry.state).toBe('dry-run')
93
+ expect(dry.detail).toContain('@agfpd/notifier-runtime')
94
+ })
95
+
96
+ test('idempotent re-run: manifest present → install skipped, deploy no-clobber', async () => {
97
+ const root = mkTmp()
98
+ const { dir, hook } = stubBins('notifier-runtime')
99
+ const env = envFor(root, dir)
100
+ writeRuntimeManifest(
101
+ { runtime: 'notifier', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] },
102
+ { env },
103
+ )
104
+ let npxCalled = false
105
+ const first = await onboardNotifierStep({ env, runNpx: () => ((npxCalled = true), { ok: true }) })
106
+ expect(first.state).toBe('deployed')
107
+ expect(npxCalled).toBe(false) // manifest-gated — npx never re-ran
108
+ const second = await onboardNotifierStep({ env, runNpx: () => ({ ok: true }) })
109
+ expect(second.state).toBe('deployed') // no-clobber re-verify
110
+ expect(readPeersIndex({ env }).peers.filter(p => p.personality === 'timer').length).toBe(1)
111
+ })
112
+ })
113
+
114
+ describe('onboardTelegramStep (б — human peer, идемпотентность, non-tty)', () => {
115
+ test('flags path: creates the human peer; TELEGRAM_USER_ID reaches the self-config hook', async () => {
116
+ const root = mkTmp()
117
+ const { dir, hook } = stubBins('telegram-runtime')
118
+ const env = envFor(root, dir)
119
+ writeRuntimeManifest({ runtime: 'telegram', selfConfig: hook }, { env }) // installed (mode b — no declared peers)
120
+ const r = await onboardTelegramStep({ human: 'Arthur', userId: '123456789', env })
121
+ expect(r.state).toBe('created')
122
+ expect(r.personality).toBe('arthur') // normalized
123
+ const peer = findPeer(readPeersIndex({ env }), 'arthur')!
124
+ expect(peer.intelligence).toBe('natural')
125
+ expect(peer.runtime).toBe('telegram')
126
+ // the hook saw the user id via env (owner's contract: hook writes interfaces itself)
127
+ expect(readFileSync(join(env.IAPEER_ROOT as string, 'sc-arthur'), 'utf8')).toBe('arthur|123456789')
128
+ })
129
+
130
+ test('IDEMPOTENT: an existing natural peer → already, NEVER a second human (boris check)', async () => {
131
+ const env = envFor(mkTmp())
132
+ await upsertPeer(
133
+ { personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' },
134
+ { env },
135
+ )
136
+ let asked = false
137
+ const r = await onboardTelegramStep({
138
+ env,
139
+ ask: async () => ((asked = true), 'never'),
140
+ isTty: true,
141
+ })
142
+ expect(r.state).toBe('already')
143
+ expect(r.personality).toBe('arthur')
144
+ expect(r.detail).toContain('уже есть')
145
+ expect(asked).toBe(false) // no questions, no install — the WHOLE step no-ops
146
+ })
147
+
148
+ test('non-tty without flags → refusal of the STEP (with the flag recipe), not of onboard', async () => {
149
+ const root = mkTmp()
150
+ const env = envFor(root)
151
+ writeRuntimeManifest({ runtime: 'telegram' }, { env })
152
+ const r = await onboardTelegramStep({ env, isTty: false })
153
+ expect(r.state).toBe('refused-non-tty')
154
+ expect(r.detail).toContain('--telegram-human')
155
+ })
156
+
157
+ test('tty prompt path: answers flow in; empty answer → soft refusal', async () => {
158
+ const root = mkTmp()
159
+ const { dir, hook } = stubBins('telegram-runtime')
160
+ const env = envFor(root, dir)
161
+ writeRuntimeManifest({ runtime: 'telegram', selfConfig: hook }, { env })
162
+ const answers = ['leo', '42']
163
+ const r = await onboardTelegramStep({ env, isTty: true, ask: async () => answers.shift() ?? '' })
164
+ expect(r.state).toBe('created')
165
+ expect(r.personality).toBe('leo')
166
+ // empty answer → not now
167
+ const env2 = envFor(mkTmp())
168
+ writeRuntimeManifest({ runtime: 'telegram' }, { env: env2 })
169
+ const r2 = await onboardTelegramStep({ env: env2, isTty: true, ask: async () => '' })
170
+ expect(r2.state).toBe('refused-non-tty')
171
+ })
172
+
173
+ test('invalid explicit flags → invalid-input (hard): bad name / non-digit user id', async () => {
174
+ const root = mkTmp()
175
+ const env = envFor(root)
176
+ writeRuntimeManifest({ runtime: 'telegram' }, { env })
177
+ expect((await onboardTelegramStep({ human: '!!!', userId: '42', env })).state).toBe('invalid-input')
178
+ const r = await onboardTelegramStep({ human: 'leo', userId: 'abc', env })
179
+ expect(r.state).toBe('invalid-input')
180
+ expect(r.detail).toContain('@userinfobot')
181
+ })
182
+
183
+ test('package unavailable → soft skip (step), with the install-later recipe', async () => {
184
+ const env = envFor(mkTmp())
185
+ const r = await onboardTelegramStep({
186
+ human: 'leo',
187
+ userId: '42',
188
+ env,
189
+ runNpx: () => ({ ok: false, detail: 'npm ERR 404' }),
190
+ })
191
+ expect(r.state).toBe('skipped-unavailable')
192
+ expect(r.detail).toContain('install-runtime telegram')
193
+ })
194
+
195
+ test('findNaturalPeer: null on empty/no registry, found when present', async () => {
196
+ const env = envFor(mkTmp())
197
+ expect(findNaturalPeer(env)).toBeNull()
198
+ await upsertPeer({ personality: 'h', runtime: 'telegram', cwd: '/tmp/h', intelligence: 'natural' }, { env })
199
+ expect(findNaturalPeer(env)).toBe('h')
200
+ })
201
+ })
@@ -0,0 +1,246 @@
1
+ // Default-yes onboard steps for the distribution BACKBONE (design doc
2
+ // docs/«Onboard костяка — notifier, telegram, каналы, update» — FINAL 10.06,
3
+ // sanctioned by Артур; owners' facts baked: notifier ack «ноль вопросов»,
4
+ // telegram self-config contract v0.10.0+).
5
+ //
6
+ // Backbone = core + memory slot + notifier-runtime + telegram-runtime. The onboard
7
+ // step ORDER is significant: marketplace → notifier → TELEGRAM (creates the human
8
+ // peer) → memory (its --human resolves from the natural peer that just appeared).
9
+ //
10
+ // UX principle («вопросами владеет рантайм»): the package owns its questions; the
11
+ // core orchestrates the steps, passes only facts IT owns, and inherits stdio for
12
+ // the package's own interactive. Every step is OPTIONAL (default-yes, removed by a
13
+ // flag); unavailability is a SOFT SKIP, never a core failure.
14
+
15
+ import { spawnSync } from 'child_process'
16
+ import { isValidName, normalizeIntelligenceValue, normalizeNameCandidate } from '../core/constants.ts'
17
+ import { readPeersIndex } from '../registry/index.ts'
18
+ import { createPeer } from '../create/index.ts'
19
+ import {
20
+ deployRuntime,
21
+ installRuntimePackage,
22
+ RUNTIME_PACKAGES,
23
+ type DeployedPeer,
24
+ type NpxRunner,
25
+ } from '../runtime/deploy.ts'
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // (а) notifier — default-yes, ZERO questions (owner-confirmed: the whole chain
29
+ // has no prompt/stdin/tty dependency; all failures are fail-closed exit≠0).
30
+ // Prereq (not a question): `bun` on PATH — without it the npx shim exits non-zero
31
+ // → SOFT SKIP with a clear message (design: unavailability is not a core error).
32
+ // Idempotent re-onboard: install is manifest-gated (skipped), deploy is no-clobber
33
+ // («1 плист = 1 infra-пир»).
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+
36
+ export interface NotifierStepOptions {
37
+ /** --no-notifier: remove the default-yes step. */
38
+ skip?: boolean
39
+ dryRun?: boolean
40
+ env?: NodeJS.ProcessEnv
41
+ /** Injected npx runner (tests / sandbox proof). */
42
+ runNpx?: NpxRunner
43
+ warn?: (message: string) => void
44
+ }
45
+
46
+ export interface NotifierStepResult {
47
+ state:
48
+ | 'deployed' // declared set provisioned (or re-verified — idempotent)
49
+ | 'skipped-flag' // --no-notifier
50
+ | 'skipped-unavailable' // npx failed (no bun / not published / no network) — soft skip
51
+ | 'deploy-failed' // the package installed but the declared-set deploy broke — REAL failure
52
+ | 'dry-run'
53
+ peers: DeployedPeer[]
54
+ detail?: string
55
+ }
56
+
57
+ export async function onboardNotifierStep(opts: NotifierStepOptions = {}): Promise<NotifierStepResult> {
58
+ const env = opts.env ?? process.env
59
+ if (opts.skip) return { state: 'skipped-flag', peers: [] }
60
+ if (opts.dryRun) {
61
+ return {
62
+ state: 'dry-run',
63
+ peers: [],
64
+ detail: `would npx-install ${RUNTIME_PACKAGES.notifier} + deploy its declared peer-set (timer, watcher)`,
65
+ }
66
+ }
67
+ const install = installRuntimePackage({ runtime: 'notifier', env, runNpx: opts.runNpx })
68
+ if (install.state === 'failed' || install.state === 'no-package') {
69
+ // SOFT skip (design (а)): a missing prereq (bun) / unpublished package / no
70
+ // network must not fail the core's onboard — report and move on.
71
+ return {
72
+ state: 'skipped-unavailable',
73
+ peers: [],
74
+ detail: `${install.package ?? 'notifier package'} install failed (${install.detail?.split('\n')[0] ?? 'npx non-zero'}) — install later: iapeer install-runtime notifier`,
75
+ }
76
+ }
77
+ try {
78
+ const deploy = await deployRuntime({ runtime: 'notifier', env, warn: opts.warn })
79
+ const broken = deploy.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed')
80
+ return {
81
+ state: broken ? 'deploy-failed' : 'deployed',
82
+ peers: deploy.peers,
83
+ detail: broken ? 'a declared peer failed self-config/bootstrap — see the per-peer lines' : undefined,
84
+ }
85
+ } catch (e) {
86
+ // The package installed but its manifest/deploy contract broke — a REAL failure
87
+ // (not unavailability), surfaced as such.
88
+ return { state: 'deploy-failed', peers: [], detail: e instanceof Error ? e.message : String(e) }
89
+ }
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+ // (б) telegram — default-yes, install WITH setup. The human owes exactly two
94
+ // facts: the human-peer name and their telegram user_id (prompt hints at
95
+ // @userinfobot). The core creates the HUMAN peer via createPeer with
96
+ // TELEGRAM_USER_ID in env — the package's self-config hook (contract v0.10.0+)
97
+ // writes interfaces.telegram.user_id itself; no prepare / interface-human calls.
98
+ // Bot tokens are NOT asked here — they are per-channel (`iapeer connect telegram`).
99
+ //
100
+ // IDEMPOTENT re-onboard (boris's check, baked into the design): an EXISTING
101
+ // natural peer in the registry → the step is a no-op/skip with a clear message —
102
+ // NEVER an offer to create a second human peer.
103
+ //
104
+ // Interactive lives INSIDE the step (tty only); non-tty without the flags
105
+ // (--telegram-human + --telegram-user-id) → an explicit refusal of the STEP, not
106
+ // of the whole onboard.
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+
109
+ export interface TelegramStepOptions {
110
+ /** --no-telegram: remove the default-yes step. */
111
+ skip?: boolean
112
+ /** --telegram-human <p>: the human peer's personality (skips the prompt). */
113
+ human?: string
114
+ /** --telegram-user-id <id>: the owner's telegram user id (skips the prompt). */
115
+ userId?: string
116
+ dryRun?: boolean
117
+ env?: NodeJS.ProcessEnv
118
+ runNpx?: NpxRunner
119
+ /** Injectable prompt (tests). Default: readline on the live tty. */
120
+ ask?: (question: string) => Promise<string>
121
+ /** Override tty detection (tests). Default: stdin AND stdout are ttys. */
122
+ isTty?: boolean
123
+ warn?: (message: string) => void
124
+ }
125
+
126
+ export interface TelegramStepResult {
127
+ state:
128
+ | 'created' // human peer created; user_id delivered via the self-config hook
129
+ | 'already' // a natural peer already exists — idempotent skip (never a second human)
130
+ | 'skipped-flag' // --no-telegram
131
+ | 'skipped-unavailable' // package install failed — soft skip
132
+ | 'refused-non-tty' // no tty and no flags — the STEP refuses, onboard continues
133
+ | 'invalid-input' // explicit flags carried an invalid name / user id — hard
134
+ | 'create-failed' // createPeer threw — hard
135
+ | 'dry-run'
136
+ personality?: string
137
+ detail?: string
138
+ }
139
+
140
+ async function ttyAsk(question: string): Promise<string> {
141
+ const { createInterface } = await import('node:readline/promises')
142
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
143
+ try {
144
+ return (await rl.question(question)).trim()
145
+ } finally {
146
+ rl.close()
147
+ }
148
+ }
149
+
150
+ /** The registry's existing natural peer, if any (the idempotency key of the step). */
151
+ export function findNaturalPeer(env: NodeJS.ProcessEnv): string | null {
152
+ try {
153
+ const naturals = readPeersIndex({ env }).peers.filter(
154
+ p => normalizeIntelligenceValue(p.intelligence) === 'natural',
155
+ )
156
+ return naturals[0]?.personality ?? null
157
+ } catch {
158
+ return null // no registry yet → no natural peer
159
+ }
160
+ }
161
+
162
+ export async function onboardTelegramStep(opts: TelegramStepOptions = {}): Promise<TelegramStepResult> {
163
+ const env = opts.env ?? process.env
164
+ if (opts.skip) return { state: 'skipped-flag' }
165
+
166
+ // Idempotency FIRST (before any install/questions): an existing natural peer
167
+ // means the host already has its human — the whole step is a no-op.
168
+ const existing = findNaturalPeer(env)
169
+ if (existing) {
170
+ return { state: 'already', personality: existing, detail: `human-пир "${existing}" уже есть — шаг пропущен` }
171
+ }
172
+
173
+ if (opts.dryRun) {
174
+ return {
175
+ state: 'dry-run',
176
+ detail: `would npx-install ${RUNTIME_PACKAGES.telegram} + create the human peer (asks: name, telegram user_id)`,
177
+ }
178
+ }
179
+
180
+ const install = installRuntimePackage({ runtime: 'telegram', env, runNpx: opts.runNpx })
181
+ if (install.state === 'failed' || install.state === 'no-package') {
182
+ return {
183
+ state: 'skipped-unavailable',
184
+ detail: `${install.package ?? 'telegram package'} install failed (${install.detail?.split('\n')[0] ?? 'npx non-zero'}) — install later: iapeer install-runtime telegram`,
185
+ }
186
+ }
187
+
188
+ // Resolve the two human-owed facts: flags → prompt (tty) → refuse (non-tty).
189
+ let human = opts.human?.trim()
190
+ let userId = opts.userId?.trim()
191
+ if (!human || !userId) {
192
+ const tty = opts.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true)
193
+ if (!tty) {
194
+ return {
195
+ state: 'refused-non-tty',
196
+ detail:
197
+ 'no tty for the telegram questions — re-run with --telegram-human <name> --telegram-user-id <id>, ' +
198
+ 'or skip with --no-telegram (add later: iapeer create <name> --runtime telegram)',
199
+ }
200
+ }
201
+ const ask = opts.ask ?? ttyAsk
202
+ if (!human) human = (await ask('telegram step — your human-peer name (short, latin): ')).trim()
203
+ if (human && !userId) {
204
+ userId = (await ask('your telegram user_id (message @userinfobot — it replies with the id): ')).trim()
205
+ }
206
+ if (!human || !userId) {
207
+ // An empty answer on the live tty is a "not now" — soft refusal of the step.
208
+ return {
209
+ state: 'refused-non-tty',
210
+ detail: 'no answer — telegram step skipped (add later: iapeer create <name> --runtime telegram)',
211
+ }
212
+ }
213
+ }
214
+
215
+ const personality = normalizeNameCandidate(human)
216
+ if (!isValidName(personality)) {
217
+ return { state: 'invalid-input', detail: `invalid human-peer name "${human}" — must normalize to /^[a-z][a-z0-9-]{0,31}$/` }
218
+ }
219
+ if (!/^\d+$/.test(userId)) {
220
+ return { state: 'invalid-input', detail: `invalid telegram user_id "${userId}" — expected digits (ask @userinfobot)` }
221
+ }
222
+
223
+ try {
224
+ // TELEGRAM_USER_ID rides the env into createPeer → initPeer → the package's
225
+ // self-config hook, which writes interfaces.telegram.user_id itself (owner's
226
+ // simplification, contract v0.10.0+ — no prepare / interface-human from the core).
227
+ const r = await createPeer({
228
+ personality,
229
+ runtime: 'telegram',
230
+ intelligence: 'natural',
231
+ env: { ...env, TELEGRAM_USER_ID: userId },
232
+ warn: opts.warn,
233
+ })
234
+ const sc = r.selfConfig?.state ?? 'absent'
235
+ if (sc === 'failed') {
236
+ return {
237
+ state: 'create-failed',
238
+ personality,
239
+ detail: `peer created but the telegram self-config hook failed: ${r.selfConfig?.detail ?? ''}`,
240
+ }
241
+ }
242
+ return { state: 'created', personality, detail: `self-config ${sc}; bootstrap ${r.bootstrapped?.state ?? 'n/a'}` }
243
+ } catch (e) {
244
+ return { state: 'create-failed', personality, detail: e instanceof Error ? e.message : String(e) }
245
+ }
246
+ }
@@ -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