@agfpd/iapeer 0.2.14 → 0.2.16

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.14",
3
+ "version": "0.2.16",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,12 +4,12 @@
4
4
  // persistent-peer launchd plist must be refused.
5
5
 
6
6
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
- import { mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
8
8
  import { tmpdir } from 'os'
9
9
  import { join } from 'path'
10
10
  import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
11
11
  import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
12
- import { isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
12
+ import { hasIdleReaped, isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
13
13
  import { launchdPlistPath } from '../launch/launchd.ts'
14
14
 
15
15
  let root: string
@@ -73,6 +73,24 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
73
73
  test('stop an unregistered peer → throws', () => {
74
74
  expect(() => stopPeer('nobody', undefined, { env: env() })).toThrow(/not registered/)
75
75
  })
76
+ test('stop is a CLEAN PARK: leaves the resume marker and drops the session-state (boris 10.06)', async () => {
77
+ await register('parked')
78
+ const e = env()
79
+ const cfg = loadLifecycleConfig(e)
80
+ // a live session-state the daemon would otherwise tag as a death post-kill
81
+ mkdirSync(cfg.stateDir, { recursive: true })
82
+ writeFileSync(
83
+ join(cfg.stateDir, 'claude-parked.session'),
84
+ JSON.stringify({ identity: 'claude-parked', runtime: 'claude', personality: 'parked', cwd: '/tmp/parked', wokeAt: Date.now() }),
85
+ )
86
+ stopPeer('parked', undefined, { env: e })
87
+ expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true) // park marker → post-start wake RESUMES
88
+ expect(existsSync(join(cfg.stateDir, 'claude-parked.session'))).toBe(false) // not a death for supervise
89
+ // start clears only the stop flag — the park marker survives for the wake
90
+ startPeer('parked', undefined, { env: e })
91
+ expect(isStopped(cfg, 'claude-parked')).toBe(false)
92
+ expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
93
+ })
76
94
  })
77
95
 
78
96
  describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
package/src/cli/index.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  isStopped,
34
34
  killSession,
35
35
  loadLifecycleConfig,
36
+ removeSessionState,
37
+ setIdleReaped,
36
38
  setNewEager,
37
39
  setStopped,
38
40
  wakeOrSpawn,
@@ -196,8 +198,15 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
196
198
  killSession(sock, identity)
197
199
  out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
198
200
  } else {
201
+ // A deliberate stop is a CLEAN PARK, not a death (boris 10.06 — stop→start
202
+ // must survive ≥ idle-reap): park-mark BEFORE the kill so the post-`start`
203
+ // wake RESUMES, and drop the supervise session-state with the session so
204
+ // the tick never tags this kill as a death (crash-loop ring stays clean,
205
+ // no reaped-gone death class for a state the daemon knows 100%).
199
206
  setStopped(cfg, identity)
207
+ setIdleReaped(cfg, identity)
200
208
  killSession(sock, identity)
209
+ removeSessionState(cfg, identity)
201
210
  out.push({ personality, runtime: rt, action: 'stopped' })
202
211
  }
203
212
  }
