@agfpd/iapeer 0.4.31 → 0.4.32

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.4.31",
3
+ "version": "0.4.32",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -100,6 +100,9 @@ export interface PeerListing {
100
100
  default_runtime: Runtime
101
101
  /** Runtime with the freshest activity (what `attach` resumes); undefined if none. */
102
102
  last_active_runtime?: Runtime
103
+ /** Epoch-ms of that freshest activity (the dashboard's ACTIVE age column);
104
+ * undefined when no runtime has an activity proxy. */
105
+ last_active_ms?: number
103
106
  intelligence: Intelligence
104
107
  description: string
105
108
  /** The peer's working directory (registry fact). Machine-readable so host-local
@@ -149,6 +152,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
149
152
  personality: peer.personality,
150
153
  default_runtime: peer.runtime,
151
154
  last_active_runtime: lastActive,
155
+ last_active_ms: bestMt >= 0 ? bestMt : undefined,
152
156
  intelligence: peer.intelligence,
153
157
  description: peer.description,
154
158
  cwd: peer.cwd,
@@ -1754,11 +1758,15 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1754
1758
  return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
1755
1759
  }
1756
1760
  case 'list': {
1757
- // tty + no --json → the interactive control-panel (↑/↓ · Enter=attach · / · q);
1758
- // non-tty / --json → the scriptable table (machine-parsable).
1761
+ // tty + no --json → the live Ink dashboard (Фаза 3: host header · live peer
1762
+ // table · per-peer log panel · Enter=attach via suspend-and-spawn);
1763
+ // non-tty / --json → the scriptable table (machine-parsable). The dashboard
1764
+ // fails CLOSED to the table when no real TTY drives it (sentinel), the same
1765
+ // belt as the onboard wizard.
1759
1766
  if (flags.json !== true && process.stdout.isTTY && process.stdin.isTTY) {
1760
- const { runListTui } = await import('./listTui.ts')
1761
- return await runListTui(env)
1767
+ const { runDashboard, DASHBOARD_NOT_INTERACTIVE } = await import('../tui/dashboard/run.tsx')
1768
+ const code = await runDashboard({ env })
1769
+ if (code !== DASHBOARD_NOT_INTERACTIVE) return code
1762
1770
  }
1763
1771
  const rows = listPeers({ env })
1764
1772
  out(flags.json ? JSON.stringify(rows, null, 2) + '\n' : formatListTable(rows))
@@ -0,0 +1,256 @@
1
+ // DashboardApp — the live management dashboard (Ink), the tty form of `iapeer list`
2
+ // (Фаза 3 «TUI-редизайн management», эталон вкуса — Claude Code, владелец 02.07).
3
+ //
4
+ // Presents: a host header (daemon / memory / voice health), a live peer table
5
+ // (per-runtime liveness ● ○ ✕, nature, last-activity age), an optional per-peer
6
+ // event-log panel (delivery + lifecycle), and a hint footer. State is polled
7
+ // in-process (решение B1) — peers every POLL_MS, host header every HOST_POLL_MS.
8
+ //
9
+ // The app itself never touches the terminal beyond Ink: attach is an ACTION handed
10
+ // to run.tsx (suspend-and-spawn — Ink unmounts, `iapeer attach` child inherits the
11
+ // real TTY, dashboard remounts on its exit). Quit likewise. That keeps ownership of
12
+ // raw-mode/alt-screen in exactly one place (run.tsx).
13
+
14
+ import React, { useEffect, useRef, useState } from 'react'
15
+ import { Box, Text, useApp, useInput, useStdout } from 'ink'
16
+ import type { PeerListing, RuntimeLiveness } from '../../cli/index.ts'
17
+ import { clampCursor, ellipsize, filterRows, formatAge, scrollWindow } from './model.ts'
18
+ import { takeHostHeader, takePeerLog, takePeersSnapshot, type HostHeader } from './data.ts'
19
+
20
+ export type DashAction = { type: 'attach'; personality: string } | { type: 'quit' }
21
+
22
+ const POLL_MS = 2000
23
+ const HOST_POLL_MS = 10_000
24
+ const LOG_LIMIT = 12
25
+
26
+ const STATUS_GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
27
+ const STATUS_COLOR: Record<RuntimeLiveness, string | undefined> = { live: 'green', asleep: undefined, stopped: 'red' }
28
+
29
+ function RuntimeCells({ row }: { row: PeerListing }): React.ReactElement {
30
+ return (
31
+ <>
32
+ {row.runtimes.map((s, i) => (
33
+ <React.Fragment key={s.runtime}>
34
+ {i > 0 ? <Text> </Text> : null}
35
+ <Text color={STATUS_COLOR[s.status]} dimColor={s.status === 'asleep'}>
36
+ {STATUS_GLYPH[s.status]} {s.runtime}
37
+ </Text>
38
+ </React.Fragment>
39
+ ))}
40
+ </>
41
+ )
42
+ }
43
+
44
+ export function DashboardApp({
45
+ env,
46
+ onAction,
47
+ }: {
48
+ env: NodeJS.ProcessEnv
49
+ onAction: (a: DashAction) => void
50
+ }): React.ReactElement {
51
+ const { exit } = useApp()
52
+ const { stdout } = useStdout()
53
+ const [rows, setRows] = useState<PeerListing[]>(() => takePeersSnapshot(env))
54
+ const [host, setHost] = useState<HostHeader | null>(null)
55
+ const [now, setNow] = useState<number>(() => Date.now())
56
+ const [cursor, setCursor] = useState(0)
57
+ const [filter, setFilter] = useState('')
58
+ const [filterMode, setFilterMode] = useState(false)
59
+ const [logsOpen, setLogsOpen] = useState(false)
60
+ const [logLines, setLogLines] = useState<Array<{ text: string; tone: 'ok' | 'fail' | 'info' }>>([])
61
+ // NB: a pty can report 0×0 (ws unset) — `??` alone would keep the zero, so clamp
62
+ // through a positive-or-default helper everywhere a dimension is read.
63
+ const dim = (v: number | undefined, d: number): number => (v && v > 0 ? v : d)
64
+ const [size, setSize] = useState({ cols: dim(stdout?.columns, 80), rows: dim(stdout?.rows, 24) })
65
+ const doneRef = useRef(false)
66
+
67
+ const finish = (a: DashAction): void => {
68
+ if (doneRef.current) return
69
+ doneRef.current = true
70
+ onAction(a)
71
+ exit()
72
+ }
73
+
74
+ // peers + clock poll (fast), host header poll (slow), resize.
75
+ useEffect(() => {
76
+ const t = setInterval(() => {
77
+ setRows(takePeersSnapshot(env))
78
+ setNow(Date.now())
79
+ }, POLL_MS)
80
+ return () => clearInterval(t)
81
+ }, [env])
82
+ useEffect(() => {
83
+ let gone = false
84
+ const probe = (): void => {
85
+ void takeHostHeader(env).then(h => {
86
+ if (!gone) setHost(h)
87
+ })
88
+ }
89
+ probe()
90
+ const t = setInterval(probe, HOST_POLL_MS)
91
+ return () => {
92
+ gone = true
93
+ clearInterval(t)
94
+ }
95
+ }, [env])
96
+ useEffect(() => {
97
+ if (!stdout) return
98
+ const onResize = (): void => setSize({ cols: dim(stdout.columns, 80), rows: dim(stdout.rows, 24) })
99
+ stdout.on('resize', onResize)
100
+ return () => {
101
+ stdout.off('resize', onResize)
102
+ }
103
+ }, [stdout])
104
+
105
+ const visible = filterRows(rows, filter)
106
+ const cur = clampCursor(cursor, visible.length)
107
+ const selected = visible[cur]
108
+
109
+ // per-peer log panel poll — only while open, keyed to the selected peer.
110
+ useEffect(() => {
111
+ if (!logsOpen || !selected) return
112
+ const load = (): void => setLogLines(takePeerLog(env, selected.personality, LOG_LIMIT))
113
+ load()
114
+ const t = setInterval(load, POLL_MS)
115
+ return () => clearInterval(t)
116
+ }, [logsOpen, selected?.personality, env])
117
+
118
+ useInput((input, key) => {
119
+ if (filterMode) {
120
+ if (key.return || key.escape) setFilterMode(false)
121
+ else if (key.backspace || key.delete) setFilter(f => f.slice(0, -1))
122
+ else if (input && !key.ctrl && !key.meta) setFilter(f => f + input)
123
+ setCursor(0)
124
+ return
125
+ }
126
+ if (input === 'q' || (key.ctrl && input === 'c')) return finish({ type: 'quit' })
127
+ if (key.escape) {
128
+ if (logsOpen) setLogsOpen(false)
129
+ else if (filter) setFilter('')
130
+ return
131
+ }
132
+ if (input === '/') return setFilterMode(true)
133
+ if (input === 'l') return setLogsOpen(o => !o)
134
+ if (input === 'r') {
135
+ setRows(takePeersSnapshot(env))
136
+ setNow(Date.now())
137
+ return
138
+ }
139
+ if (key.upArrow || input === 'k') return setCursor(c => clampCursor(c - 1, visible.length))
140
+ if (key.downArrow || input === 'j') return setCursor(c => clampCursor(c + 1, visible.length))
141
+ if (key.return && selected) return finish({ type: 'attach', personality: selected.personality })
142
+ })
143
+
144
+ // ── layout ──────────────────────────────────────────────────────────────────
145
+ const liveCount = rows.filter(r => r.runtimes.some(s => s.status === 'live')).length
146
+ // fixed rows around the table: header(1) + header-margin(1) + column line(1) +
147
+ // "↓ N more"(1) + footer margin+line(2) + one spare against terminal rounding.
148
+ const overhead = 7 + (logsOpen ? LOG_LIMIT + 3 : 0) + (filterMode || filter ? 1 : 0)
149
+ const tableHeight = Math.max(3, size.rows - overhead)
150
+ const win = scrollWindow(cur, visible.length, tableHeight)
151
+ const nameW = Math.min(18, Math.max(6, ...visible.map(r => r.personality.length)))
152
+ const rtW = Math.max(10, ...visible.map(r => r.runtimes.map(s => `x ${s.runtime}`).join(' ').length))
153
+ const descW = Math.max(0, size.cols - (2 + nameW + 2 + rtW + 2 + 10 + 2 + 6 + 2) - 2)
154
+
155
+ return (
156
+ <Box flexDirection="column" paddingX={1}>
157
+ {/* host header */}
158
+ <Box justifyContent="space-between">
159
+ <Box>
160
+ <Text color="cyan" bold>
161
+ ⏺ iapeer
162
+ </Text>
163
+ <Text bold> peers</Text>
164
+ <Text dimColor>
165
+ {' '}
166
+ {rows.length} peers · {liveCount} live
167
+ </Text>
168
+ </Box>
169
+ <Box>
170
+ {host ? (
171
+ <>
172
+ <Text dimColor>v{host.version} · daemon </Text>
173
+ <Text color={host.daemonHealthy ? 'green' : 'red'}>{host.daemonHealthy ? '●' : '✕'}</Text>
174
+ <Text dimColor> · memory </Text>
175
+ <Text color={host.memory.present ? 'green' : undefined} dimColor={!host.memory.present}>
176
+ {host.memory.present ? '●' : '○'}
177
+ </Text>
178
+ <Text dimColor> · voice </Text>
179
+ <Text color={host.voice.present ? 'green' : undefined} dimColor={!host.voice.present}>
180
+ {host.voice.present ? '●' : '○'}
181
+ </Text>
182
+ </>
183
+ ) : (
184
+ <Text dimColor>probing host…</Text>
185
+ )}
186
+ </Box>
187
+ </Box>
188
+ <Box marginTop={1}>
189
+ <Text dimColor>
190
+ {' '}
191
+ {'NAME'.padEnd(nameW)} {'RUNTIMES'.padEnd(rtW)} {'NATURE'.padEnd(10)} {'ACTIVE'.padEnd(6)} DESCRIPTION
192
+ </Text>
193
+ </Box>
194
+
195
+ {/* peer table (scroll window) */}
196
+ <Box flexDirection="column">
197
+ {visible.length === 0 ? (
198
+ <Text dimColor> (no peers{filter ? ' match' : ' registered'})</Text>
199
+ ) : (
200
+ visible.slice(win.start, win.end).map((r, i) => {
201
+ const idx = win.start + i
202
+ const sel = idx === cur
203
+ return (
204
+ <Box key={r.personality}>
205
+ <Text color={sel ? 'cyan' : undefined} bold={sel}>
206
+ {sel ? '❯ ' : ' '}
207
+ {ellipsize(r.personality, nameW).padEnd(nameW)}{' '}
208
+ </Text>
209
+ <Box width={rtW + 1}>
210
+ <RuntimeCells row={r} />
211
+ </Box>
212
+ <Text dimColor> {r.intelligence.padEnd(10)}</Text>
213
+ <Text color={sel ? 'cyan' : undefined} dimColor={!sel}>
214
+ {' '}
215
+ {formatAge(r.last_active_ms, now).padEnd(6)}
216
+ </Text>
217
+ <Text dimColor> {ellipsize(r.description, descW)}</Text>
218
+ </Box>
219
+ )
220
+ })
221
+ )}
222
+ {visible.length > win.end ? <Text dimColor> ↓ {visible.length - win.end} more</Text> : null}
223
+ </Box>
224
+
225
+ {/* per-peer log panel */}
226
+ {logsOpen && selected ? (
227
+ <Box marginTop={1} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
228
+ <Text dimColor>logs · {selected.personality} (delivery + lifecycle)</Text>
229
+ {logLines.length === 0 ? (
230
+ <Text dimColor>(no recent events)</Text>
231
+ ) : (
232
+ logLines.map((l, i) => (
233
+ <Text key={i} color={l.tone === 'fail' ? 'red' : l.tone === 'ok' ? undefined : undefined} dimColor={l.tone !== 'fail'}>
234
+ {ellipsize(l.text, Math.max(10, size.cols - 6))}
235
+ </Text>
236
+ ))
237
+ )}
238
+ </Box>
239
+ ) : null}
240
+
241
+ {/* filter + footer */}
242
+ {filterMode ? (
243
+ <Text>
244
+ <Text color="cyan">/</Text>
245
+ {filter}
246
+ <Text color="cyan">▌</Text>
247
+ </Text>
248
+ ) : filter ? (
249
+ <Text dimColor>filter: {filter} (esc to clear)</Text>
250
+ ) : null}
251
+ <Box marginTop={1}>
252
+ <Text dimColor>↑↓ move · ⏎ attach · l logs · / filter · r refresh · q quit</Text>
253
+ </Box>
254
+ </Box>
255
+ )
256
+ }
@@ -0,0 +1,79 @@
1
+ // Dashboard DATA — the in-process state feed (решение B1: фундамент-примитивы, не
2
+ // RPC): peer rows via listPeers (registry + per-runtime liveness + last-active), the
3
+ // host header via hostStatus (daemon health probe + memory/voice slots), and the
4
+ // per-peer event-log tails (delivery.log + lifecycle.log under cfg.eventLogDir).
5
+ //
6
+ // All paths resolve through the injected env → the same IAPEER_ROOT isolation as
7
+ // every other side-effect (sandbox rule). Reads only — the dashboard mutates nothing.
8
+
9
+ import { closeSync, openSync, readSync, statSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { listPeers, type PeerListing } from '../../cli/index.ts'
12
+ import { loadLifecycleConfig } from '../../lifecycle/index.ts'
13
+ import { deliveryLogPath } from '../../daemon/deliverylog.ts'
14
+ import { lifecycleLogPath } from '../../lifecycle/eventlog.ts'
15
+ import { assemblePeerLog } from './model.ts'
16
+
17
+ /** One peers snapshot (the poll unit). listPeers is synchronous and cheap: registry
18
+ * read + pidfile kill-0 liveness + transcript/pane-log statSync per peer. */
19
+ export function takePeersSnapshot(env: NodeJS.ProcessEnv): PeerListing[] {
20
+ return listPeers({ env })
21
+ }
22
+
23
+ export interface HostHeader {
24
+ version: string
25
+ daemonHealthy: boolean
26
+ memory: { present: boolean; label: string }
27
+ voice: { present: boolean; label: string }
28
+ }
29
+
30
+ /** The host header line (slow poll — the daemon probe has its own short timeout).
31
+ * Dynamic import keeps the update/status modules out of the fast peers-poll path. */
32
+ export async function takeHostHeader(env: NodeJS.ProcessEnv): Promise<HostHeader> {
33
+ const { hostStatus } = await import('../../status/index.ts')
34
+ const s = await hostStatus({ env })
35
+ const slot = (p: { provider: string; version: string } | null, hbAge: number | null): { present: boolean; label: string } =>
36
+ p ? { present: true, label: `${p.version}${hbAge !== null ? ` · hb ${hbAge}s` : ''}` } : { present: false, label: 'none' }
37
+ return {
38
+ version: s.version,
39
+ daemonHealthy: s.daemon.healthy,
40
+ memory: slot(s.memory.provider, s.memory.heartbeatAgeSecs),
41
+ voice: slot(s.voice.provider, s.voice.heartbeatAgeSecs),
42
+ }
43
+ }
44
+
45
+ /** Read the last `bytes` of a file without loading the whole log (delivery.log can
46
+ * be MBs before rotation). Missing/unreadable file → ''. */
47
+ export function readTail(path: string, bytes = 64 * 1024): string {
48
+ try {
49
+ const size = statSync(path).size
50
+ const fd = openSync(path, 'r')
51
+ try {
52
+ const start = Math.max(0, size - bytes)
53
+ const buf = Buffer.alloc(size - start)
54
+ readSync(fd, buf, 0, buf.length, start)
55
+ // drop the leading partial line when we started mid-file
56
+ const s = buf.toString('utf8')
57
+ return start > 0 ? s.slice(s.indexOf('\n') + 1) : s
58
+ } finally {
59
+ closeSync(fd)
60
+ }
61
+ } catch {
62
+ return ''
63
+ }
64
+ }
65
+
66
+ /** The per-peer log panel content: newest `limit` events concerning the peer from
67
+ * delivery.log + lifecycle.log (merged by timestamp). */
68
+ export function takePeerLog(env: NodeJS.ProcessEnv, personality: string, limit: number): Array<{ text: string; tone: 'ok' | 'fail' | 'info' }> {
69
+ const cfg = loadLifecycleConfig(env)
70
+ const tails = [readTail(deliveryLogPath(cfg.eventLogDir)), readTail(lifecycleLogPath(cfg.eventLogDir))]
71
+ return assemblePeerLog(tails, personality, limit)
72
+ }
73
+
74
+ /** The pane-log path of a peer address — reserved for a future raw-view; unused by
75
+ * the prototype (raw pty bytes are an attach concern, not a log panel one). */
76
+ export function paneLogPath(env: NodeJS.ProcessEnv, address: string): string {
77
+ const cfg = loadLifecycleConfig(env)
78
+ return join(cfg.logDir, `${address}.log`)
79
+ }
@@ -0,0 +1,127 @@
1
+ // Dashboard MODEL — the pure logic of the management dashboard (Фаза 3 «TUI-редизайн
2
+ // management»): row filtering, activity-age formatting, event-log line parsing and
3
+ // per-peer log assembly. NO Ink, NO I/O — unit-testable in isolation (the same
4
+ // pure-core discipline as listTui.ts / the onboard wizard state machine).
5
+ //
6
+ // The VIEW (app.tsx) renders this; the DATA layer (data.ts) feeds it; run.tsx owns
7
+ // the terminal. Keeping the model pure is what lets the dashboard stay testable
8
+ // under `bun test` with zero terminal plumbing.
9
+
10
+ import type { PeerListing } from '../../cli/index.ts'
11
+
12
+ // ─── row filtering ───────────────────────────────────────────────────────────
13
+
14
+ /** Rows matching the filter (case-insensitive substring over personality/description). */
15
+ export function filterRows(rows: PeerListing[], filter: string): PeerListing[] {
16
+ const f = filter.trim().toLowerCase()
17
+ if (!f) return rows
18
+ return rows.filter(r => r.personality.toLowerCase().includes(f) || r.description.toLowerCase().includes(f))
19
+ }
20
+
21
+ /** Clamp the cursor into the filtered row set (rows shrink under a live filter). */
22
+ export function clampCursor(cursor: number, visibleCount: number): number {
23
+ if (visibleCount <= 0) return 0
24
+ return Math.min(Math.max(0, cursor), visibleCount - 1)
25
+ }
26
+
27
+ /** The scroll window [start, end) of `height` rows keeping `cursor` visible. */
28
+ export function scrollWindow(cursor: number, total: number, height: number): { start: number; end: number } {
29
+ if (total <= height) return { start: 0, end: total }
30
+ const start = Math.min(Math.max(0, cursor - Math.floor(height / 2)), total - height)
31
+ return { start, end: start + height }
32
+ }
33
+
34
+ // ─── activity age ────────────────────────────────────────────────────────────
35
+
36
+ /** Compact k9s/CC-style age: 12s · 4m · 3h · 2d; '—' when unknown. */
37
+ export function formatAge(mtimeMs: number | undefined, nowMs: number): string {
38
+ if (mtimeMs === undefined || mtimeMs <= 0) return '—'
39
+ const s = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000))
40
+ if (s < 60) return `${s}s`
41
+ const m = Math.floor(s / 60)
42
+ if (m < 60) return `${m}m`
43
+ const h = Math.floor(m / 60)
44
+ if (h < 48) return `${h}h`
45
+ return `${Math.floor(h / 24)}d`
46
+ }
47
+
48
+ // ─── event-log parsing (ts=… ev=… k=v lines from delivery.log / lifecycle.log) ──
49
+
50
+ export interface LogEvent {
51
+ tsMs: number
52
+ /** HH:MM:SS local time for display. */
53
+ time: string
54
+ ev: string
55
+ fields: Record<string, string>
56
+ }
57
+
58
+ /** Parse one `ts=<iso> ev=<kind> k=v …` line; null for anything else (corrupt tail). */
59
+ export function parseEventLine(line: string): LogEvent | null {
60
+ if (!line.startsWith('ts=')) return null
61
+ const fields: Record<string, string> = {}
62
+ // k=v tokens; values are token-atomic in our append format (no embedded spaces).
63
+ for (const tok of line.split(' ')) {
64
+ const i = tok.indexOf('=')
65
+ if (i > 0) fields[tok.slice(0, i)] = tok.slice(i + 1)
66
+ }
67
+ const ts = fields.ts
68
+ const ev = fields.ev
69
+ if (!ts || !ev) return null
70
+ const tsMs = Date.parse(ts)
71
+ if (!Number.isFinite(tsMs)) return null
72
+ const d = new Date(tsMs)
73
+ const pad = (n: number): string => String(n).padStart(2, '0')
74
+ return { tsMs, time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, ev, fields }
75
+ }
76
+
77
+ /** Does this event concern the peer? Matches bare personality and any `<rt>-<personality>`
78
+ * identity in the fields the two logs actually carry (to/caller/via/personality/identity). */
79
+ export function eventConcernsPeer(e: LogEvent, personality: string): boolean {
80
+ const idSuffix = `-${personality}`
81
+ for (const key of ['to', 'caller', 'via', 'personality', 'identity', 'from', 'peer'] as const) {
82
+ const v = e.fields[key]
83
+ if (!v) continue
84
+ if (v === personality || v.endsWith(idSuffix)) return true
85
+ }
86
+ return false
87
+ }
88
+
89
+ /** One display line per event — compact, the interesting fields only. */
90
+ export function formatEvent(e: LogEvent): { text: string; tone: 'ok' | 'fail' | 'info' } {
91
+ const f = e.fields
92
+ if (e.ev === 'delivery') {
93
+ const ok = f.ok === 'true'
94
+ return {
95
+ text: `${e.time} delivery ${f.caller ?? '?'} → ${f.to ?? '?'} ${ok ? 'ok' : 'FAIL'}${f.ms ? ` ${f.ms}ms` : ''}${f.woke === 'true' ? ' (woke)' : ''}`,
96
+ tone: ok ? 'ok' : 'fail',
97
+ }
98
+ }
99
+ const rest = Object.entries(f)
100
+ .filter(([k]) => k !== 'ts' && k !== 'ev')
101
+ .map(([k, v]) => `${k}=${v}`)
102
+ .join(' ')
103
+ return { text: `${e.time} ${e.ev} ${rest}`.trimEnd(), tone: e.ev.includes('error') || e.ev.includes('fail') ? 'fail' : 'info' }
104
+ }
105
+
106
+ /** Assemble the per-peer log panel: parse both raw tails, filter to the peer, merge
107
+ * by timestamp, keep the newest `limit`. */
108
+ export function assemblePeerLog(rawTails: string[], personality: string, limit: number): Array<{ text: string; tone: 'ok' | 'fail' | 'info' }> {
109
+ const events: LogEvent[] = []
110
+ for (const raw of rawTails) {
111
+ for (const line of raw.split('\n')) {
112
+ const e = parseEventLine(line)
113
+ if (e && eventConcernsPeer(e, personality)) events.push(e)
114
+ }
115
+ }
116
+ events.sort((a, b) => a.tsMs - b.tsMs)
117
+ return events.slice(-limit).map(formatEvent)
118
+ }
119
+
120
+ // ─── ellipsis ────────────────────────────────────────────────────────────────
121
+
122
+ /** Truncate to `width` code points with a … tail (multi-byte-safe via the spread). */
123
+ export function ellipsize(s: string, width: number): string {
124
+ if (width <= 0) return ''
125
+ const chars = [...s]
126
+ return chars.length <= width ? s : chars.slice(0, Math.max(0, width - 1)).join('') + '…'
127
+ }
@@ -0,0 +1,96 @@
1
+ // runDashboard — the Ink entry point + terminal owner of the management dashboard
2
+ // (tty form of `iapeer list`). Owns EVERYTHING terminal-shaped so the app stays pure:
3
+ //
4
+ // - alt-screen: the dashboard is a full-screen live surface; it renders on the
5
+ // alternate screen so quitting restores the operator's scrollback untouched
6
+ // (Apple Terminal keeps no E3, so repeated in-scrollback repaints would pile up —
7
+ // the alt-screen sidesteps that class entirely).
8
+ // - интерактив только в реальном TTY (контракт фазы): stdin+stdout must both be
9
+ // TTYs, else the caller falls back to the scriptable table (never render + hang;
10
+ // the same fail-closed belt as the onboard wizard).
11
+ // - TUI↔TUI handoff = suspend-and-spawn (решение фазы, Требование-1): on attach the
12
+ // dashboard fully unmounts + leaves the alt-screen + returns the terminal to
13
+ // cooked mode, then spawns `iapeer attach <peer>` as a CHILD with inherited stdio
14
+ // (the runtime TUI gets the REAL tty — the readline-class constraint), waits, and
15
+ // remounts. One full-screen TUI owns the terminal at any moment.
16
+ //
17
+ // Self-invocation uses the same `$bunfs` discriminator as supervisor's
18
+ // resolveDaemonSelfArgv: compiled standalone → re-invoke the binary by verb; running
19
+ // from src → `bun <cli/index.ts> attach …`.
20
+
21
+ import React from 'react'
22
+ import { render } from 'ink'
23
+ import { spawnSync } from 'node:child_process'
24
+ import { fileURLToPath } from 'node:url'
25
+ import { DashboardApp, type DashAction } from './app.tsx'
26
+
27
+ /** Sentinel: no real interactive TTY — the caller should print the scriptable table. */
28
+ export const DASHBOARD_NOT_INTERACTIVE = -1
29
+
30
+ const ALT_SCREEN_ON = '\x1b[?1049h\x1b[H'
31
+ const ALT_SCREEN_OFF = '\x1b[?1049l'
32
+
33
+ /** argv prefix that re-invokes THIS package's CLI (compiled binary by verb / src via
34
+ * bun). Same discriminator as supervisor/index.ts resolveDaemonSelfArgv: `$bunfs` in
35
+ * the module path marks the compiled standalone (existsSync is NO discriminator —
36
+ * Bun intercepts bunfs reads). */
37
+ export function resolveSelfCliArgv(execPath: string, selfPath: string): string[] {
38
+ return selfPath.includes('$bunfs') ? [execPath] : [execPath, selfPath]
39
+ }
40
+
41
+ function selfCliArgv(): string[] {
42
+ return resolveSelfCliArgv(process.execPath, fileURLToPath(new URL('../../cli/index.ts', import.meta.url)))
43
+ }
44
+
45
+ export interface DashboardOptions {
46
+ env?: NodeJS.ProcessEnv
47
+ }
48
+
49
+ /** Mount the dashboard once; resolve with the action that ended it. */
50
+ async function mountOnce(env: NodeJS.ProcessEnv): Promise<DashAction> {
51
+ let action: DashAction = { type: 'quit' }
52
+ process.stdout.write(ALT_SCREEN_ON)
53
+ try {
54
+ const app = render(<DashboardApp env={env} onAction={a => (action = a)} />, {
55
+ // the dashboard repaints in place on the alt screen; Ctrl-C is handled by the
56
+ // app itself (quit action) so a half-torn frame never leaks to the main screen
57
+ exitOnCtrlC: false,
58
+ })
59
+ await app.waitUntilExit()
60
+ } finally {
61
+ process.stdout.write(ALT_SCREEN_OFF)
62
+ }
63
+ return action
64
+ }
65
+
66
+ /**
67
+ * Run the live management dashboard. Loops mount → (attach child) → remount until
68
+ * quit. Returns the process exit code, or DASHBOARD_NOT_INTERACTIVE when there is no
69
+ * real TTY to drive it (caller falls back to the scriptable table).
70
+ */
71
+ export async function runDashboard(opts: DashboardOptions = {}): Promise<number> {
72
+ const env = opts.env ?? process.env
73
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return DASHBOARD_NOT_INTERACTIVE
74
+ for (;;) {
75
+ let action: DashAction
76
+ try {
77
+ action = await mountOnce(env)
78
+ } catch {
79
+ // raw-mode unsupported / render failed → let the caller fall back, never hang
80
+ return DASHBOARD_NOT_INTERACTIVE
81
+ }
82
+ if (action.type === 'quit') return 0
83
+ // suspend-and-spawn: the child owns the real TTY until the operator detaches
84
+ // (Ctrl-]) or the session ends; then the dashboard remounts with fresh state.
85
+ const argv = selfCliArgv()
86
+ const r = spawnSync(argv[0]!, [...argv.slice(1), 'attach', action.personality], {
87
+ stdio: 'inherit',
88
+ env,
89
+ })
90
+ if (r.error) {
91
+ process.stdout.write(`attach failed: ${r.error.message}\npress Enter to continue…`)
92
+ // swallow one line so the operator sees the error before the alt-screen returns
93
+ spawnSync('sh', ['-c', 'read _'], { stdio: 'inherit' })
94
+ }
95
+ }
96
+ }