@kal-elsam/kairo-runtime 0.8.0 → 0.10.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.
Files changed (49) hide show
  1. package/README.md +41 -15
  2. package/package.json +1 -1
  3. package/scripts/cockpit-smoke.mjs +6 -6
  4. package/scripts/ux-prototype-tty.mjs +9 -0
  5. package/src/cli.js +24 -1
  6. package/src/global/control-plane-proposals.js +2 -1
  7. package/src/global/control-plane-snapshot.js +1 -1
  8. package/src/global/global-doctor.js +2 -0
  9. package/src/global/ink/cockpit/primitives.js +127 -50
  10. package/src/global/ink/cockpit-alerts.js +36 -0
  11. package/src/global/ink/cockpit-changes.js +61 -37
  12. package/src/global/ink/cockpit-control-center.js +79 -53
  13. package/src/global/ink/cockpit-controller.js +98 -15
  14. package/src/global/ink/cockpit-enter.js +1 -0
  15. package/src/global/ink/cockpit-focus.js +4 -2
  16. package/src/global/ink/cockpit-models.js +100 -51
  17. package/src/global/ink/cockpit-palette.js +109 -0
  18. package/src/global/ink/cockpit-path-label.js +19 -0
  19. package/src/global/ink/cockpit-recovery.js +84 -18
  20. package/src/global/ink/cockpit-reviews.js +14 -10
  21. package/src/global/ink/cockpit-runs.js +13 -4
  22. package/src/global/ink/cockpit-settings.js +194 -0
  23. package/src/global/ink/cockpit-usage.js +111 -0
  24. package/src/global/ink/cockpit-views.js +119 -116
  25. package/src/global/ink/orchestrator-app.js +169 -46
  26. package/src/global/ink/orchestrator-state.js +24 -14
  27. package/src/global/ink/setup-app.js +55 -72
  28. package/src/global/ink/setup-state.js +16 -0
  29. package/src/global/ink/theme.js +27 -0
  30. package/src/global/ink/use-orchestrator-data.js +58 -0
  31. package/src/global/ink/ux/live-activity.js +194 -0
  32. package/src/global/ink/ux/live-alerts.js +159 -0
  33. package/src/global/ink/ux/live-governance.js +195 -0
  34. package/src/global/ink/ux/live-orchestration.js +188 -0
  35. package/src/global/ink/ux/live-overview.js +125 -0
  36. package/src/global/ink/ux/live-settings.js +189 -0
  37. package/src/global/ink/ux/live-setup.js +160 -0
  38. package/src/global/ink/ux/live-usage.js +51 -0
  39. package/src/global/ink/ux/semantic.js +84 -0
  40. package/src/global/ink/ux/task-flow-app.js +85 -0
  41. package/src/global/ink/ux/task-flow.js +173 -0
  42. package/src/global/orchestrator.js +37 -20
  43. package/src/global/paths.js +3 -0
  44. package/src/global/runtime/alerts/alert-store.js +216 -0
  45. package/src/global/runtime/alerts/alert-types.js +59 -0
  46. package/src/global/runtime/alerts/alert-validate.js +117 -0
  47. package/src/global/runtime/monitor/monitor-cli.js +62 -0
  48. package/src/global/runtime/monitor/monitor-platform.js +95 -0
  49. package/src/global/runtime/monitor/monitor.js +249 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Live semantic Usage panel — renderer only; model lives in cockpit-usage.js.