@@ -359,7 +368,8 @@ const USAGE = `usage: iapeer <verb> [args]
359
368
  version | --version | -v print the installed binary's version
360
369
  help | --help | -h print this usage (works appended to any verb; executes nothing)
361
370
  daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
362
- onboard [--dry-run] [--infra <csv>] [--no-memory] [--memory <pkg>] register the agfpd marketplace (+ infra runtimes; + default memory provider, default YES)
371
+ onboard [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]
372
+ backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
363
373
  status host snapshot: version, daemon health, memory slot (<provider> | none)
364
374
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
365
375
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
@@ -410,6 +420,39 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
410
420
  out(r.noop ? 'onboard: no marketplace changes (already configured / dry-run)\n' : 'onboard: marketplace(s) registered\n')
411
421
  let infraFailed = false
412
422
  const infra = typeof flags.infra === 'string' ? flags.infra.split(',').map(s => s.trim()).filter(Boolean) : []
423
+ // Backbone default-yes steps (design «Onboard костяка», FINAL 10.06). ORDER is
424
+ // significant: notifier → TELEGRAM (creates the human peer) → memory below
425
+ // (its --human resolves from the natural peer that just appeared). Each step
426
+ // is soft-skip on unavailability; only a REAL deploy/create break fails.
427
+ const { onboardNotifierStep, onboardTelegramStep } = await import('./../onboard/steps.ts')
428
+ // An explicit `--infra notifier` takes the fail-closed explicit path below —
429
+ // the default-yes (soft-skip) step then stands aside to avoid double-deploy.
430
+ const ns = await onboardNotifierStep({
431
+ skip: flags['no-notifier'] === true || infra.includes('notifier'),
432
+ dryRun: flags['dry-run'] === true,
433
+ env,
434
+ warn: m => errOut(`warn: ${m}\n`),
435
+ })
436
+ if (ns.state !== 'skipped-flag') {
437
+ const peersLine = ns.peers.length ? ` — ${ns.peers.map(p => `${p.personality} (bootstrap ${p.bootstrap ?? 'n/a'})`).join(', ')}` : ''
438
+ out(`notifier: ${ns.state}${ns.detail ? ` — ${ns.detail}` : ''}${peersLine}\n`)
439
+ if (ns.state === 'deploy-failed') infraFailed = true
440
+ }
441
+ const ts = await onboardTelegramStep({
442
+ skip: flags['no-telegram'] === true,
443
+ human: typeof flags['telegram-human'] === 'string' ? flags['telegram-human'] : undefined,
444
+ userId: typeof flags['telegram-user-id'] === 'string' ? flags['telegram-user-id'] : undefined,
445
+ dryRun: flags['dry-run'] === true,
446
+ env,
447
+ warn: m => errOut(`warn: ${m}\n`),
448
+ })
449
+ if (ts.state !== 'skipped-flag') {
450
+ const line = `telegram: ${ts.state}${ts.personality ? ` — ${ts.personality}` : ''}${ts.detail ? ` — ${ts.detail}` : ''}\n`
451
+ // a refusal/failure goes to stderr (loud), the rest to stdout
452
+ if (ts.state === 'refused-non-tty' || ts.state === 'invalid-input' || ts.state === 'create-failed') errOut(line)
453
+ else out(line)
454
+ if (ts.state === 'invalid-input' || ts.state === 'create-failed') infraFailed = true
455
+ }
413
456
  if (infra.length && flags['dry-run'] !== true) {
414
457
  const { onboardRuntime } = await import('../runtime/deploy.ts')
415
458
  for (const rt of infra) {
@@ -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]', () => {
@@ -148,7 +148,10 @@ function writeSessionState(cfg: LifecycleConfig, state: SessionState): void {
148
148
  }
149
149
  }
150
150
 
151
- function removeSessionState(cfg: LifecycleConfig, identity: string): void {
151
+ /** Drop the supervise session-state. Exported for the STOP verb: a deliberate stop
152
+ * removes the state with the session so superviseTick never tags the kill as a
153
+ * death (no crash-loop entry, no reaped-gone death class). */
154
+ export function removeSessionState(cfg: LifecycleConfig, identity: string): void {
152
155
  try {
153
156
  rmSync(sessionStatePath(cfg, identity), { force: true })
154
157
  } catch {
@@ -198,10 +201,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
198
201
  // Lifecycle markers — the DAEMON decides fresh-vs-resume by the DEATH CAUSE it
199
202
  // tracks itself (TARGET redesign). Plain files in state/lifecycle/<identity>.* :
200
203
  //
201
- // .idle-reaped : written ONLY when the daemon idle-reaps the session (the only
202
- // death the daemon initiates). Presence on the next wake = session was parked
203
- // cleanly = RESUME-eligible. ABSENT on a dead session = it died on its own
204
- // (crash / self-close) = FRESH. (resolver branch 3.)
204
+ // .idle-reaped : the CLEAN-PARK marker — written when the daemon idle-reaps the
205
+ // session AND by the deliberate `stop` verb (both are daemon/operator-initiated
206
+ // parks, not faults; boris 10.06: stop→start must survive idle-reap).
207
+ // Presence on the next wake = session was parked cleanly = RESUME-eligible.
208
+ // ABSENT on a dead session = it died on its own (crash / self-close) = FRESH.
209
+ // (resolver branch 3.)
205
210
  // .new-eager : set when /new is invoked (owner reset, via `iapeer self-fresh`).
206
211
  // Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
207
212
  // wait for a message) and injects initial_prompt. Consumed on the relaunch.
@@ -230,7 +235,9 @@ export function hasIdleReaped(cfg: LifecycleConfig, identity: string): boolean {
230
235
  return existsSync(idleReapedPath(cfg, identity))
231
236
  }
232
237
 
233
- /** Write the idle-reaped marker ONLY the idle-reap path in superviseTick does this. */
238
+ /** Write the clean-park marker. Three writers, all deliberate parks: the idle-reap
239
+ * path in superviseTick, the `stop` verb (park before kill), and superviseTick's
240
+ * stopped-catch-up branch (a stop that raced the tick). Never a crash path. */
234
241
  export function setIdleReaped(cfg: LifecycleConfig, identity: string): void {
235
242
  mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
236
243
  writeFileSync(idleReapedPath(cfg, identity), `${new Date().toISOString()}\n`, { mode: 0o600 })
@@ -462,8 +469,15 @@ export function resolveWakeMode(
462
469
  clearIdleReaped(cfg, identity)
463
470
  return { resume: false, cause: 'ephemeral-policy' }
464
471
  }
465
- // 3a. NOT idle-reapedit died on its own (crash / self-close) clean FRESH.
466
- if (!hasIdleReaped(cfg, identity)) return { resume: false, cause: 'crash-or-self-close' }
472
+ // 3a. NOT parked-cleanFRESH either way, but tell the two cases apart in the
473
+ // cause (boris finding, 10.06): a peer with NO transcript at all never ran here —
474
+ // that is its FIRST-ever wake, not a crash. (A peer whose transcripts were rotated
475
+ // away also reads first-wake — equally honest: there is nothing it could resume.)
476
+ if (!hasIdleReaped(cfg, identity)) {
477
+ return resolveResume(cwd).ok
478
+ ? { resume: false, cause: 'crash-or-self-close' }
479
+ : { resume: false, cause: 'first-wake' }
480
+ }
467
481
  // 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
468
482
  clearIdleReaped(cfg, identity)
469
483
  // human-conversational dialogue never auto-freshes; only an explicit /new resets it.
@@ -967,7 +981,7 @@ export function killSession(sock: string, identity: string): void {
967
981
 
968
982
  export interface SuperviseOutcome {
969
983
  identity: string
970
- action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'alive' | 'needs-eager-fresh'
984
+ action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'skipped-stopped' | 'alive' | 'needs-eager-fresh'
971
985
  reason?: string
972
986
  /** For 'needs-eager-fresh': the peer to EAGERLY re-launch fresh (its session died
973
987
  * carrying a .new-eager mark). The daemon timer drives the async relaunch.
@@ -1002,6 +1016,19 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1002
1016
  }
1003
1017
  const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
1004
1018
  if (!sessionAlive(sock, s.identity)) {
1019
+ // A DELIBERATE stop is not a death (boris 10.06): the stop verb parks clean
1020
+ // (.stopped + .idle-reaped) and kills the session itself. Catch-up branch for
1021
+ // a stop that raced this tick (or a pre-fix stop): drop the state quietly —
1022
+ // no crash-loop entry, no death class — and ensure the clean-park marker so
1023
+ // the post-`start` wake RESUMES (stop→start must survive ≥ idle-reap).
1024
+ if (isStopped(cfg, s.identity)) {
1025
+ removeSessionState(cfg, s.identity)
1026
+ clearEphemeralArmed(cfg, s.identity)
1027
+ setIdleReaped(cfg, s.identity) // idempotent with the stop verb's own park
1028
+ out.push({ identity: s.identity, action: 'skipped-stopped', reason: 'deliberate stop — parked clean, resumes on start' })
1029
+ trace({ identity: s.identity, action: 'skipped-stopped', outcome: 'resume-on-start' })
1030
+ continue
1031
+ }
1005
1032
  // A dead session: record a death for crash-loop accounting, then branch on the
1006
1033
  // .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
1007
1034
  // the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
@@ -218,6 +218,24 @@ describe('superviseTick H4 guard', () => {
218
218
  expect(existsSync(join(stateDir, `${id}.session`))).toBe(true)
219
219
  })
220
220
 
221
+ test('a STOPPED peer with a dead session → skipped-stopped: no death record, park marker ensured', () => {
222
+ // boris repro (10.06): `iapeer stop` → next tick used to tag reaped-gone
223
+ // death=server-dead + recordDeath → post-start wake came up FRESH. A deliberate
224
+ // stop is a clean park the daemon knows 100% — never a death.
225
+ const c = cfg()
226
+ const id = writeState('iapeer-supstopped')
227
+ setStopped(c, id)
228
+ const out = superviseTick(c, { env: env(), nowMs: Date.now() })
229
+ const o = out.find(x => x.identity === id)
230
+ expect(o?.action).toBe('skipped-stopped')
231
+ expect(existsSync(join(stateDir, `${id}.session`))).toBe(false) // state dropped quietly
232
+ expect(readDeaths(c, id).length).toBe(0) // NOT a death — crash-loop ring untouched
233
+ expect(hasIdleReaped(c, id)).toBe(true) // clean park → post-start wake resumes
234
+ const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
235
+ expect(logged).toContain(`identity=${id} action=skipped-stopped`)
236
+ expect(logged).not.toContain('death=') // no death class for a deliberate stop
237
+ })
238
+
221
239
  test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
222
240
  const c = cfg()
223
241
  const id = writeState('iapeer-supgone') // no plist, no live tmux session
@@ -433,6 +451,19 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
433
451
  expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, hasTranscript)).toEqual({ resume: false, cause: 'crash-or-self-close' })
434
452
  })
435
453
 
454
+ test('DEFAULT + NOT idle-reaped + NO transcript at all → FRESH with cause=first-wake (not a crash)', () => {
455
+ // boris finding (10.06): the first-ever wake of a freshly created peer used to
456
+ // read crash-or-self-close — a mis-classification. No transcript = never ran.
457
+ expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, noTranscript)).toEqual({ resume: false, cause: 'first-wake' })
458
+ })
459
+
460
+ // ── stop→start is a CLEAN PARK (boris 10.06): the park marker → RESUME ────────
461
+ test('stop→start path: clean-park marker set by stop → the next default wake RESUMES', () => {
462
+ const c = cfg()
463
+ setIdleReaped(c, 'claude-p') // what the stop verb writes before killing
464
+ expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
465
+ })
466
+
436
467
  // ── branch 3b: default + idle-reaped → resume-eligible, CONSUME the marker ───
437
468
  test('DEFAULT + idle-reaped + human-conversational (interfaces.telegram) → RESUME, marker consumed', () => {
438
469
  const c = cfg()
@@ -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
+ }