@agfpd/iapeer 0.2.14 → 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.14",
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": {
package/src/cli/index.ts CHANGED
@@ -359,7 +359,8 @@ const USAGE = `usage: iapeer <verb> [args]
359
359
  version | --version | -v print the installed binary's version
360
360
  help | --help | -h print this usage (works appended to any verb; executes nothing)
361
361
  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)
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)
363
364
  status host snapshot: version, daemon health, memory slot (<provider> | none)
364
365
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
365
366
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
@@ -410,6 +411,39 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
410
411
  out(r.noop ? 'onboard: no marketplace changes (already configured / dry-run)\n' : 'onboard: marketplace(s) registered\n')
411
412
  let infraFailed = false
412
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
+ }
413
447
  if (infra.length && flags['dry-run'] !== true) {
414
448
  const { onboardRuntime } = await import('../runtime/deploy.ts')
415
449
  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]', () => {
@@ -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
+ }