@agfpd/iapeer 0.2.99 → 0.3.0

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/README.md CHANGED
@@ -22,19 +22,33 @@ recipient peer — asleep? the daemon wakes it and delivers on wake
22
22
 
23
23
  ## Quick start
24
24
 
25
- Requirements: **macOS**, [bun](https://bun.sh) on `PATH`, `~/.local/bin` on `PATH`, and at least one agent runtime installed — Claude Code or Codex CLI (each AI peer is a live session of one).
25
+ Requirements: **macOS** (Linux is on the [Roadmap](#roadmap)) and at least one agent runtime — Claude Code or Codex CLI (each AI peer is a live session of one). The installer handles the rest ([bun](https://bun.sh) and `~/.local/bin` on `PATH`).
26
+
27
+ **Install — one line.** Installs bun if missing, builds the `iapeer` binary, then hands off to the interactive `onboard`:
28
+
29
+ ```sh
30
+ curl -fsSL https://agfpd.github.io/iapeer/install.sh | sh
31
+ ```
32
+
33
+ **Prefer not to pipe a script into your shell?** Run the same steps by hand (needs [bun](https://bun.sh) on `PATH`):
26
34
 
27
35
  ```sh
28
- # 1. install — the iapeer binary and the ~/.iapeer layout
29
- npx @agfpd/iapeer
36
+ # install — the iapeer binary and the ~/.iapeer layout
37
+ npx @agfpd/iapeer # or: bunx @agfpd/iapeer
30
38
 
31
- # 2. set up the host — starts the router daemon, then adds scheduler peers, Telegram, and shared memory
39
+ # set up the host — router daemon, scheduler peers, Telegram, shared memory
32
40
  iapeer onboard
41
+ ```
42
+
43
+ Either path runs `onboard` interactively: it surfaces the security checks (runtime sign-in, macOS Full Disk Access) and asks before installing the memory provider — nothing is auto-approved behind your back. You can read [`install.sh`](./install.sh) before running it, or preview every step with `IAPEER_INSTALL_DRYRUN=1`.
33
44
 
34
- # 3. create your first agent
45
+ **Then create your first agent and talk to it:**
46
+
47
+ ```sh
48
+ # create your first agent
35
49
  iapeer create assistant --runtime claude
36
50
 
37
- # 4. talk to it live in your terminal — detach with Ctrl-] (or just close it; the agent keeps running)
51
+ # talk to it live in your terminal — detach with Ctrl-] (or just close it; the agent keeps running)
38
52
  iapeer attach assistant
39
53
  ```
40
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.99",
3
+ "version": "0.3.0",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -56,13 +56,17 @@
56
56
  "@modelcontextprotocol/sdk": "1.29.0",
57
57
  "@xterm/addon-serialize": "0.14.0",
58
58
  "@xterm/headless": "6.0.0",
59
+ "ink": "^7.1.0",
59
60
  "proper-lockfile": "^4.1.2",
61
+ "react": "^19.2.7",
62
+ "react-devtools-core": "^7.0.1",
60
63
  "transliteration": "^2.6.1"
61
64
  },
62
65
  "devDependencies": {
63
66
  "@types/bun": "latest",
64
67
  "@types/node": "^22.0.0",
65
68
  "@types/proper-lockfile": "^4.1.4",
69
+ "@types/react": "^19.2.17",
66
70
  "typescript": "^5.6.0"
67
71
  }
68
72
  }
package/src/cli/index.ts CHANGED
@@ -1002,6 +1002,26 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1002
1002
  try {
1003
1003
  switch (verb) {
1004
1004
  case 'onboard': {
1005
+ // Interactive TUI wizard (Фаза TUI-редизайн) — the DEFAULT for an
1006
+ // interactive `iapeer onboard`. Routed ONLY in a real interactive terminal
1007
+ // and never for an automation path (--accept-risk / --dry-run / --no-memory
1008
+ // / --infra), which stay on the deterministic linear flow below. Escape
1009
+ // hatch: IAPEER_ONBOARD_WIZARD=0 forces the linear path (recovery / scripts
1010
+ // that want the old flow). The wizard owns its own gate screen, so we route
1011
+ // BEFORE the readline gate. It fails closed: no real TTY →
1012
+ // WIZARD_NOT_INTERACTIVE → fall through to linear.
1013
+ const wizardOptOut = /^(0|false|no)$/i.test((env.IAPEER_ONBOARD_WIZARD ?? '').trim())
1014
+ const automationFlag =
1015
+ flags['accept-risk'] === true ||
1016
+ flags['dry-run'] === true ||
1017
+ flags['no-memory'] === true ||
1018
+ typeof flags.infra === 'string'
1019
+ if (!wizardOptOut && !automationFlag && process.stdin.isTTY === true && process.stdout.isTTY === true) {
1020
+ const { runOnboardWizard, WIZARD_NOT_INTERACTIVE } = await import('../tui/onboard/run.tsx')
1021
+ const wc = await runOnboardWizard({ env })
1022
+ if (wc !== WIZARD_NOT_INTERACTIVE) return wc
1023
+ // not a real interactive TTY after all → linear path below
1024
+ }
1005
1025
  // SECURITY GATE (pre-release): the operator must consciously accept the risk of
1006
1026
  // beta infra with live agents BEFORE onboard mutates the host. --accept-risk /
1007
1027
  // IAPEER_ACCEPT_RISK accepts non-interactively (one-liner installers / CI); a
@@ -0,0 +1,260 @@
1
+ // OnboardApp — the interactive onboard wizard (Ink). Owns the interactive +
2
+ // live-progress part and EXITS; the inherited-stdio memory install + the final
3
+ // summary run post-Ink (see run.tsx). Drives the SAME backend functions as the
4
+ // linear non-interactive path (no logic duplication), honoring the phase
5
+ // principles: explicit prompts (what to press), visible progress on otherwise
6
+ // silent steps (daemon start), and a clear next-step instead of a cryptic code.
7
+
8
+ import React, { useEffect, useState } from 'react'
9
+ import { Box, Text, useApp, useInput, useStdin } from 'ink'
10
+ import { ONBOARD_SECURITY_WARNING } from '../../onboard/risk.ts'
11
+ import { onboardHost, runtimeAuthNote, tccFullDiskAccessNote, type OnboardRuntime } from '../../onboard/index.ts'
12
+ import { probeFullDiskAccess } from '../../status/index.ts'
13
+ import { ensureDaemonStarted } from '../../daemon/main.ts'
14
+ import { iapeerBinPath } from '../../install/index.ts'
15
+
16
+ /** What the wizard hands back to run.tsx for the post-Ink phase (memory install +
17
+ * summary). */
18
+ export interface WizardResult {
19
+ code: number
20
+ memoryConsent: boolean
21
+ advisories: string[]
22
+ summary: string[]
23
+ }
24
+
25
+ type Phase = 'gate' | 'running' | 'memory' | 'declined' | 'finishing'
26
+ type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
27
+ interface Step {
28
+ key: string
29
+ label: string
30
+ status: StepStatus
31
+ detail?: string
32
+ }
33
+
34
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
35
+
36
+ function useSpinnerFrame(active: boolean): string {
37
+ const [i, setI] = useState(0)
38
+ useEffect(() => {
39
+ if (!active) return
40
+ const t = setInterval(() => setI(n => (n + 1) % SPINNER.length), 80)
41
+ return () => clearInterval(t)
42
+ }, [active])
43
+ return SPINNER[i]
44
+ }
45
+
46
+ function glyph(status: StepStatus, spin: string): string {
47
+ switch (status) {
48
+ case 'running':
49
+ return spin
50
+ case 'ok':
51
+ return '✓'
52
+ case 'warn':
53
+ return '!'
54
+ case 'fail':
55
+ return '✗'
56
+ default:
57
+ return '·'
58
+ }
59
+ }
60
+
61
+ function color(status: StepStatus): string {
62
+ switch (status) {
63
+ case 'ok':
64
+ return 'green'
65
+ case 'warn':
66
+ return 'yellow'
67
+ case 'fail':
68
+ return 'red'
69
+ case 'running':
70
+ return 'cyan'
71
+ default:
72
+ return 'gray'
73
+ }
74
+ }
75
+
76
+ export function OnboardApp({
77
+ env,
78
+ onResult,
79
+ }: {
80
+ env: NodeJS.ProcessEnv
81
+ onResult: (r: WizardResult) => void
82
+ }): React.ReactElement {
83
+ const { exit } = useApp()
84
+ const { isRawModeSupported } = useStdin()
85
+ const [phase, setPhase] = useState<Phase>('gate')
86
+ const [steps, setSteps] = useState<Step[]>([])
87
+ const [advisories, setAdvisories] = useState<string[]>([])
88
+ const spin = useSpinnerFrame(phase === 'running')
89
+
90
+ const finish = (memoryConsent: boolean): void => {
91
+ const failed = steps.some(s => s.status === 'fail')
92
+ const summary = steps.map(s => {
93
+ const mark = s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'fail' ? '✗' : '·'
94
+ return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
95
+ })
96
+ summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
97
+ onResult({ code: failed ? 1 : 0, memoryConsent, advisories, summary })
98
+ setPhase('finishing')
99
+ setTimeout(() => exit(), 30)
100
+ }
101
+
102
+ // Gate input — explicit y/N.
103
+ useInput(
104
+ (input, key) => {
105
+ if (input === 'y' || input === 'Y') setPhase('running')
106
+ else if (input === 'n' || input === 'N' || key.escape) setPhase('declined')
107
+ },
108
+ { isActive: phase === 'gate' },
109
+ )
110
+
111
+ // Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install).
112
+ useInput(
113
+ (input, key) => {
114
+ if (input === 'n' || input === 'N') finish(false)
115
+ else if (input === 'y' || input === 'Y' || key.return) finish(true)
116
+ },
117
+ { isActive: phase === 'memory' },
118
+ )
119
+
120
+ // Declined → exit 1 (no host mutation).
121
+ useEffect(() => {
122
+ if (phase !== 'declined') return
123
+ onResult({ code: 1, memoryConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
124
+ const t = setTimeout(() => exit(), 30)
125
+ return () => clearTimeout(t)
126
+ }, [phase, onResult, exit])
127
+
128
+ // Running phase — drive the backend steps sequentially with live status.
129
+ useEffect(() => {
130
+ if (phase !== 'running') return
131
+ let cancelled = false
132
+ const set = (k: string, patch: Partial<Step>) =>
133
+ setSteps(prev => prev.map(s => (s.key === k ? { ...s, ...patch } : s)))
134
+ setSteps([
135
+ { key: 'daemon', label: 'Start router daemon', status: 'pending' },
136
+ { key: 'market', label: 'Register marketplace (claude, codex)', status: 'pending' },
137
+ { key: 'auth', label: 'Check runtime sign-in', status: 'pending' },
138
+ { key: 'fda', label: 'Check macOS Full Disk Access', status: 'pending' },
139
+ ])
140
+
141
+ // Backend steps use synchronous spawnSync (launchctl, runtime plugin list) —
142
+ // those block the event loop, so we yield (tick) after each status change so
143
+ // Ink flushes a frame and steps paint as they progress, not all at once.
144
+ // (Intra-step spinner can't animate while a single spawnSync blocks — a known
145
+ // limitation until those backend calls go async; the step still shows.)
146
+ const tick = () => new Promise<void>(r => setTimeout(r, 0))
147
+
148
+ void (async () => {
149
+ const adv: string[] = []
150
+ set('daemon', { status: 'running' })
151
+ await tick()
152
+ try {
153
+ const d = await ensureDaemonStarted({ env })
154
+ const ok = d.state !== 'failed' && d.healthy !== false
155
+ set('daemon', { status: ok ? 'ok' : 'fail', detail: d.detail ?? d.state })
156
+ } catch (e) {
157
+ set('daemon', { status: 'fail', detail: e instanceof Error ? e.message : String(e) })
158
+ }
159
+ if (cancelled) return
160
+ await tick()
161
+
162
+ set('market', { status: 'running' })
163
+ await tick()
164
+ const r = onboardHost({ env })
165
+ const installed: OnboardRuntime[] = []
166
+ for (const m of r.marketplaces) if (m.state !== 'runtime-missing') installed.push(m.runtime)
167
+ const mFail = r.marketplaces.some(m => m.state === 'failed')
168
+ set('market', {
169
+ status: mFail ? 'fail' : 'ok',
170
+ detail: r.marketplaces.map(m => `${m.runtime}:${m.state}`).join(' '),
171
+ })
172
+ if (cancelled) return
173
+ await tick()
174
+
175
+ set('auth', { status: 'running' })
176
+ await tick()
177
+ if (installed.length === 0) {
178
+ set('auth', { status: 'warn', detail: 'no runtime installed' })
179
+ 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`.',
182
+ )
183
+ } else {
184
+ const notes = installed.map(rt => runtimeAuthNote(rt, env)).filter((n): n is string => n != null)
185
+ set('auth', {
186
+ status: notes.length ? 'warn' : 'ok',
187
+ detail: notes.length ? `${notes.length} runtime(s) not signed in` : installed.join(', '),
188
+ })
189
+ adv.push(...notes)
190
+ }
191
+ if (cancelled) return
192
+
193
+ set('fda', { status: 'running' })
194
+ await tick()
195
+ const fda = probeFullDiskAccess(env)
196
+ const tcc = tccFullDiskAccessNote({ fda, binPath: iapeerBinPath(env) })
197
+ set('fda', { status: tcc ? 'warn' : 'ok', detail: fda === true ? 'granted' : tcc ? 'not granted' : 'n/a' })
198
+ if (tcc) adv.push(tcc)
199
+ if (cancelled) return
200
+
201
+ setAdvisories(adv)
202
+ setPhase('memory')
203
+ })()
204
+
205
+ return () => {
206
+ cancelled = true
207
+ }
208
+ }, [phase, env])
209
+
210
+ if (isRawModeSupported === false) {
211
+ return (
212
+ <Text color="yellow">
213
+ iapeer onboard needs an interactive terminal. Open a normal terminal and run: iapeer onboard
214
+ </Text>
215
+ )
216
+ }
217
+
218
+ if (phase === 'gate') {
219
+ return (
220
+ <Box flexDirection="column">
221
+ <Box borderStyle="round" borderColor="yellow" paddingX={1} flexDirection="column">
222
+ <Text color="yellow" bold>
223
+ Security warning — please read.
224
+ </Text>
225
+ <Text>{ONBOARD_SECURITY_WARNING.replace(/^Security warning — please read\.\n\n/, '')}</Text>
226
+ </Box>
227
+ <Box marginTop={1}>
228
+ <Text bold>Continue? </Text>
229
+ <Text dimColor>[y/N] </Text>
230
+ </Box>
231
+ </Box>
232
+ )
233
+ }
234
+
235
+ if (phase === 'declined') {
236
+ return <Text color="red">onboard aborted — risk not accepted.</Text>
237
+ }
238
+
239
+ // running | memory | finishing
240
+ return (
241
+ <Box flexDirection="column">
242
+ <Text bold>Setting up the host…</Text>
243
+ <Box flexDirection="column" marginTop={1}>
244
+ {steps.map(s => (
245
+ <Box key={s.key}>
246
+ <Text color={color(s.status)}>{glyph(s.status, spin)} </Text>
247
+ <Text>{s.label}</Text>
248
+ {s.detail ? <Text dimColor> — {s.detail}</Text> : null}
249
+ </Box>
250
+ ))}
251
+ </Box>
252
+ {phase === 'memory' ? (
253
+ <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>
256
+ </Box>
257
+ ) : null}
258
+ </Box>
259
+ )
260
+ }
@@ -0,0 +1,72 @@
1
+ // runOnboardWizard — the Ink entry point for the interactive onboard wizard.
2
+ //
3
+ // Boundary (контракт «интерактив только в реальном TTY», фаза TUI-редизайн):
4
+ // the wizard runs ONLY in a real interactive terminal. The CLI routes here only
5
+ // when stdin AND stdout are TTYs and no non-interactive flag is set; install.sh
6
+ // never launches it (it prints a next-step to open a normal terminal). As a
7
+ // belt-and-suspenders guard against the readline-class wedge (Bun raw-mode on a
8
+ // redirect-opened /dev/tty reports isTTY=true but never receives input), we also
9
+ // fail CLOSED here: no real TTY → return WIZARD_NOT_INTERACTIVE so the caller
10
+ // falls back to the linear non-interactive path instead of rendering raw and
11
+ // hanging.
12
+ //
13
+ // Structure: Ink owns the interactive + live-progress part (gate, daemon/
14
+ // marketplace/auth/FDA steps, memory consent) and EXITS. The memory provider
15
+ // install — an INHERITED-STDIO subprocess (it owns its own tty questions) — runs
16
+ // AFTER Ink has unmounted, so the two never share the terminal at once. The final
17
+ // summary is plain text post-Ink (it folds in the memory outcome).
18
+
19
+ import React from 'react'
20
+ import { render } from 'ink'
21
+ import { OnboardApp, type WizardResult } from './app.tsx'
22
+
23
+ /** Sentinel: the environment is not a real interactive TTY — caller should fall
24
+ * back to the linear non-interactive onboard path (never render raw + hang). */
25
+ export const WIZARD_NOT_INTERACTIVE = -1
26
+
27
+ export interface OnboardWizardOptions {
28
+ env?: NodeJS.ProcessEnv
29
+ }
30
+
31
+ /** Render the interactive onboard wizard. Resolves with the process exit code, or
32
+ * WIZARD_NOT_INTERACTIVE when there is no real TTY to drive it. */
33
+ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise<number> {
34
+ const env = opts.env ?? process.env
35
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
36
+ return WIZARD_NOT_INTERACTIVE
37
+ }
38
+
39
+ let result: WizardResult = { code: 0, memoryConsent: false, advisories: [], summary: [] }
40
+ try {
41
+ const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
42
+ await app.waitUntilExit()
43
+ } catch {
44
+ return WIZARD_NOT_INTERACTIVE
45
+ }
46
+
47
+ // Post-Ink: the memory provider install owns the terminal (inherited stdio).
48
+ // Ink is fully unmounted now, so there is no contention.
49
+ if (result.memoryConsent) {
50
+ const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
51
+ process.stdout.write('\nInstalling shared-memory provider (@agfpd/iapeer-memory)…\n')
52
+ try {
53
+ const mem = await onboardMemoryProvider({ env })
54
+ const label = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
55
+ process.stdout.write(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${label})\n`)
56
+ } catch (e) {
57
+ process.stdout.write(`memory: step failed — ${e instanceof Error ? e.message : String(e)}\n`)
58
+ }
59
+ } else {
60
+ process.stdout.write('\nmemory: skipped — set it up later with `iapeer onboard` or `npx @agfpd/iapeer-memory init`\n')
61
+ }
62
+
63
+ // Final summary (plain text, post-Ink).
64
+ process.stdout.write('\n')
65
+ for (const line of result.summary) process.stdout.write(line + '\n')
66
+ if (result.advisories.length > 0) {
67
+ process.stdout.write('\n')
68
+ for (const a of result.advisories) process.stdout.write(a + '\n')
69
+ }
70
+ process.stdout.write('\nNext: create your first agent — `iapeer create <name> --runtime claude`\n')
71
+ return result.code
72
+ }
package/tsconfig.json CHANGED
@@ -4,6 +4,8 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
6
  "allowImportingTsExtensions": true,
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "react",
7
9
  "verbatimModuleSyntax": true,
8
10
  "noEmit": true,
9
11
  "strict": true,
@@ -14,5 +16,5 @@
14
16
  "types": ["bun", "node"],
15
17
  "lib": ["ESNext"]
16
18
  },
17
- "include": ["src/**/*.ts"]
19
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
18
20
  }