@agfpd/iapeer 0.3.0 → 0.4.1

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/src/index.ts CHANGED
@@ -23,6 +23,8 @@ export * from './init/index.ts'
23
23
  export * from './install/index.ts'
24
24
  // Update — the cloud-only deploy path (`iapeer update`: npm latest → rebuild → restart).
25
25
  export * from './update/index.ts'
26
+ // Uninstall — symmetric foundation removal (namespace-safe; refuses on a foreign fleet).
27
+ export * from './uninstall/index.ts'
26
28
  // Onboard — the host-phase (idempotent marketplace registration in claude + codex).
27
29
  export * from './onboard/index.ts'
28
30
  // Memory slot — the declarative provider slot (контракт «Слот памяти»): status read + onboard step.
package/src/init/index.ts CHANGED
@@ -21,6 +21,7 @@ import { IapError } from '../core/errors.ts'
21
21
  import {
22
22
  ensureLocalIapScaffold,
23
23
  pluginStateDir,
24
+ resolveGlobalRoot,
24
25
  writeFileAtomic,
25
26
  type StorageOptions,
26
27
  } from '../storage/index.ts'
@@ -245,6 +246,36 @@ export function ensureDoctrineTemplate(cwd: string): { path: string; created: bo
245
246
  return { path, created: true }
246
247
  }
247
248
 
249
+ /**
250
+ * Create the GLOBAL host-doctrine template `~/.iapeer/IAPEER.md` if absent — the
251
+ * host-wide owner doctrine (Layer-2 GLOBAL in composeSystemPrompt, shared by EVERY
252
+ * peer; the general layer ABOVE each peer's own `.iapeer/IAPEER.md`). It is consumed at
253
+ * launch but was never scaffolded, so a fresh install left no host-doctrine file for the
254
+ * owner to find — asymmetric with the per-peer stub (ensureDoctrineTemplate). This
255
+ * mirrors that stub at host scope (discoverability + consistency). NEVER overwrites an
256
+ * existing host doctrine. Returns {path, created}.
257
+ */
258
+ export function ensureGlobalDoctrineTemplate(env: NodeJS.ProcessEnv = process.env): { path: string; created: boolean } {
259
+ const root = resolveGlobalRoot(env)
260
+ const path = join(root, IAPEER_DOCTRINE_FILE)
261
+ if (existsSync(path)) return { path, created: false }
262
+ mkdirSync(root, { recursive: true })
263
+ writeFileSync(
264
+ path,
265
+ [
266
+ '# Host doctrine',
267
+ '',
268
+ '<!-- Host-wide rules shared by EVERY peer on this machine — the general layer,',
269
+ " merged into every peer's system prompt at launch ABOVE that peer's own",
270
+ ' .iapeer/IAPEER.md. Replace this with your host-wide conventions. An empty',
271
+ ' file contributes nothing. -->',
272
+ '',
273
+ ].join('\n'),
274
+ { mode: 0o644 },
275
+ )
276
+ return { path, created: true }
277
+ }
278
+
248
279
  // ─────────────────────────────────────────────────────────────────────────────
249
280
  // initPeer — orchestrate: provision (identity + registry + infra plist) + per-peer
250
281
  // ecosystem wiring (MCP transport per agentic runtime + doctrine template)
@@ -13,10 +13,11 @@
13
13
  // signInstalledBinary (signing.ts) re-signs each install with the
14
14
  // stable local identity so the designated requirement — and the grants — survive.
15
15
 
