@agfpd/iapeer 0.4.31 → 0.4.33

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.33",
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))
@@ -37,8 +37,8 @@
37
37
 
38
38
  import { homedir } from 'os'
39
39
  import { join } from 'path'
40
- import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
41
- import { lastTimestampedEntryMs } from './transcriptTail.ts'
40
+ import { readdirSync, realpathSync, statSync } from 'fs'
41
+ import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
42
42
  import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
43
43
 
44
44
  // ─────────────────────────────────────────────────────────────────────────────
@@ -75,26 +75,8 @@ function codexHooksReviewActive(pane: string): boolean {
75
75
  // Session activity proxy + resume preflight (watcher / spawner port)
76
76
  // ─────────────────────────────────────────────────────────────────────────────
77
77
 
78
- /**
79
- * The cwd a codex session was opened in, read from the first jsonl line's
80
- * `session_meta.payload.cwd` (watcher.codexSessionCwd, 200-212). null when the
81
- * file is missing, unreadable, not a session_meta record, or carries no cwd.
82
- */
83
- function codexSessionCwd(file: string): string | null {
84
- try {
85
- const firstLine = readFileSync(file, 'utf8').split(/\r?\n/, 1)[0]
86
- if (!firstLine) return null
87
- const entry = JSON.parse(firstLine) as {
88
- type?: unknown
89
- payload?: { cwd?: unknown }
90
- }
91
- return entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string'
92
- ? entry.payload.cwd
93
- : null
94
- } catch {
95
- return null
96
- }
97
- }
78
+ // codexSessionCwd (bounded first-line read + path-memoized) is shared from transcriptTail.ts —
79
+ // the old full-file readFileSync here read tens of MB per session file on every hot-path scan.
98
80
 
99
81
  /** realpath a path so a symlinked cwd compares equal to the dir codex recorded;
100
82
  * a stale/missing path falls through to the original string (the canonicalPath
@@ -30,6 +30,73 @@ import { openSync, readSync, closeSync, statSync } from 'fs'
30
30
  * the newline-bounded segments after it. Reaching byte 0 makes every segment complete, including the
31
31
  * first — and terminates the growth.
32
32
  */
33
+ /**
34
+ * The FIRST newline-terminated line of a file, read with an ADAPTIVE growing window — NEVER the whole
35
+ * file. A codex `session_meta` first line carries the full instruction payload (measured 29–73 KB on the
36
+ * live host), so a fixed small chunk would miss the terminating `\n` and return a truncated line; the
37
+ * window doubles (capped at file size) until a `\n` is seen or the whole file is read. Returns null when
38
+ * the file is unreadable/empty. Cheap for the common case (one read covers it), bounded otherwise.
39
+ */
40
+ export function readFirstLine(path: string, chunkBytes = 65536): string | null {
41
+ let fd: number
42
+ try {
43
+ fd = openSync(path, 'r')
44
+ } catch {
45
+ return null
46
+ }
47
+ try {
48
+ const size = statSync(path).size
49
+ if (size <= 0) return null
50
+ let window = Math.max(chunkBytes, 1)
51
+ for (;;) {
52
+ const len = Math.min(window, size)
53
+ const buf = Buffer.allocUnsafe(len)
54
+ let read = 0
55
+ while (read < len) {
56
+ const n = readSync(fd, buf, read, len - read, read)
57
+ if (n <= 0) break
58
+ read += n
59
+ }
60
+ const text = buf.subarray(0, read).toString('utf8')
61
+ const nl = text.indexOf('\n')
62
+ if (nl >= 0) return text.slice(0, nl).replace(/\r$/, '')
63
+ if (len >= size) return text.replace(/\r$/, '') // whole file, no trailing newline → the single line
64
+ window *= 2
65
+ }
66
+ } catch {
67
+ return null
68
+ } finally {
69
+ closeSync(fd)
70
+ }
71
+ }
72
+
73
+ /** Memoized path→cwd. session_meta is written ONCE at session start (immutable) and session-file paths
74
+ * are unique, so caching a DEFINITIVE parse is sound. Only the unreadable/empty case (readFirstLine
75
+ * null) is NOT cached — that can become readable. */
76
+ const codexCwdCache = new Map<string, string | null>()
77
+
78
+ /**
79
+ * The cwd a codex session was opened in — the `session_meta.payload.cwd` of the FIRST jsonl line — read
80
+ * via the bounded readFirstLine and memoized by path. Replaces the old full-file readFileSync that ran
81
+ * per-file on every hot-path scan (superviseTick, ready-gate, delivery baseline). Returns null when the
82
+ * file is missing/unreadable or carries no session_meta cwd.
83
+ */
84
+ export function codexSessionCwd(file: string): string | null {
85
+ const cached = codexCwdCache.get(file)
86
+ if (cached !== undefined) return cached
87
+ const firstLine = readFirstLine(file)
88
+ if (firstLine === null) return null // unreadable/empty — do NOT cache (may become readable)
89
+ let cwd: string | null = null
90
+ try {
91
+ const entry = JSON.parse(firstLine) as { type?: unknown; payload?: { cwd?: unknown } }
92
+ if (entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string') cwd = entry.payload.cwd
93
+ } catch {
94
+ cwd = null
95
+ }
96
+ codexCwdCache.set(file, cwd)
97
+ return cwd
98
+ }
99
+
33
100
  export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number | null {
34
101
  let fd: number
35
102
  try {
@@ -121,7 +121,7 @@ export async function startPtyHost(b: {
121
121
  }): Promise<HostStartResult> {
122
122
  try {
123
123
  const { startSupervisorDaemon } = await import('../supervisor/index.ts')
124
- const r = startSupervisorDaemon({
124
+ const r = await startSupervisorDaemon({
125
125
  session: b.identity,
126
126
  runtime: b.runtime,
127
127
  runDir: hostRunDir(),
@@ -1445,7 +1445,14 @@ export async function drainEphemeralQueue(
1445
1445
  )
1446
1446
  const wake = deps.wakeFn ?? wakeOrSpawn
1447
1447
  const result = await wake({ personality, runtime, topic: item.topic, task: item.task }, { cfg, env })
1448
- if (result.status === 'READY') removeEphemeralTask(cfg, identity, item.seq)
1448
+ // Remove ONLY when THIS wake actually delivered the task. On the live-session fast
1449
+ // path (READY, taskDelivered:false) a CONCURRENT wake delivered ITS OWN task — ours
1450
+ // was NOT delivered. Deleting it here would silently drop a queued task (the loss the
1451
+ // contract forbids); leaving it at the head lets the next drain (after that session
1452
+ // reaps) deliver it into a fresh session — invariant "one task = one fresh session".
1453
+ if (result.status === 'READY' && result.taskDelivered !== false) {
1454
+ removeEphemeralTask(cfg, identity, item.seq)
1455
+ }
1449
1456
  return result
1450
1457
  }
1451
1458
 
@@ -43,6 +43,30 @@ export function ephemeralQueueDir(cfg: LifecycleConfig, identity: string): strin
43
43
  }
44
44
 
45
45
  const SEQ_PAD = 6
46
+ const HWM_FILE = '.hwm'
47
+
48
+ /** High-water mark of seqs ever ISSUED for this queue — the monotonic floor that
49
+ * makes names never repeat, even after the queue drains empty (`max(existing)+1`
50
+ * alone resets to 1 once empty, so a concurrent drain still holding the OLD seq
51
+ * could delete a fresh task that got the reused name). 0 when absent/unreadable. */
52
+ function readHwm(dir: string): number {
53
+ try {
54
+ const n = parseInt(readFileSync(join(dir, HWM_FILE), 'utf8'), 10)
55
+ return Number.isFinite(n) && n >= 0 ? n : 0
56
+ } catch {
57
+ return 0
58
+ }
59
+ }
60
+
61
+ /** Raise the high-water mark (monotonic; only ever rises). Best-effort — a lost
62
+ * bump merely risks a reuse after a FULL drain, the same rare window we harden. */
63
+ function bumpHwm(dir: string, seq: number): void {
64
+ try {
65
+ if (seq > readHwm(dir)) writeFileSync(join(dir, HWM_FILE), String(seq), { mode: 0o600 })
66
+ } catch {
67
+ /* best-effort */
68
+ }
69
+ }
46
70
 
47
71
  function listSeqs(dir: string): string[] {
48
72
  let entries: string[]
@@ -79,16 +103,19 @@ export function enqueueEphemeralTask(
79
103
  mode: 0o600,
80
104
  })
81
105
  try {
82
- // Next seq = max existing + 1; on an EEXIST race, advance and retry.
106
+ // Next seq = max(existing items, ever-issued high-water) + 1 — MONOTONIC: never
107
+ // reuse a name after the queue empties. On an EEXIST race, advance and retry.
83
108
  let seq = (() => {
84
109
  const seqs = listSeqs(dir)
85
- return seqs.length ? parseInt(seqs[seqs.length - 1]!, 10) + 1 : 1
110
+ const maxExisting = seqs.length ? parseInt(seqs[seqs.length - 1]!, 10) : 0
111
+ return Math.max(maxExisting, readHwm(dir)) + 1
86
112
  })()
87
113
  // Bounded retry: a competitor can win a name at most once per its own enqueue.
88
114
  for (let attempt = 0; attempt < 1000; attempt++, seq++) {
89
115
  const target = join(dir, String(seq).padStart(SEQ_PAD, '0'))
90
116
  try {
91
117
  linkSync(tmp, target) // atomic exclusive-create (EEXIST when taken)
118
+ bumpHwm(dir, seq) // record the issued name so it is never reused
92
119
  return listSeqs(dir).length
93
120
  } catch (e) {
94
121
  if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
Binary file
@@ -5,7 +5,7 @@
5
5
  // serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
6
6
  // binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
7
7
  import { spawn as nodeSpawn } from 'node:child_process'
8
- import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
8
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { runSupervisorClient, sessionAlive } from './client.ts'
11
11
  import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
@@ -86,8 +86,10 @@ export function resolveDaemonSelfArgv(execPath: string, selfPath: string): strin
86
86
  }
87
87
  const selfArgv = (): string[] => resolveDaemonSelfArgv(process.execPath, fileURLToPath(import.meta.url))
88
88
 
89
- /** Start a detached supervisor daemon and wait until its socket is live. Idempotent. */
90
- export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResult {
89
+ /** Start a detached supervisor daemon and wait until its socket is live. Idempotent.
90
+ * Async: the socket-wait yields the event loop (a sync busy-wait here froze the whole
91
+ * router daemon for the bring-up window on every warm wake). */
92
+ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<StartDaemonResult> {
91
93
  const { session, runtime, runDir } = opts
92
94
  if (sessionAlive(runDir, session)) return { state: 'already-running' }
93
95
  mkdirSync(runDir, { recursive: true })
@@ -111,13 +113,21 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
111
113
  detached: true,
112
114
  stdio: ['ignore', out, out],
113
115
  })
116
+ // The child dup'd `out` at spawn — close the PARENT copy immediately. This function
117
+ // runs inside the always-on router daemon on EVERY warm wake; an unclosed fd here bled
118
+ // the daemon ~1 fd/wake toward EMFILE (live-observed) → eventual full routing outage.
119
+ try {
120
+ closeSync(out)
121
+ } catch {
122
+ /* */
123
+ }
114
124
  child.unref()
115
125
  const deadline = Date.now() + (opts.timeoutMs ?? 5000)
116
126
  while (Date.now() < deadline) {
117
127
  if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
118
128
  return { state: 'started', pid: Number(readFileSync(pidPath(runDir, session), 'utf8')) }
119
129
  }
120
- Bun.sleepSync(80)
130
+ await Bun.sleep(80) // yield the loop — never a sync block in the daemon's wake path
121
131
  }
122
132
  // Failed to come up — drop the serve-spec we wrote so it can't mislead a later read.
123
133
  if (opts.serve)
@@ -192,7 +202,7 @@ export async function runSupervisorCli(argv: string[]): Promise<number> {
192
202
  return 0 // unreachable
193
203
  }
194
204
  case 'start': {
195
- const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
205
+ const r = await startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
196
206
  console.log(`${r.state}${r.pid ? ` pid=${r.pid}` : ''}${r.detail ? ` — ${r.detail}` : ''}: ${a}`)
197
207
  return r.state === 'failed' ? 1 : 0
198
208
  }
@@ -200,7 +210,7 @@ export async function runSupervisorCli(argv: string[]): Promise<number> {
200
210
  await runSupervisorClient(runDir, a!)
201
211
  return 0
202
212
  case 'up': {
203
- const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
213
+ const r = await startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
204
214
  if (r.state === 'failed') {
205
215
  console.error(r.detail)
206
216
  return 1
@@ -13,7 +13,7 @@
13
13
  // runtime's adapter.deliveryMarkers. The poll/re-press logic is untouched; only the
14
14
  // marker SOURCE moved, so the timing behaviour is identical.
15
15
 
16
- import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
16
+ import { closeSync, fstatSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'fs'
17
17
  import { join } from 'path'
18
18
  import { homedir } from 'os'
19
19
  import {
@@ -40,6 +40,7 @@ import type { ResolvedCaller } from '../identity/index.ts'
40
40
  // reads them from getAdapter(target.runtime) for the tui submit path. One-way
41
41
  // dependency: launch does NOT import transport, so no cycle.
42
42
  import { getAdapter } from '../launch/index.ts'
43
+ import { codexSessionCwd } from '../launch/adapters/transcriptTail.ts'
43
44
  import type { ControlCommand } from '../launch/types.ts'
44
45
  // Spawn-flip Ф0b-2: warm-deliver is HOST-AWARE. hostSessionAlive (runtime-state host detect, an
45
46
  // @xterm-free pid-file check) + deliverHosted (the pure-protocol socket leaf, Ф0a) come from
@@ -234,7 +235,11 @@ async function deliverViaHost(
234
235
  // Baseline the transcript/session-jsonl file SIZES BEFORE delivery (per-runtime candidate files).
235
236
  // The landed-confirm reads ONLY the bytes appended past this baseline, so a record carrying our
236
237
  // envelope can only be one written in RESPONSE to this delivery — never a pre-existing copy.
237
- const baseline = cwd ? compactDoneBaseline(target.runtime, cwd) : null
238
+ // LAZY: only 'transcript'-confirm runtimes (claude) use the baseline; router (socket-ack) and
239
+ // socket-ack runtimes (codex) return before it — computing it for them recursively scanned all of
240
+ // ~/.codex/sessions (full-file read per session) on every delivery for nothing.
241
+ const usesBaseline = adapter.kind !== 'router' && adapter.deliveryConfirm !== 'socket-ack'
242
+ const baseline = cwd && usesBaseline ? compactDoneBaseline(target.runtime, cwd) : null
238
243
  const send = seam.deliverHosted ?? deliverHosted
239
244
  const sent = await send(target.address, envelope)
240
245
  if (!sent.ok) {
@@ -374,14 +379,36 @@ function claudeTranscriptDirForCwd(cwd: string, env?: NodeJS.ProcessEnv): string
374
379
  return join(envHome(env), '.claude', 'projects', slug)
375
380
  }
376
381
 
377
- function codexSessionCwd(file: string): string | null {
382
+ // codexSessionCwd (bounded first-line read + path-memoized) is shared from launch/adapters/transcriptTail.ts —
383
+ // the old full-file readFileSync here scanned tens of MB per session file on every warm delivery baseline.
384
+
385
+ /** Read ONLY the bytes of `path` past `offset` (the appended tail), without loading the whole file.
386
+ * O(added bytes) instead of O(entire history) — the transcript files this scans grow to hundreds of MB.
387
+ * Returns '' on any fs error / empty tail. */
388
+ function readFileTailFrom(path: string, offset: number): string {
389
+ let fd: number
378
390
  try {
379
- const firstLine = readFileSync(file, 'utf8').split(/\r?\n/, 1)[0]
380
- if (!firstLine) return null
381
- const entry = JSON.parse(firstLine) as { type?: unknown; payload?: { cwd?: unknown } }
382
- return entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string' ? entry.payload.cwd : null
391
+ fd = openSync(path, 'r')
383
392
  } catch {
384
- return null
393
+ return ''
394
+ }
395
+ try {
396
+ const size = fstatSync(fd).size
397
+ const start = Math.min(Math.max(0, offset), size)
398
+ const len = size - start
399
+ if (len <= 0) return ''
400
+ const buf = Buffer.allocUnsafe(len)
401
+ let read = 0
402
+ while (read < len) {
403
+ const n = readSync(fd, buf, read, len - read, start + read)
404
+ if (n <= 0) break
405
+ read += n
406
+ }
407
+ return buf.subarray(0, read).toString('utf8')
408
+ } catch {
409
+ return ''
410
+ } finally {
411
+ closeSync(fd)
385
412
  }
386
413
  }
387
414
 
@@ -478,14 +505,8 @@ export function compactTranscriptHasDone(
478
505
  }
479
506
 
480
507
  for (const [path, offset] of offsets) {
481
- let buf: Buffer
482
- try {
483
- buf = readFileSync(path)
484
- } catch {
485
- continue
486
- }
487
- const start = Math.min(Math.max(0, offset), buf.length)
488
- const tail = buf.subarray(start).toString('utf8')
508
+ const tail = readFileTailFrom(path, offset) // tail-only read (see transcriptCarriesEnvelope)
509
+ if (!tail) continue
489
510
  for (const line of tail.split(/\r?\n/)) {
490
511
  if (!line.trim()) continue
491
512
  let obj: unknown
@@ -537,14 +558,11 @@ export function transcriptCarriesEnvelope(baseline: CompactDoneBaseline, envelop
537
558
  const needle = envelope.replace(/\r/g, '')
538
559
  if (!needle) return false
539
560
  for (const { path, size: offset } of baseline.files) {
540
- let buf: Buffer
541
- try {
542
- buf = readFileSync(path)
543
- } catch {
544
- continue
545
- }
546
- const start = Math.min(Math.max(0, offset), buf.length)
547
- const tail = buf.subarray(start).toString('utf8')
561
+ // Read ONLY the tail past the baseline offset — a full readFileSync here re-read the ENTIRE
562
+ // transcript (hundreds of MB for a long-running peer) on every ~100ms confirm-poll, stalling the
563
+ // single-threaded router event loop for the whole fleet.
564
+ const tail = readFileTailFrom(path, offset)
565
+ if (!tail) continue
548
566
  for (const line of tail.split(/\r?\n/)) {
549
567
  if (!line.trim()) continue
550
568
  let obj: unknown
@@ -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
+ }