3
+ * ActionList never owns focus.
4
+ */
5
+ import React from "react";
6
+ import { Box, Text } from "ink";
7
+ import { COCKPIT_COLORS } from "../theme.js";
8
+ import { LAYOUT_MODES } from "../layout.js";
9
+ import { adaptUsageModel } from "../cockpit-usage.js";
10
+ import { ActionList, Callout } from "./semantic.js";
11
+
12
+ export function SemanticUsagePanel({
13
+ snapshot = null,
14
+ dashboard = null,
15
+ layoutMode = LAYOUT_MODES.COMPACT,
16
+ colorEnabled = true,
17
+ unicode = true
18
+ }) {
19
+ const model = adaptUsageModel({ snapshot, dashboard, layoutMode });
20
+ const muted = colorEnabled ? COCKPIT_COLORS.muted : undefined;
21
+ return React.createElement(Box, { flexDirection: "column" },
22
+ React.createElement(Text, {
23
+ bold: true, color: colorEnabled ? COCKPIT_COLORS.secondary : undefined
24
+ }, model.title),
25
+ React.createElement(Callout, {
26
+ tone: model.callout.tone,
27
+ title: model.callout.title,
28
+ body: model.callout.body || undefined,
29
+ colorEnabled,
30
+ compact: true
31
+ }),
32
+ React.createElement(Text, { bold: true }, "Measured"),
33
+ React.createElement(Text, null, ` ${model.measured}`),
34
+ React.createElement(Text, { bold: true }, "Configured limits"),
35
+ React.createElement(Text, null, ` ${model.configured}`),
36
+ React.createElement(Text, { bold: true }, "Run usage"),
37
+ model.runs.length === 0
38
+ ? React.createElement(Text, null, " No auditable run tokenUsage yet.")
39
+ : React.createElement(ActionList, {
40
+ items: model.runs,
41
+ selectedIndex: -1,
42
+ focused: false,
43
+ colorEnabled,
44
+ unicode
45
+ }),
46
+ model.moreLine
47
+ ? React.createElement(Text, { color: muted }, model.moreLine)
48
+ : null,
49
+ React.createElement(Text, { color: muted }, model.footnote)
50
+ );
51
+ }
@@ -0,0 +1,84 @@
1
+ /** Semantic Ink primitives — structured models, not string-array panels. */
2
+ import React from "react";
3
+ import { Box, Text } from "ink";
4
+ import { COCKPIT_COLORS, statusColor, resolveGlyphs } from "../theme.js";
5
+
6
+ const mute = (on) => (on ? COCKPIT_COLORS.muted : undefined);
7
+
8
+ export function ActionList({ items = [], selectedIndex = 0, focused = true, colorEnabled = true, unicode = true }) {
9
+ const g = resolveGlyphs(unicode);
10
+ return React.createElement(Box, { flexDirection: "column" },
11
+ ...items.map((item, i) => {
12
+ const sel = i === selectedIndex;
13
+ return React.createElement(Text, {
14
+ key: item.id ?? String(i),
15
+ bold: sel,
16
+ color: sel && focused && colorEnabled ? COCKPIT_COLORS.primary : undefined
17
+ }, `${sel && focused ? g.focus : " "} ${item.label}${item.hint ? ` ${item.hint}` : ""}`);
18
+ })
19
+ );
20
+ }
21
+
22
+ export function Stepper({ steps = [], currentIndex = 0, colorEnabled = true, unicode = true }) {
23
+ // Progress marks: not focus `>` and not failure-looking `x`.
24
+ const done = unicode ? "✓" : "+";
25
+ const current = unicode ? "●" : "*";
26
+ const idle = unicode ? "·" : "-";
27
+ return React.createElement(Box, { flexDirection: "column" },
28
+ ...steps.map((step, i) => {
29
+ const isDone = i < currentIndex;
30
+ const cur = i === currentIndex;
31
+ const color = isDone
32
+ ? (colorEnabled ? COCKPIT_COLORS.success : undefined)
33
+ : cur ? (colorEnabled ? COCKPIT_COLORS.primary : undefined) : mute(colorEnabled);
34
+ return React.createElement(Text, {
35
+ key: step.id ?? String(i), bold: cur, color
36
+ }, `${isDone ? done : cur ? current : idle} ${i + 1}. ${step.label}`);
37
+ })
38
+ );
39
+ }
40
+
41
+ export function Callout({ tone = "info", title, body, colorEnabled = true, compact = false }) {
42
+ const kind = tone === "danger" ? "danger" : tone === "warn" ? "warn" : "ready";
43
+ return React.createElement(Box, {
44
+ flexDirection: "column",
45
+ marginY: compact ? 0 : 1
46
+ },
47
+ React.createElement(Text, { bold: true, color: statusColor(kind, { colorEnabled }) }, title),
48
+ body ? React.createElement(Text, null, body) : null
49
+ );
50
+ }
51
+
52
+ export function Confirm({ summary, primaryLabel = "Confirm", focused = true, colorEnabled = true, mark = " " }) {
53
+ return React.createElement(Box, { flexDirection: "column" },
54
+ summary ? React.createElement(Text, null, summary) : null,
55
+ React.createElement(Text, {
56
+ bold: focused,
57
+ color: focused && colorEnabled ? COCKPIT_COLORS.primary : undefined
58
+ }, `${mark} ${primaryLabel}`)
59
+ );
60
+ }
61
+
62
+ export function Receipt({ title = "Receipt", lines = [], colorEnabled = true }) {
63
+ return React.createElement(Box, { flexDirection: "column" },
64
+ React.createElement(Text, { bold: true, color: colorEnabled ? COCKPIT_COLORS.success : undefined }, title),
65
+ ...lines.map((line, i) => React.createElement(Text, { key: `r${i}`, color: mute(colorEnabled) }, line))
66
+ );
67
+ }
68
+
69
+ export function Details({ open = false, summary = "Details", lines = [], colorEnabled = true, focused = false, mark = " " }) {
70
+ const color = focused && colorEnabled ? COCKPIT_COLORS.primary : mute(colorEnabled);
71
+ if (!open) return React.createElement(Text, { bold: focused, color }, `${mark} ${summary} · Space`);
72
+ return React.createElement(Box, { flexDirection: "column" },
73
+ React.createElement(Text, { bold: true, color }, `${mark} ${summary}`),
74
+ ...lines.map((line, i) => React.createElement(Text, { key: `d${i}`, color: mute(colorEnabled) }, line))
75
+ );
76
+ }
77
+
78
+ export function KeyBar({ hints = [], colorEnabled = true, columns = 80 }) {
79
+ const width = Math.max(24, Math.min(Number(columns) || 80, 120));
80
+ return React.createElement(Box, { width },
81
+ React.createElement(Text, { color: mute(colorEnabled) },
82
+ hints.map((h) => `${h.keys} ${h.label}`).join(" · "))
83
+ );
84
+ }
@@ -0,0 +1,85 @@
1
+ import React, { useEffect, useReducer } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import { COCKPIT_COLORS, resolveGlyphs } from "../theme.js";
4
+ import { resolveTerminalCapabilities } from "../terminal-capabilities.js";
5
+ import { ActionList, Callout, Confirm, Details, KeyBar, Receipt, Stepper } from "./semantic.js";
6
+ import {
7
+ FOCUS, SCREENS, createTaskFlowState, keyHintsFor, modelForState, reduceTaskFlow, resolvePrimaryPresentation
8
+ } from "./task-flow.js";
9
+
10
+ export function TaskFlowApp({ columns: cols, rows: rowCount, onExit } = {}) {
11
+ const { exit } = useApp();
12
+ const { stdout } = useStdout();
13
+ const [state, dispatch] = useReducer(
14
+ reduceTaskFlow,
15
+ createTaskFlowState({ columns: cols ?? stdout?.columns ?? 80, rows: rowCount ?? stdout?.rows ?? 24 })
16
+ );
17
+ const caps = resolveTerminalCapabilities({
18
+ columns: state.columns, rows: state.rows, env: process.env, isTTY: true
19
+ });
20
+ const model = modelForState(state);
21
+ const primary = resolvePrimaryPresentation(model, state, caps.unicode);
22
+ const detailsMark = state.focus === FOCUS.DETAILS ? resolveGlyphs(caps.unicode).focus : " ";
23
+ const accent = caps.color ? COCKPIT_COLORS.primary : undefined;
24
+
25
+ useEffect(() => {
26
+ if (!stdout) return undefined;
27
+ const onResize = () => dispatch({ type: "resize", columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
28
+ stdout.on("resize", onResize);
29
+ return () => stdout.off("resize", onResize);
30
+ }, [stdout]);
31
+
32
+ useEffect(() => {
33
+ if (!state.exited) return;
34
+ onExit?.({ reason: "escape" });
35
+ exit();
36
+ }, [state.exited, exit, onExit]);
37
+
38
+ useInput((input, key) => {
39
+ if (key.upArrow) dispatch({ type: "up" });
40
+ else if (key.downArrow) dispatch({ type: "down" });
41
+ else if (key.return) dispatch({ type: "enter" });
42
+ else if (key.escape) dispatch({ type: "escape" });
43
+ else if (input === " ") dispatch({ type: "space" });
44
+ else if (input === "/") dispatch({ type: "slash" });
45
+ });
46
+
47
+ return React.createElement(Box, { flexDirection: "column", width: state.columns },
48
+ React.createElement(Text, { bold: true, color: accent }, `Kairo · ${model.title} · ${state.layout}`),
49
+ React.createElement(Callout, { ...model.callout, colorEnabled: caps.color }),
50
+ state.screen === SCREENS.SETUP
51
+ ? React.createElement(Stepper, {
52
+ steps: model.steps, currentIndex: model.stepIndex, colorEnabled: caps.color, unicode: caps.unicode
53
+ })
54
+ : null,
55
+ primary.mode === "confirm"
56
+ ? React.createElement(Confirm, {
57
+ summary: primary.summary, primaryLabel: primary.label,
58
+ focused: state.focus === FOCUS.PRIMARY, colorEnabled: caps.color, mark: primary.mark
59
+ })
60
+ : React.createElement(Box, { flexDirection: "column" },
61
+ React.createElement(Text, {
62
+ bold: state.focus === FOCUS.PRIMARY,
63
+ color: state.focus === FOCUS.PRIMARY ? accent : undefined
64
+ }, `${primary.mark} ${primary.label}`),
65
+ primary.detail
66
+ ? React.createElement(Text, { color: caps.color ? COCKPIT_COLORS.muted : undefined }, primary.detail)
67
+ : null
68
+ ),
69
+ model.receipt ? React.createElement(Receipt, { ...model.receipt, colorEnabled: caps.color }) : null,
70
+ (model.secondary || model.metrics)
71
+ ? React.createElement(ActionList, {
72
+ items: model.secondary ?? model.metrics,
73
+ selectedIndex: state.listIndex,
74
+ focused: state.focus === FOCUS.LIST,
75
+ colorEnabled: caps.color,
76
+ unicode: caps.unicode
77
+ })
78
+ : null,
79
+ React.createElement(Details, {
80
+ open: state.detailsOpen, summary: "Details", lines: model.details,
81
+ colorEnabled: caps.color, focused: state.focus === FOCUS.DETAILS, mark: detailsMark
82
+ }),
83
+ React.createElement(KeyBar, { hints: keyHintsFor(state), colorEnabled: caps.color, columns: state.columns })
84
+ );
85
+ }
@@ -0,0 +1,173 @@
1
+ /** Pure task-flow prototype: Home → Setup → Overview. Mock only — no writes. */
2
+
3
+ export const SCREENS = { HOME: "home", SETUP: "setup", OVERVIEW: "overview" };
4
+ export const FOCUS = { PRIMARY: "primary", LIST: "list", DETAILS: "details" };
5
+ export const SETUP_STEPS = [
6
+ { id: "detect", label: "Detect agents" },
7
+ { id: "select", label: "Select governance" },
8
+ { id: "review", label: "Review changes" },
9
+ { id: "confirm", label: "Confirm" },
10
+ { id: "receipt", label: "Receipt" }
11
+ ];
12
+
13
+ export function resolveLayout(c = 80, r = 24) {
14
+ if (c >= 120 && r >= 40) return "wide";
15
+ if (c < 80 || r < 24) return "minimal";
16
+ return "compact";
17
+ }
18
+
19
+ export function createTaskFlowState(init = {}) {
20
+ const columns = init.columns ?? 80;
21
+ const rows = init.rows ?? 24;
22
+ return {
23
+ screen: init.screen ?? SCREENS.HOME,
24
+ setupStep: init.setupStep ?? 0,
25
+ listIndex: init.listIndex ?? 0,
26
+ focus: init.focus ?? FOCUS.PRIMARY,
27
+ detailsOpen: Boolean(init.detailsOpen),
28
+ columns,
29
+ rows,
30
+ exited: Boolean(init.exited),
31
+ layout: resolveLayout(columns, rows)
32
+ };
33
+ }
34
+
35
+ export function buildHomeModel() {
36
+ return {
37
+ title: "Home",
38
+ callout: { tone: "warn", title: "Needs attention", body: "2 open alerts · monitor idle · 1 active run" },
39
+ primary: { id: "start-setup", label: "Start setup · Cursor drift", detail: "Opens guided setup. Stale hooks detected." },
40
+ secondary: [
41
+ { id: "overview", label: "Open overview" },
42
+ { id: "details", label: "Show details", hint: "paths · ids" }
43
+ ],
44
+ details: ["project: agentic-harness", "path hidden until Details"]
45
+ };
46
+ }
47
+
48
+ export function buildSetupModel(stepIndex = 0) {
49
+ const step = SETUP_STEPS[stepIndex] ?? SETUP_STEPS[0];
50
+ const isConfirm = step.id === "confirm";
51
+ const isReceipt = step.id === "receipt";
52
+ return {
53
+ title: "Setup",
54
+ steps: SETUP_STEPS,
55
+ stepIndex,
56
+ callout: {
57
+ tone: "info",
58
+ title: step.label,
59
+ body: isReceipt ? "Prototype receipt — no files written." : "Enter advances · Esc leaves setup."
60
+ },
61
+ primary: { label: isReceipt ? "Open overview" : isConfirm ? "Confirm (no write)" : "Continue" },
62
+ confirm: isConfirm ? { summary: "Apply governance preview to Cursor hooks.", primaryLabel: "Confirm (no write)" } : null,
63
+ receipt: isReceipt ? { title: "Receipt", lines: ["status: simulated", "wrote: none", "planId: proto-1"] } : null,
64
+ details: [`step: ${step.id}`, "write: disabled"]
65
+ };
66
+ }
67
+
68
+ export function buildOverviewModel() {
69
+ return {
70
+ title: "Overview",
71
+ callout: { tone: "warn", title: "Attention first", body: "2 items need a decision before secondary metrics." },
72
+ primary: { label: "Back to home priority" },
73
+ metrics: [
74
+ { id: "agents", label: "Protected agents · Cursor · Codex · Pi" },
75
+ { id: "monitor", label: "Monitor · Disabled" },
76
+ { id: "runs", label: "Active runs · 1" },
77
+ { id: "usage", label: "Usage · Pi evidence only" }
78
+ ],
79
+ details: ["alert: alt-1", "run: run-1"]
80
+ };
81
+ }
82
+
83
+ export function modelForState(state) {
84
+ if (state.screen === SCREENS.SETUP) return buildSetupModel(state.setupStep);
85
+ if (state.screen === SCREENS.OVERVIEW) return buildOverviewModel();
86
+ return buildHomeModel();
87
+ }
88
+
89
+ /** Focus mark only when PRIMARY — list/details own their own markers. */
90
+ export function focusMarkFor(state, unicode = true) {
91
+ return state.focus === FOCUS.PRIMARY ? (unicode ? "›" : ">") : " ";
92
+ }
93
+
94
+ /** Exactly one primary surface; confirm steps own the action (no duplicate line). */
95
+ export function resolvePrimaryPresentation(model, state, unicode = true) {
96
+ const mark = focusMarkFor(state, unicode);
97
+ if (model.confirm) {
98
+ return { mode: "confirm", summary: model.confirm.summary, label: model.confirm.primaryLabel, mark };
99
+ }
100
+ return { mode: "primary", label: model.primary.label, detail: model.primary.detail ?? null, mark };
101
+ }
102
+
103
+ export function keyHintsFor(state) {
104
+ const hints = [
105
+ { keys: "↑↓", label: "Move" },
106
+ { keys: "Enter", label: "Primary" },
107
+ { keys: "Space", label: "Details" },
108
+ { keys: "/", label: "Home" },
109
+ { keys: "Esc", label: state.screen === SCREENS.SETUP ? "Home" : "Exit" }
110
+ ];
111
+ return state.layout === "minimal" ? hints.slice(0, 3) : hints;
112
+ }
113
+
114
+ export function reduceTaskFlow(state, event) {
115
+ switch (event.type) {
116
+ case "resize": {
117
+ const columns = event.columns ?? state.columns;
118
+ const rows = event.rows ?? state.rows;
119
+ return { ...state, columns, rows, layout: resolveLayout(columns, rows) };
120
+ }
121
+ case "up": return moveList(state, -1);
122
+ case "down": return moveList(state, 1);
123
+ case "space": return { ...state, detailsOpen: !state.detailsOpen, focus: FOCUS.DETAILS };
124
+ case "slash":
125
+ return { ...state, screen: SCREENS.HOME, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
126
+ case "escape":
127
+ if (state.detailsOpen) return { ...state, detailsOpen: false, focus: FOCUS.PRIMARY };
128
+ if (state.screen === SCREENS.SETUP) {
129
+ return { ...state, screen: SCREENS.HOME, focus: FOCUS.PRIMARY, listIndex: 0 };
130
+ }
131
+ return { ...state, exited: true };
132
+ case "enter": return activate(state);
133
+ default: return state;
134
+ }
135
+ }
136
+
137
+ function listLength(state) {
138
+ if (state.screen === SCREENS.HOME) return 2;
139
+ if (state.screen === SCREENS.OVERVIEW) return 4;
140
+ return 0;
141
+ }
142
+
143
+ function moveList(state, delta) {
144
+ const len = listLength(state);
145
+ if (len <= 0) return { ...state, focus: FOCUS.PRIMARY };
146
+ if (state.focus !== FOCUS.LIST) {
147
+ return { ...state, listIndex: delta > 0 ? 0 : len - 1, focus: FOCUS.LIST };
148
+ }
149
+ return { ...state, listIndex: (state.listIndex + delta + len) % len, focus: FOCUS.LIST };
150
+ }
151
+
152
+ function activate(state) {
153
+ if (state.screen === SCREENS.HOME) {
154
+ if (state.focus === FOCUS.LIST) {
155
+ const id = buildHomeModel().secondary[state.listIndex]?.id;
156
+ if (id === "overview") {
157
+ return { ...state, screen: SCREENS.OVERVIEW, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
158
+ }
159
+ if (id === "details") return { ...state, detailsOpen: !state.detailsOpen, focus: FOCUS.DETAILS };
160
+ }
161
+ return { ...state, screen: SCREENS.SETUP, setupStep: 0, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
162
+ }
163
+ if (state.screen === SCREENS.SETUP) {
164
+ if (state.setupStep >= SETUP_STEPS.length - 1) {
165
+ return { ...state, screen: SCREENS.OVERVIEW, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
166
+ }
167
+ return { ...state, setupStep: state.setupStep + 1, focus: FOCUS.PRIMARY, detailsOpen: false };
168
+ }
169
+ if (state.screen === SCREENS.OVERVIEW) {
170
+ return { ...state, screen: SCREENS.HOME, focus: FOCUS.PRIMARY, listIndex: 0, detailsOpen: false };
171
+ }
172
+ return state;
173
+ }
@@ -85,27 +85,44 @@ export async function runOrchestratorShell({
85
85
  }
86
86
  }
87
87
 
88
- const outcome = await runOrchestratorInkImpl({
89
- homeDir,
90
- workspaceRoot,
91
- packageRoot,
92
- packageName: packageManifest.name,
93
- cliVersion: packageManifest.version,
94
- hasGlobalState: hasConfiguredGlobalState(homeDir),
95
- fullscreenSession: session
96
- });
97
-
98
- if (outcome.error) {
99
- throw outcome.error;
100
- }
88
+ for (;;) {
89
+ const outcome = await runOrchestratorInkImpl({
90
+ homeDir,
91
+ workspaceRoot,
92
+ packageRoot,
93
+ packageName: packageManifest.name,
94
+ cliVersion: packageManifest.version,
95
+ hasGlobalState: hasConfiguredGlobalState(homeDir),
96
+ fullscreenSession: session
97
+ });
98
+
99
+ if (outcome.error) {
100
+ throw outcome.error;
101
+ }
101
102
 
102
- return {
103
- cancelled: Boolean(outcome.cancelled),
104
- wrote: Boolean(setupOutcome && !setupOutcome.cancelled),
105
- action: outcome.action ?? null,
106
- initialMode,
107
- setup: setupOutcome
108
- };
103
+ if (outcome?.action === "setup") {
104
+ const mid = await runHarnessSetupImpl({
105
+ packageRoot,
106
+ packageName: packageManifest.name,
107
+ cliVersion: packageManifest.version,
108
+ homeDir,
109
+ workspaceRoot,
110
+ onboarding: false,
111
+ interactive: true,
112
+ fullscreenSession: session
113
+ });
114
+ if (!mid?.cancelled) setupOutcome = mid;
115
+ continue;
116
+ }
117
+
118
+ return {
119
+ cancelled: Boolean(outcome.cancelled),
120
+ wrote: Boolean(setupOutcome && !setupOutcome.cancelled),
121
+ action: outcome.action ?? null,
122
+ initialMode,
123
+ setup: setupOutcome
124
+ };
125
+ }
109
126
  } finally {
110
127
  if (ownsSession) {
111
128
  session.leave();
@@ -19,6 +19,9 @@ export function harnessHomePaths(homeDir) {
19
19
  historyPath: join(root, "history.jsonl"),
20
20
  runsDir: join(root, "runs"),
21
21
  reviewsDir: join(root, "reviews"),
22
+ alertsDir: join(root, "alerts"),
23
+ monitorDir: join(root, "monitor"),
24
+ monitorStatePath: join(root, "monitor", "state.json"),
22
25
  coreDir: join(root, "core"),
23
26
  backupsDir: join(root, "backups")
24
27
  };
@@ -0,0 +1,216 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, unlink } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import { harnessHomePaths } from "../../paths.js";
5
+ import { writeAtomicJson } from "../write-atomic-json.js";
6
+ import {
7
+ ALERT_STATES,
8
+ assertSafeAlertId,
9
+ createAlert,
10
+ createAlertFingerprint
11
+ } from "./alert-types.js";
12
+ import { assertAlertSecretFree } from "./alert-validate.js";
13
+
14
+ const TERMINAL_ALERT_STATES = new Set([ALERT_STATES.RESOLVED, ALERT_STATES.DISMISSED]);
15
+
16
+ export class AlertStoreError extends Error {
17
+ constructor(message, { code = "alert_store_error", details = null } = {}) {
18
+ super(message);
19
+ this.name = "AlertStoreError";
20
+ this.code = code;
21
+ this.details = details;
22
+ }
23
+ }
24
+
25
+ export function alertPaths(homeDir, alertId) {
26
+ assertSafeAlertId(alertId);
27
+ const alertDir = join(harnessHomePaths(homeDir).alertsDir, alertId);
28
+ return { alertDir, alertPath: join(alertDir, "alert.json") };
29
+ }
30
+
31
+ function openIndexPath(homeDir, fingerprint) {
32
+ if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) {
33
+ throw new AlertStoreError(`Invalid alert fingerprint "${fingerprint}".`, {
34
+ code: "invalid_fingerprint"
35
+ });
36
+ }
37
+ return join(harnessHomePaths(homeDir).alertsDir, "open", fingerprint);
38
+ }
39
+
40
+ function openDirPath(homeDir) {
41
+ return join(harnessHomePaths(homeDir).alertsDir, "open");
42
+ }
43
+
44
+ async function readOpenAlert(indexPath) {
45
+ const expectedFingerprint = basename(indexPath);
46
+ const alert = assertAlertSecretFree(JSON.parse(await readFile(indexPath, "utf8")));
47
+ if (alert.fingerprint !== expectedFingerprint || alert.state !== ALERT_STATES.OPEN) {
48
+ throw new AlertStoreError(`Corrupt open alert claim "${expectedFingerprint}".`, {
49
+ code: "corrupt_alert",
50
+ details: {
51
+ fingerprint: expectedFingerprint,
52
+ payloadFingerprint: alert.fingerprint,
53
+ state: alert.state
54
+ }
55
+ });
56
+ }
57
+ return alert;
58
+ }
59
+
60
+ async function readHistoryAlert(homeDir, alertId) {
61
+ const alert = assertAlertSecretFree(
62
+ JSON.parse(await readFile(alertPaths(homeDir, alertId).alertPath, "utf8"))
63
+ );
64
+ if (alert.alertId !== alertId || !TERMINAL_ALERT_STATES.has(alert.state)) {
65
+ throw new AlertStoreError(`Corrupt history alert "${alertId}".`, {
66
+ code: "corrupt_alert",
67
+ details: {
68
+ alertId,
69
+ payloadAlertId: alert.alertId,
70
+ state: alert.state
71
+ }
72
+ });
73
+ }
74
+ return alert;
75
+ }
76
+
77
+ function wrapCorruptAlert(label, details, error) {
78
+ if (error instanceof AlertStoreError) throw error;
79
+ throw new AlertStoreError(`Corrupt or unreadable ${label}.`, {
80
+ code: "corrupt_alert",
81
+ details: {
82
+ ...details,
83
+ cause: error instanceof Error ? error.message : String(error)
84
+ }
85
+ });
86
+ }
87
+
88
+ async function listOpenAlerts(homeDir) {
89
+ const dir = openDirPath(homeDir);
90
+ if (!existsSync(dir)) return [];
91
+ const alerts = [];
92
+ for (const name of await readdir(dir)) {
93
+ if (!/^[a-f0-9]{64}$/.test(name)) continue;
94
+ try {
95
+ alerts.push(await readOpenAlert(join(dir, name)));
96
+ } catch (error) {
97
+ wrapCorruptAlert(`open alert "${name}"`, { fingerprint: name }, error);
98
+ }
99
+ }
100
+ return alerts;
101
+ }
102
+
103
+ async function findOpenAlert(homeDir, alertId) {
104
+ assertSafeAlertId(alertId);
105
+ for (const alert of await listOpenAlerts(homeDir)) {
106
+ if (alert.alertId === alertId) return alert;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ export async function loadAlert(alertId, { homeDir } = {}) {
112
+ const open = await findOpenAlert(homeDir, alertId);
113
+ if (open) return open;
114
+ const { alertPath } = alertPaths(homeDir, alertId);
115
+ if (!existsSync(alertPath)) throw new Error(`Alert not found: ${alertId}`);
116
+ return readHistoryAlert(homeDir, alertId);
117
+ }
118
+
119
+ async function writeHistoryAlert(alert, { homeDir } = {}) {
120
+ const sanitized = assertAlertSecretFree(alert);
121
+ const { alertDir, alertPath } = alertPaths(homeDir, sanitized.alertId);
122
+ await mkdir(alertDir, { recursive: true });
123
+ await writeAtomicJson(alertPath, sanitized);
124
+ return sanitized;
125
+ }
126
+
127
+ /**
128
+ * Persist an open alert. `open/<fingerprint>` is the authoritative open record
129
+ * (exclusive create); no secondary mutex.
130
+ */
131
+ export async function saveAlert(input, { homeDir } = {}) {
132
+ const draft = input?.version === 1 ? { ...input } : createAlert(input);
133
+ draft.fingerprint = createAlertFingerprint(draft);
134
+ const candidate = assertAlertSecretFree(draft);
135
+ if (candidate.state !== ALERT_STATES.OPEN) {
136
+ throw new AlertStoreError("Only open alerts can be saved to the open index.", {
137
+ code: "invalid_alert_state",
138
+ details: { state: candidate.state, fingerprint: candidate.fingerprint }
139
+ });
140
+ }
141
+ const indexPath = openIndexPath(homeDir, candidate.fingerprint);
142
+ await mkdir(openDirPath(homeDir), { recursive: true });
143
+
144
+ for (let attempt = 0; attempt < 5; attempt += 1) {
145
+ try {
146
+ await writeAtomicJson(indexPath, candidate, { createExclusive: true });
147
+ return { alert: candidate, deduped: false };
148
+ } catch (error) {
149
+ if (error?.code !== "EEXIST") throw error;
150
+ try {
151
+ return { alert: await readOpenAlert(indexPath), deduped: true };
152
+ } catch (readError) {
153
+ if (readError?.code !== "ENOENT") throw readError;
154
+ // Claim removed between EEXIST and read (resolve/dismiss race) — retry create.
155
+ }
156
+ }
157
+ }
158
+
159
+ throw new AlertStoreError("Unable to claim open alert fingerprint.", {
160
+ code: "claim_failed",
161
+ details: { fingerprint: candidate.fingerprint }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * List alerts. Open records come from open/<fingerprint>;
167
+ * terminal history comes from alt-<id>/alert.json.
168
+ * Corrupt records fail closed.
169
+ */
170
+ export async function listAlerts({ homeDir, state = null, limit = null } = {}) {
171
+ const dir = harnessHomePaths(homeDir).alertsDir;
172
+ if (!existsSync(dir)) return [];
173
+
174
+ const open = await listOpenAlerts(homeDir);
175
+ const openIds = new Set(open.map((alert) => alert.alertId));
176
+ const alerts = [...open];
177
+
178
+ for (const alertId of (await readdir(dir)).filter((n) => /^alt-[a-f0-9]{16,32}$/.test(n))) {
179
+ if (openIds.has(alertId)) continue;
180
+ try {
181
+ alerts.push(await readHistoryAlert(homeDir, alertId));
182
+ } catch (error) {
183
+ wrapCorruptAlert(`alert "${alertId}"`, { alertId }, error);
184
+ }
185
+ }
186
+
187
+ alerts.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))
188
+ || String(a.alertId).localeCompare(String(b.alertId)));
189
+ const filtered = state ? alerts.filter((a) => a.state === state) : alerts;
190
+ return Number.isInteger(limit) && limit >= 0 ? filtered.slice(0, limit) : filtered;
191
+ }
192
+
193
+ async function transitionAlert(alertId, nextState, { homeDir } = {}) {
194
+ const current = await loadAlert(alertId, { homeDir });
195
+ if (current.state !== ALERT_STATES.OPEN) return current;
196
+ const now = new Date().toISOString();
197
+ const updated = await writeHistoryAlert({
198
+ ...current,
199
+ state: nextState,
200
+ updatedAt: now,
201
+ resolvedAt: now
202
+ }, { homeDir });
203
+ const indexPath = openIndexPath(homeDir, current.fingerprint);
204
+ await unlink(indexPath).catch((error) => {
205
+ if (error?.code !== "ENOENT") throw error;
206
+ });
207
+ return updated;
208
+ }
209
+
210
+ export async function resolveAlert(alertId, { homeDir } = {}) {
211
+ return transitionAlert(alertId, ALERT_STATES.RESOLVED, { homeDir });
212
+ }
213
+
214
+ export async function dismissAlert(alertId, { homeDir } = {}) {
215
+ return transitionAlert(alertId, ALERT_STATES.DISMISSED, { homeDir });
216
+ }