16
- import { copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
16
+ import { copyFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
17
17
  import { homedir } from 'os'
18
- import { join } from 'path'
18
+ import { basename, join, relative, sep } from 'path'
19
19
  import { spawnSync } from 'child_process'
20
+ import { resolveGlobalRoot } from '../storage/index.ts'
20
21
  import { signInstalledBinary, type SigningOutcome } from './signing.ts'
21
22
 
22
23
  /** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
@@ -95,6 +96,64 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
95
96
  return { binPath, prevPath, size, signing }
96
97
  }
97
98
 
99
+ /**
100
+ * Scaffold an ecosystem package's docs to the STABLE, versioned, per-package host
101
+ * path `~/.iapeer/docs/<pkg>/` so an agent can read the contract OFFLINE (a compiled
102
+ * binary embeds no docs; the npm tarball's docs/ is discarded after install — proven:
103
+ * `iapeer update` extracts to a temp dir it rm's in a `finally`, and the bunx cache
104
+ * does not retain the package findably). This is the FOUNDATION-OWNED CONVENTION every
105
+ * ecosystem package follows: each copies its OWN docs on its OWN install/update, so a
106
+ * package's on-host docs always match its installed version.
107
+ *
108
+ * Mechanics: copy <docsSource> → ~/.iapeer/docs/<pkg> EXCLUDING internals/ (mirroring
109
+ * the npm `files` exclusion), via an atomic temp-dir swap so a reader never sees a
110
+ * half-copied tree. BEST-EFFORT — a missing source or copy hiccup NEVER fails the
111
+ * install (the binary works without on-host docs); the caller logs the outcome.
112
+ */
113
+ export function scaffoldHostDocs(
114
+ pkg: string,
115
+ docsSource: string,
116
+ env: NodeJS.ProcessEnv = process.env,
117
+ ): { copied: boolean; dest: string; reason?: string } {
118
+ const root = resolveGlobalRoot(env)
119
+ const dest = join(root, 'docs', pkg)
120
+ // Fail-closed sandbox guard (in spirit with assertInstallSandboxIsolated): never write
121
+ // the REAL machine ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT.
122
+ // Compared against the ACTUAL OS home (homedir()), not env.HOME — the root IS the docs
123
+ // isolation, so an env.HOME-based check would false-trip when a test legitimately
124
+ // points IAPEER_ROOT at <tmp>/.iapeer (with env.HOME also <tmp>).
125
+ if (env.IAPEER_TEST_SANDBOX === '1' && root === join(homedir(), '.iapeer')) {
126
+ throw new Error(`refusing to scaffold docs into the REAL ${join(root, 'docs')} under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT`)
127
+ }
128
+ if (!existsSync(docsSource)) return { copied: false, dest, reason: `docs source not found: ${docsSource}` }
129
+ const tmp = `${dest}.tmp-${process.pid}`
130
+ try {
131
+ rmSync(tmp, { recursive: true, force: true })
132
+ mkdirSync(join(dest, '..'), { recursive: true })
133
+ cpSync(docsSource, tmp, {
134
+ recursive: true,
135
+ // Skip the internals/ subtree (matches package.json files: "!docs/internals";
136
+ // returning false for a directory skips its whole subtree) and macOS .DS_Store
137
+ // cruft (the foundation is macOS-targeted, so every dev/host tree carries it).
138
+ filter: src => {
139
+ if (basename(src) === '.DS_Store') return false
140
+ const rel = relative(docsSource, src)
141
+ return rel !== 'internals' && !rel.startsWith(`internals${sep}`)
142
+ },
143
+ })
144
+ rmSync(dest, { recursive: true, force: true })
145
+ renameSync(tmp, dest)
146
+ return { copied: true, dest }
147
+ } catch (e) {
148
+ try {
149
+ rmSync(tmp, { recursive: true, force: true })
150
+ } catch {
151
+ /* best-effort cleanup */
152
+ }
153
+ return { copied: false, dest, reason: e instanceof Error ? e.message : String(e) }
154
+ }
155
+ }
156
+
98
157
  /** The previous-binary path kept by the last install for one-step rollback. */
99
158
  export function iapeerPrevBinPath(env: NodeJS.ProcessEnv = process.env): string {
100
159
  return `${iapeerBinPath(env)}.prev`
@@ -103,6 +103,14 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
103
103
  // The YAML identity block is the only Layer-1 section (synthesized, no marker).
104
104
  const sections: string[] = [yaml]
105
105
 
106
+ // Host-context pointer to the on-host ecosystem docs (FU6). Each package's install
107
+ // scaffolds its own contract docs to the stable, versioned, per-package path
108
+ // ~/.iapeer/docs/<package>/, so an agent can read "how does iapeer do X" offline
109
+ // instead of guessing. Synthesized Layer-1 fact (no marker). Wording: owner-approved.
110
+ sections.push(
111
+ 'iapeer reference docs are on this host at ~/.iapeer/docs/ — one folder per package (start at iapeer/README.md). Consult them for how iapeer works instead of guessing.',
112
+ )
113
+
106
114
  // Layer 2 — IAPEER.md doctrine, GLOBAL then LOCAL (general → specific), each its
107
115
  // OWN marked file-section. The bash existence-gate (present-but-empty global → a
108
116
  // bare "\n") is dropped deliberately: an empty doctrine file now contributes no
@@ -25,11 +25,26 @@ export interface MemoryOnboardOptions {
25
25
  skip?: boolean
26
26
  /** --memory <pkg>: override the provider package (default @agfpd/iapeer-memory). */
27
27
  package?: string
28
+ /** Host agentic runtime to thread to the provider init (`--runtime <rt>`) — the
29
+ * onboard↔init contract: the provider provisions its role-peers (index/watcher/
30
+ * timer) on THIS runtime instead of guessing. Omitted on a no-runtime host →
31
+ * the provider degrades (base+BM25, role-peers skipped). */
32
+ runtime?: string
28
33
  dryRun?: boolean
29
34
  env?: NodeJS.ProcessEnv
35
+ /** Wall-clock backstop (ms) for the provider-init subprocess — a hung provider
36
+ * (e.g. a stalled native build) must NEVER wedge onboard (memory is optional).
37
+ * The interactive wizard passes a generous value; the linear path leaves it
38
+ * unset (no timeout). */
39
+ timeoutMs?: number
30
40
  /** Injectable runner (tests). Default: availability probe + `npx -y <pkg> init …`
31
41
  * with inherited stdio. */
32
- runInit?: (pkg: string, args: string[], env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
42
+ runInit?: (
43
+ pkg: string,
44
+ args: string[],
45
+ env: NodeJS.ProcessEnv,
46
+ timeoutMs?: number,
47
+ ) => { status: number | null; unavailable: boolean }
33
48
  }
34
49
 
35
50
  export interface MemoryOnboardResult {
@@ -49,17 +64,24 @@ function defaultRunInit(
49
64
  pkg: string,
50
65
  args: string[],
51
66
  env: NodeJS.ProcessEnv,
67
+ timeoutMs?: number,
52
68
  ): { status: number | null; unavailable: boolean } {
53
69
  // Availability probe FIRST (cheap, side-effect-free): an unpublished package /
54
70
  // no network → soft-skip per the contract (the provider's release order must
55
- // never block the core's onboard).
56
- const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env })
71
+ // never block the core's onboard). Bounded so the probe itself cannot hang.
72
+ const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
57
73
  if (probe.status !== 0) return { status: probe.status, unavailable: true }
58
74
  // INHERITED stdio — the provider owns its install questions (tty interactive
59
75
  // happens here, once). npx bin-name nuance (cf. the 0.2.9 update pitfall): with
60
76
  // the provider bin already on PATH npx runs the INSTALLED one — acceptable for
61
77
  // an idempotent init (unlike the self-update case where it was a structural bug).
62
- const r = spawnSync('npx', ['-y', pkg, ...args], { stdio: 'inherit', env })
78
+ // timeout: a generous wall-clock backstop so a hung provider can't wedge onboard
79
+ // (the caller also makes Ctrl-C skip just this step).
80
+ const r = spawnSync('npx', ['-y', pkg, ...args], {
81
+ stdio: 'inherit',
82
+ env,
83
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
84
+ })
63
85
  return { status: r.status, unavailable: false }
64
86
  }
65
87
 
@@ -91,12 +113,12 @@ export async function onboardMemoryProvider(opts: MemoryOnboardOptions = {}): Pr
91
113
  detail: `slot is occupied by "${existing.provider}" (${existing.package}) — uninstall it first`,
92
114
  }
93
115
  }
94
- const args = ['init', ...coreKnownInitArgs(env)]
116
+ const args = ['init', ...coreKnownInitArgs(env), ...(opts.runtime ? ['--runtime', opts.runtime] : [])]
95
117
  if (opts.dryRun) {
96
118
  return { state: 'dry-run', provider: null, detail: `would run: npx -y ${pkg} ${args.join(' ')}` }
97
119
  }
98
120
  const run = opts.runInit ?? defaultRunInit
99
- const r = run(pkg, args, env)
121
+ const r = run(pkg, args, env, opts.timeoutMs)
100
122
  if (r.unavailable) {
101
123
  return {
102
124
  state: 'skipped-unavailable',
@@ -0,0 +1,71 @@
1
+ // ANSI palette for plain-text CLI output that runs OUTSIDE Ink — chiefly the
2
+ // post-Ink onboard summary (run.tsx), which prints after the wizard unmounts and
3
+ // would otherwise read as a flat, colorless tail to a styled flow.
4
+ //
5
+ // It mirrors the Ink wizard's palette one-for-one (see app.tsx color()/glyph()):
6
+ // ok → green ✓ · warn → yellow ! · fail → red ✗ · pending → gray · ·
7
+ // headers → bold (cyan for emphasis) · secondary detail/paths → dim
8
+ // so the seam between the React-rendered part and the printed part is invisible.
9
+ //
10
+ // Color is GATED (colorEnabled): on by default only on a real TTY, force-on with
11
+ // FORCE_COLOR (covers inherited-pipe passthrough by a child process), and hard-off
12
+ // with NO_COLOR (https://no-color.org — presence disables, any value) so piped /
13
+ // redirected / CI captures stay byte-clean. This is the team-wide convention we
14
+ // hand to peer providers (e.g. iapeer-memory) whose stdout we surface verbatim.
15
+
16
+ const ESC = '\x1b['
17
+ const RESET = `${ESC}0m`
18
+
19
+ const CODE = {
20
+ bold: '1',
21
+ dim: '2',
22
+ red: '31',
23
+ green: '32',
24
+ yellow: '33',
25
+ cyan: '36',
26
+ gray: '90',
27
+ } as const
28
+
29
+ export type AnsiPaint = (s: string) => string
30
+
31
+ export interface Ansi {
32
+ bold: AnsiPaint
33
+ dim: AnsiPaint
34
+ red: AnsiPaint
35
+ green: AnsiPaint
36
+ yellow: AnsiPaint
37
+ cyan: AnsiPaint
38
+ gray: AnsiPaint
39
+ }
40
+
41
+ /** Whether to emit ANSI color for `stream`. NO_COLOR (any value) hard-disables;
42
+ * FORCE_COLOR (truthy, not "0"/"false") forces on; otherwise gated on a real TTY. */
43
+ export function colorEnabled(
44
+ env: NodeJS.ProcessEnv = process.env,
45
+ stream: { isTTY?: boolean } = process.stdout,
46
+ ): boolean {
47
+ if (env.NO_COLOR != null) return false
48
+ const fc = env.FORCE_COLOR
49
+ if (fc != null && fc !== '0' && fc !== 'false' && fc !== '') return true
50
+ return stream.isTTY === true
51
+ }
52
+
53
+ /** Build a palette. When `enabled` is false every paint fn is identity (no codes),
54
+ * so the same call sites produce clean text for pipes/CI. Codes compose: an inner
55
+ * paint applies before the outer, both before the run resets — `a.bold(a.cyan(s))`
56
+ * renders bold+cyan. */
57
+ export function makeAnsi(enabled: boolean): Ansi {
58
+ const wrap =
59
+ (code: string): AnsiPaint =>
60
+ (s: string) =>
61
+ enabled ? `${ESC}${code}m${s}${RESET}` : s
62
+ return {
63
+ bold: wrap(CODE.bold),
64
+ dim: wrap(CODE.dim),
65
+ red: wrap(CODE.red),
66
+ green: wrap(CODE.green),
67
+ yellow: wrap(CODE.yellow),
68
+ cyan: wrap(CODE.cyan),
69
+ gray: wrap(CODE.gray),
70
+ }
71
+ }
@@ -20,15 +20,24 @@ export interface WizardResult {
20
20
  memoryConsent: boolean
21
21
  advisories: string[]
22
22
  summary: string[]
23
+ telegramConsent: boolean
24
+ /** Host agentic runtime detected during the run (claude if installed, else
25
+ * codex, else undefined on a no-runtime host) — threaded to the memory
26
+ * provider init as `--runtime` per the onboard↔init contract. */
27
+ hostRuntime?: OnboardRuntime
23
28
  }
24
29
 
25
- type Phase = 'gate' | 'running' | 'memory' | 'declined' | 'finishing'
30
+ type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'declined' | 'finishing'
26
31
  type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
27
32
  interface Step {
28
33
  key: string
29
34
  label: string
30
35
  status: StepStatus
31
36
  detail?: string
37
+ /** Honest "this can take a moment" note shown while the step is running (the
38
+ * spinner can't animate during a blocking spawnSync, so the label carries the
39
+ * expectation). */
40
+ hint?: string
32
41
  }
33
42
 
34
43
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -85,6 +94,8 @@ export function OnboardApp({
85
94
  const [phase, setPhase] = useState<Phase>('gate')
86
95
  const [steps, setSteps] = useState<Step[]>([])
87
96
  const [advisories, setAdvisories] = useState<string[]>([])
97
+ const [hostRuntime, setHostRuntime] = useState<OnboardRuntime | undefined>(undefined)
98
+ const [telegramConsent, setTelegramConsent] = useState(false)
88
99
  const spin = useSpinnerFrame(phase === 'running')
89
100
 
90
101
  const finish = (memoryConsent: boolean): void => {
@@ -94,7 +105,7 @@ export function OnboardApp({
94
105
  return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
95
106
  })
96
107
  summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
97
- onResult({ code: failed ? 1 : 0, memoryConsent, advisories, summary })
108
+ onResult({ code: failed ? 1 : 0, memoryConsent, telegramConsent, advisories, summary, hostRuntime })
98
109
  setPhase('finishing')
99
110
  setTimeout(() => exit(), 30)
100
111
  }
@@ -108,6 +119,22 @@ export function OnboardApp({
108
119
  { isActive: phase === 'gate' },
109
120
  )
110
121
 
122
+ // Telegram consent — DEFAULT-YES ([Y/n]). Records the decision; the actual setup
123
+ // (install + ask name/user_id) runs post-Ink (its readline needs a cooked
124
+ // terminal). Then → memory consent.
125
+ useInput(
126
+ (input, key) => {
127
+ if (input === 'n' || input === 'N') {
128
+ setTelegramConsent(false)
129
+ setPhase('memory')
130
+ } else if (input === 'y' || input === 'Y' || key.return) {
131
+ setTelegramConsent(true)
132
+ setPhase('memory')
133
+ }
134
+ },
135
+ { isActive: phase === 'telegram' },
136
+ )
137
+
111
138
  // Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install).
112
139
  useInput(
113
140
  (input, key) => {
@@ -120,7 +147,7 @@ export function OnboardApp({
120
147
  // Declined → exit 1 (no host mutation).
121
148
  useEffect(() => {
122
149
  if (phase !== 'declined') return
123
- onResult({ code: 1, memoryConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
150
+ onResult({ code: 1, memoryConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
124
151
  const t = setTimeout(() => exit(), 30)
125
152
  return () => clearTimeout(t)
126
153
  }, [phase, onResult, exit])
@@ -132,10 +159,16 @@ export function OnboardApp({
132
159
  const set = (k: string, patch: Partial<Step>) =>
133
160
  setSteps(prev => prev.map(s => (s.key === k ? { ...s, ...patch } : s)))
134
161
  setSteps([
135
- { key: 'daemon', label: 'Start router daemon', status: 'pending' },
162
+ { key: 'daemon', label: 'Start router daemon', status: 'pending', hint: 'a few seconds' },
136
163
  { key: 'market', label: 'Register marketplace (claude, codex)', status: 'pending' },
137
164
  { key: 'auth', label: 'Check runtime sign-in', status: 'pending' },
138
165
  { key: 'fda', label: 'Check macOS Full Disk Access', status: 'pending' },
166
+ {
167
+ key: 'notifier',
168
+ label: 'Install scheduler (timer · watcher)',
169
+ status: 'pending',
170
+ hint: 'first run can take up to a minute',
171
+ },
139
172
  ])
140
173
 
141
174
  // Backend steps use synchronous spawnSync (launchctl, runtime plugin list) —
@@ -164,10 +197,19 @@ export function OnboardApp({
164
197
  const r = onboardHost({ env })
165
198
  const installed: OnboardRuntime[] = []
166
199
  for (const m of r.marketplaces) if (m.state !== 'runtime-missing') installed.push(m.runtime)
200
+ // host-runtime for the memory init contract: claude if present (covers
201
+ // both-installed → claude default), else codex, else undefined (no runtime).
202
+ setHostRuntime(installed.includes('claude') ? 'claude' : installed[0])
167
203
  const mFail = r.marketplaces.some(m => m.state === 'failed')
204
+ const allMissing = r.marketplaces.length > 0 && r.marketplaces.every(m => m.state === 'runtime-missing')
168
205
  set('market', {
169
- status: mFail ? 'fail' : 'ok',
170
- detail: r.marketplaces.map(m => `${m.runtime}:${m.state}`).join(' '),
206
+ // runtime-missing is NOT success — show '!' (warn), never a contradictory ✓.
207
+ status: mFail ? 'fail' : allMissing ? 'warn' : 'ok',
208
+ detail: allMissing
209
+ ? 'no agent runtime installed — skipped'
210
+ : r.marketplaces
211
+ .map(m => `${m.runtime}: ${m.state === 'runtime-missing' ? 'not installed' : m.state}`)
212
+ .join(', '),
171
213
  })
172
214
  if (cancelled) return
173
215
  await tick()
@@ -177,8 +219,11 @@ export function OnboardApp({
177
219
  if (installed.length === 0) {
178
220
  set('auth', { status: 'warn', detail: 'no runtime installed' })
179
221
  adv.push(
180
- '⚠ SETUP INCOMPLETE — no agent runtime found. iapeer needs Claude Code or Codex CLI\n' +
181
- ' to run peers. Install one, sign in, then re-run `iapeer onboard`.',
222
+ '⚠ SETUP INCOMPLETE — no agent runtime found. iapeer needs an agent runtime: Claude Code OR Codex (either works).\n' +
223
+ ' Install EITHER (commands verified against the official docs), sign in, then re-run `iapeer onboard`:\n' +
224
+ ' Claude Code: curl -fsSL https://claude.ai/install.sh | bash (or: brew install --cask claude-code)\n' +
225
+ ' Codex: npm install -g @openai/codex (or: brew install --cask codex)\n' +
226
+ ' If a download is blocked in your region, try the brew form or the other runtime.',
182
227
  )
183
228
  } else {
184
229
  const notes = installed.map(rt => runtimeAuthNote(rt, env)).filter((n): n is string => n != null)
@@ -188,6 +233,7 @@ export function OnboardApp({
188
233
  })
189
234
  adv.push(...notes)
190
235
  }
236
+ setAdvisories([...adv]) // surface SETUP-INCOMPLETE / auth advisories IN the wizard now
191
237
  if (cancelled) return
192
238
 
193
239
  set('fda', { status: 'running' })
@@ -197,9 +243,32 @@ export function OnboardApp({
197
243
  set('fda', { status: tcc ? 'warn' : 'ok', detail: fda === true ? 'granted' : tcc ? 'not granted' : 'n/a' })
198
244
  if (tcc) adv.push(tcc)
199
245
  if (cancelled) return
246
+ await tick()
247
+
248
+ // notifier — the scheduler (timer · watcher) that memory's index upkeep
249
+ // rides on; a silent default. Its npx install captures stdio (no terminal
250
+ // contention), so it runs in-wizard like the other steps. Idempotent: a
251
+ // present package/peers → no-op. A failure must NOT fail onboard (degrade).
252
+ set('notifier', { status: 'running' })
253
+ await tick()
254
+ try {
255
+ // canonical backbone step (same as the linear onboard path): zero-question,
256
+ // captured-stdio, idempotent, soft-skip on unavailable.
257
+ const { onboardNotifierStep } = await import('../../onboard/steps.ts')
258
+ const ns = await onboardNotifierStep({ env, warn: () => {} })
259
+ set('notifier', {
260
+ status: ns.state === 'deployed' ? 'ok' : 'warn',
261
+ detail: ns.state === 'deployed' ? `${ns.peers.length} peer(s)` : (ns.detail ?? ns.state),
262
+ })
263
+ if (ns.state !== 'deployed') adv.push(`Scheduler (notifier): ${ns.detail ?? ns.state}`)
264
+ } catch (e) {
265
+ set('notifier', { status: 'warn', detail: (e instanceof Error ? e.message : 'failed').slice(0, 60) })
266
+ }
267
+ if (cancelled) return
268
+ await tick()
200
269
 
201
- setAdvisories(adv)
202
- setPhase('memory')
270
+ setAdvisories([...adv])
271
+ setPhase('telegram')
203
272
  })()
204
273
 
205
274
  return () => {
@@ -236,23 +305,51 @@ export function OnboardApp({
236
305
  return <Text color="red">onboard aborted — risk not accepted.</Text>
237
306
  }
238
307
 
239
- // running | memory | finishing
308
+ // running | telegram | memory | finishing
240
309
  return (
241
310
  <Box flexDirection="column">
242
- <Text bold>Setting up the host…</Text>
243
- <Box flexDirection="column" marginTop={1}>
311
+ <Box marginBottom={1}>
312
+ <Text bold color="cyan">
313
+ iapeer
314
+ </Text>
315
+ <Text bold> · setting up the host</Text>
316
+ </Box>
317
+ <Box flexDirection="column">
244
318
  {steps.map(s => (
245
319
  <Box key={s.key}>
246
320
  <Text color={color(s.status)}>{glyph(s.status, spin)} </Text>
247
321
  <Text>{s.label}</Text>
248
- {s.detail ? <Text dimColor> — {s.detail}</Text> : null}
322
+ {s.detail ? (
323
+ <Text dimColor> — {s.detail}</Text>
324
+ ) : s.status === 'running' && s.hint ? (
325
+ <Text dimColor> — {s.hint}…</Text>
326
+ ) : null}
249
327
  </Box>
250
328
  ))}
251
329
  </Box>
330
+ {advisories.length > 0 ? (
331
+ <Box marginTop={1} flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
332
+ {advisories.map((a, i) => (
333
+ <Text key={i} color="yellow">
334
+ {a}
335
+ </Text>
336
+ ))}
337
+ </Box>
338
+ ) : null}
339
+ {phase === 'telegram' ? (
340
+ <Box marginTop={1} flexDirection="column">
341
+ <Text bold color="cyan">
342
+ ? Set up Telegram — talk to your agents from your phone?
343
+ </Text>
344
+ <Text dimColor> It will ask your name + Telegram user_id next. (bot token comes later) [Y/n]</Text>
345
+ </Box>
346
+ ) : null}
252
347
  {phase === 'memory' ? (
253
348
  <Box marginTop={1} flexDirection="column">
254
- <Text>Install the shared-memory provider (@agfpd/iapeer-memory)?</Text>
255
- <Text dimColor>It gives your agents a shared knowledge vault. [Y/n] </Text>
349
+ <Text bold color="cyan">
350
+ ? Install the shared-memory provider (@agfpd/iapeer-memory)?
351
+ </Text>
352
+ <Text dimColor> Gives your agents a shared knowledge vault. [Y/n]</Text>
256
353
  </Box>
257
354
  ) : null}
258
355
  </Box>