@smithers-orchestrator/cli 0.16.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 (110) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +55 -0
  3. package/src/AgentAvailability.ts +13 -0
  4. package/src/AgentAvailabilityStatus.ts +5 -0
  5. package/src/AggregateNodeDetailParams.ts +5 -0
  6. package/src/AskOptions.ts +12 -0
  7. package/src/ChatAttemptMeta.ts +7 -0
  8. package/src/ChatAttemptRow.ts +12 -0
  9. package/src/ChatOutputEvent.ts +6 -0
  10. package/src/DiffBundleLike.ts +6 -0
  11. package/src/DiscoveredWorkflow.ts +9 -0
  12. package/src/EnrichedNodeDetail.ts +60 -0
  13. package/src/EventCategory.ts +18 -0
  14. package/src/FindDbWaitOptions.ts +4 -0
  15. package/src/FormatEventLineOptions.ts +4 -0
  16. package/src/HijackCandidate.ts +11 -0
  17. package/src/HijackLaunchSpec.ts +6 -0
  18. package/src/InitWorkflowPackOptions.ts +4 -0
  19. package/src/InitWorkflowPackResult.ts +6 -0
  20. package/src/NativeHijackEngine.ts +8 -0
  21. package/src/NodeDetailAttempt.ts +22 -0
  22. package/src/NodeDetailTokenUsage.ts +11 -0
  23. package/src/NodeDetailToolCall.ts +12 -0
  24. package/src/ParsedNodeOutputEvent.ts +9 -0
  25. package/src/RenderNodeDetailOptions.ts +4 -0
  26. package/src/RunAutoResumeSkipReason.ts +4 -0
  27. package/src/RunDiffCommandInput.ts +13 -0
  28. package/src/RunDiffCommandResult.ts +3 -0
  29. package/src/RunOutputCommandInput.ts +12 -0
  30. package/src/RunOutputCommandResult.ts +3 -0
  31. package/src/RunRewindCommandInput.ts +14 -0
  32. package/src/RunRewindCommandResult.ts +3 -0
  33. package/src/RunTreeCommandInput.ts +14 -0
  34. package/src/RunTreeCommandResult.ts +3 -0
  35. package/src/SmithersEventType.ts +3 -0
  36. package/src/SupervisorOptions.ts +33 -0
  37. package/src/SupervisorPollSummary.ts +6 -0
  38. package/src/TreeRenderOptions.ts +5 -0
  39. package/src/WatchLoopOptions.ts +9 -0
  40. package/src/WatchLoopResult.ts +8 -0
  41. package/src/WatchRenderContext.ts +4 -0
  42. package/src/WhyBlocker.ts +17 -0
  43. package/src/WhyBlockerKind.ts +9 -0
  44. package/src/WhyDiagnosis.ts +10 -0
  45. package/src/WorkflowCta.ts +4 -0
  46. package/src/WorkflowSourceType.ts +1 -0
  47. package/src/agent-detection.js +257 -0
  48. package/src/ask.js +491 -0
  49. package/src/chat.js +226 -0
  50. package/src/diff.js +221 -0
  51. package/src/event-categories.js +141 -0
  52. package/src/find-db.js +93 -0
  53. package/src/format.js +272 -0
  54. package/src/hijack-session.js +207 -0
  55. package/src/hijack.js +226 -0
  56. package/src/index.d.ts +1 -0
  57. package/src/index.js +4868 -0
  58. package/src/mcp/SemanticMcpServerOptions.ts +4 -0
  59. package/src/mcp/SemanticToolCallResult.ts +14 -0
  60. package/src/mcp/SemanticToolContext.ts +6 -0
  61. package/src/mcp/SemanticToolDefinition.ts +13 -0
  62. package/src/mcp/SemanticToolError.ts +6 -0
  63. package/src/mcp/semantic-server.js +41 -0
  64. package/src/mcp/semantic-tools.js +1242 -0
  65. package/src/node-detail.js +682 -0
  66. package/src/output.js +111 -0
  67. package/src/resume-detached.js +37 -0
  68. package/src/rewind.js +88 -0
  69. package/src/scheduler.js +112 -0
  70. package/src/smithersRuntime.js +63 -0
  71. package/src/supervisor.js +418 -0
  72. package/src/tree.js +307 -0
  73. package/src/tui/app.jsx +139 -0
  74. package/src/tui/app.tsx +5 -0
  75. package/src/tui/components/AskModal.jsx +109 -0
  76. package/src/tui/components/AskModal.tsx +3 -0
  77. package/src/tui/components/AttentionPane.jsx +112 -0
  78. package/src/tui/components/AttentionPane.tsx +6 -0
  79. package/src/tui/components/ChatPane.jsx +57 -0
  80. package/src/tui/components/ChatPane.tsx +7 -0
  81. package/src/tui/components/CronList.jsx +87 -0
  82. package/src/tui/components/CronList.tsx +5 -0
  83. package/src/tui/components/DetailsPane.jsx +96 -0
  84. package/src/tui/components/DetailsPane.tsx +7 -0
  85. package/src/tui/components/FramesPane.jsx +147 -0
  86. package/src/tui/components/FramesPane.tsx +8 -0
  87. package/src/tui/components/LogsPane.jsx +46 -0
  88. package/src/tui/components/LogsPane.tsx +6 -0
  89. package/src/tui/components/MetricsPane.jsx +108 -0
  90. package/src/tui/components/MetricsPane.tsx +5 -0
  91. package/src/tui/components/NodeDetailView.jsx +284 -0
  92. package/src/tui/components/NodeDetailView.tsx +7 -0
  93. package/src/tui/components/NodeInspector.jsx +51 -0
  94. package/src/tui/components/NodeInspector.tsx +7 -0
  95. package/src/tui/components/RunDetailView.jsx +190 -0
  96. package/src/tui/components/RunDetailView.tsx +7 -0
  97. package/src/tui/components/RunsList.jsx +184 -0
  98. package/src/tui/components/RunsList.tsx +7 -0
  99. package/src/tui/components/SqliteBrowser.jsx +131 -0
  100. package/src/tui/components/SqliteBrowser.tsx +5 -0
  101. package/src/tui/components/WorkflowLauncher.jsx +63 -0
  102. package/src/tui/components/WorkflowLauncher.tsx +3 -0
  103. package/src/util/CliErrorMapping.ts +7 -0
  104. package/src/util/CliExitCode.ts +10 -0
  105. package/src/util/errorMessage.js +212 -0
  106. package/src/util/exitCodes.js +18 -0
  107. package/src/watch.js +128 -0
  108. package/src/why-diagnosis.js +1000 -0
  109. package/src/workflow-pack.js +2151 -0
  110. package/src/workflows.js +122 -0
package/src/tree.js ADDED
@@ -0,0 +1,307 @@
1
+ // @smithers-type-exports-begin
2
+ /** @typedef {import("./TreeRenderOptions.ts").TreeRenderOptions} TreeRenderOptions */
3
+ /** @typedef {import("./RunTreeCommandInput.ts").RunTreeCommandInput} RunTreeCommandInput */
4
+ /** @typedef {import("./RunTreeCommandResult.ts").RunTreeCommandResult} RunTreeCommandResult */
5
+ // @smithers-type-exports-end
6
+
7
+ import pc from "picocolors";
8
+ import { getDevToolsSnapshotRoute, DevToolsRouteError } from "@smithers-orchestrator/server/gatewayRoutes/getDevToolsSnapshot";
9
+ import { streamDevToolsRoute } from "@smithers-orchestrator/server/gatewayRoutes/streamDevTools";
10
+ import { applyDelta } from "@smithers-orchestrator/devtools";
11
+ import { EXIT_OK, EXIT_USER_ERROR, EXIT_SERVER_ERROR, EXIT_SIGINT } from "./util/exitCodes.js";
12
+ import { formatCliErrorForStderr, getCliErrorMapping } from "./util/errorMessage.js";
13
+
14
+ export const TREE_INDENT = " ";
15
+
16
+ /** @param {boolean} color */
17
+ function colors(color) {
18
+ return color ? pc.createColors(true) : pc.createColors(false);
19
+ }
20
+
21
+ /**
22
+ * @param {unknown} value
23
+ * @returns {string}
24
+ */
25
+ function renderAttr(value) {
26
+ if (value === null || value === undefined) return "";
27
+ if (typeof value === "string") return JSON.stringify(value);
28
+ if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
29
+ try {
30
+ return JSON.stringify(value);
31
+ } catch {
32
+ return '"[unserializable]"';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {import("@smithers-orchestrator/protocol/devtools").DevToolsNode} node
38
+ * @param {boolean} useColor
39
+ */
40
+ function renderOpenTag(node, useColor) {
41
+ const c = colors(useColor);
42
+ const parts = [c.cyan(`<${node.type}`)];
43
+ if (node.name) {
44
+ parts.push(` ${c.yellow("name")}=${c.green(renderAttr(node.name))}`);
45
+ }
46
+ if (node.task?.nodeId) {
47
+ parts.push(` ${c.yellow("nodeId")}=${c.green(renderAttr(node.task.nodeId))}`);
48
+ if (node.task.kind) {
49
+ parts.push(` ${c.yellow("kind")}=${c.green(renderAttr(node.task.kind))}`);
50
+ }
51
+ if (node.task.agent) {
52
+ parts.push(` ${c.yellow("agent")}=${c.green(renderAttr(node.task.agent))}`);
53
+ }
54
+ if (typeof node.task.iteration === "number") {
55
+ parts.push(` ${c.yellow("iter")}=${c.magenta(String(node.task.iteration))}`);
56
+ }
57
+ }
58
+ for (const [key, value] of Object.entries(node.props ?? {})) {
59
+ // Keep props short; skip nested objects to avoid multi-line blowouts.
60
+ if (value === null || value === undefined) continue;
61
+ if (typeof value === "object") continue;
62
+ parts.push(` ${c.yellow(key)}=${c.green(renderAttr(value))}`);
63
+ }
64
+ parts.push(c.cyan(">"));
65
+ return parts.join("");
66
+ }
67
+
68
+ /** @param {import("@smithers-orchestrator/protocol/devtools").DevToolsNode} node @param {boolean} useColor */
69
+ function renderCloseTag(node, useColor) {
70
+ const c = colors(useColor);
71
+ return c.cyan(`</${node.type}>`);
72
+ }
73
+
74
+ /** @param {import("@smithers-orchestrator/protocol/devtools").DevToolsNode} node @param {string} id */
75
+ function findNode(node, id) {
76
+ if (node.task?.nodeId === id) return node;
77
+ if (typeof node.name === "string" && node.name === id) return node;
78
+ for (const child of node.children ?? []) {
79
+ const hit = findNode(child, id);
80
+ if (hit) return hit;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * @param {import("@smithers-orchestrator/protocol/devtools").DevToolsNode} root
87
+ * @param {string} nodeId
88
+ * @returns {import("@smithers-orchestrator/protocol/devtools").DevToolsNode | null}
89
+ */
90
+ export function selectSubtree(root, nodeId) {
91
+ return findNode(root, nodeId);
92
+ }
93
+
94
+ /**
95
+ * @param {import("@smithers-orchestrator/protocol/devtools").DevToolsSnapshot} snapshot
96
+ * @param {TreeRenderOptions} [options]
97
+ * @returns {string}
98
+ */
99
+ export function renderDevToolsTree(snapshot, options) {
100
+ const useColor = Boolean(options?.color);
101
+ const depthLimit = options?.depth;
102
+ const selected = options?.nodeId ? selectSubtree(snapshot.root, options.nodeId) : snapshot.root;
103
+ if (!selected) {
104
+ return "";
105
+ }
106
+ /** @type {string[]} */
107
+ const lines = [];
108
+ /**
109
+ * @param {import("@smithers-orchestrator/protocol/devtools").DevToolsNode} node
110
+ * @param {number} indent
111
+ */
112
+ const walk = (node, indent) => {
113
+ const pad = TREE_INDENT.repeat(indent);
114
+ const children = node.children ?? [];
115
+ const atLimit = typeof depthLimit === "number" && indent + 1 >= depthLimit;
116
+ if (children.length === 0 || atLimit) {
117
+ if (children.length > 0 && atLimit) {
118
+ const opened = renderOpenTag(node, useColor);
119
+ const trunc = useColor ? pc.dim(`...${children.length} hidden...`) : `...${children.length} hidden...`;
120
+ lines.push(`${pad}${opened}${trunc}${renderCloseTag(node, useColor)}`);
121
+ } else {
122
+ lines.push(`${pad}${renderOpenTag(node, useColor)}${renderCloseTag(node, useColor)}`);
123
+ }
124
+ return;
125
+ }
126
+ lines.push(`${pad}${renderOpenTag(node, useColor)}`);
127
+ for (const child of children) {
128
+ walk(child, indent + 1);
129
+ }
130
+ lines.push(`${pad}${renderCloseTag(node, useColor)}`);
131
+ };
132
+ walk(selected, 0);
133
+ return lines.join("\n");
134
+ }
135
+
136
+ /**
137
+ * @param {RunTreeCommandInput} input
138
+ * @returns {Promise<RunTreeCommandResult>}
139
+ */
140
+ export async function runTreeOnce(input) {
141
+ try {
142
+ const snapshot = await getDevToolsSnapshotRoute({
143
+ adapter: input.adapter,
144
+ runId: input.runId,
145
+ frameNo: input.frameNo,
146
+ });
147
+ if (input.json) {
148
+ input.stdout.write(`${JSON.stringify(snapshot)}\n`);
149
+ return { exitCode: EXIT_OK };
150
+ }
151
+ const rendered = renderDevToolsTree(snapshot, {
152
+ depth: input.depth,
153
+ nodeId: input.node,
154
+ color: input.color,
155
+ });
156
+ if (input.node && rendered.length === 0) {
157
+ input.stderr.write(`${formatCliErrorForStderr("NodeNotFound", `Node not found in tree: ${input.node}`)}\n`);
158
+ return { exitCode: EXIT_USER_ERROR };
159
+ }
160
+ input.stdout.write(`${rendered}\n`);
161
+ return { exitCode: EXIT_OK };
162
+ } catch (err) {
163
+ const code = err instanceof DevToolsRouteError ? err.code : undefined;
164
+ input.stderr.write(`${formatCliErrorForStderr(code, err instanceof Error ? err.message : String(err))}\n`);
165
+ const mapping = getCliErrorMapping(code, err instanceof Error ? err.message : undefined);
166
+ return { exitCode: mapping.exitCode };
167
+ }
168
+ }
169
+
170
+ // Finding #8: fatal codes that should terminate the watch instead of
171
+ // triggering a reconnect. InvalidRunId/FrameOutOfRange/Unauthorized won't
172
+ // self-heal by reconnecting; BackpressureDisconnect also signals a
173
+ // consumer-side problem we should not hammer the server over.
174
+ const WATCH_FATAL_CODES = new Set([
175
+ "InvalidRunId",
176
+ "FrameOutOfRange",
177
+ "Unauthorized",
178
+ "BackpressureDisconnect",
179
+ ]);
180
+
181
+ // Exponential backoff bounds. Kept small so `--watch` feels responsive
182
+ // on transient server restarts but doesn't busy-loop.
183
+ const WATCH_RECONNECT_BACKOFF_MS = [200, 500, 1000, 2000, 5000];
184
+
185
+ /** @param {number} attempt */
186
+ function backoffMs(attempt) {
187
+ const idx = Math.min(attempt, WATCH_RECONNECT_BACKOFF_MS.length - 1);
188
+ return WATCH_RECONNECT_BACKOFF_MS[idx];
189
+ }
190
+
191
+ /** @param {RunTreeCommandInput} input @returns {Promise<RunTreeCommandResult>} */
192
+ export async function runTreeWatch(input) {
193
+ const renderOpts = {
194
+ depth: input.depth,
195
+ nodeId: input.node,
196
+ color: input.color,
197
+ };
198
+ /** @type {import("@smithers-orchestrator/protocol/devtools").DevToolsSnapshot | null} */
199
+ let snapshot = null;
200
+ /** @type {number | undefined} */
201
+ let lastDeliveredSeq;
202
+ /** @param {import("@smithers-orchestrator/protocol/devtools").DevToolsSnapshot} snap */
203
+ const emit = (snap) => {
204
+ lastDeliveredSeq = snap.seq;
205
+ if (input.json) {
206
+ input.stdout.write(`${JSON.stringify(snap)}\n`);
207
+ return;
208
+ }
209
+ input.stdout.write(`${renderDevToolsTree(snap, renderOpts)}\n`);
210
+ };
211
+ // Internal controller so we can exit cleanly on SIGINT.
212
+ const internalAbort = new AbortController();
213
+ const onAbort = () => internalAbort.abort();
214
+ if (input.abortSignal) {
215
+ if (input.abortSignal.aborted) internalAbort.abort();
216
+ else input.abortSignal.addEventListener("abort", onAbort, { once: true });
217
+ }
218
+ let attempt = 0;
219
+ try {
220
+ // Finding #8: reconnect loop. On stream error, retry with
221
+ // `fromSeq` so we pick up where we left off. SeqOutOfRange is
222
+ // recoverable (server re-bases from a fresh snapshot), so we
223
+ // treat it as a retryable reconnect, not a fatal error.
224
+ while (!internalAbort.signal.aborted) {
225
+ try {
226
+ const iterator = streamDevToolsRoute({
227
+ adapter: input.adapter,
228
+ runId: input.runId,
229
+ signal: internalAbort.signal,
230
+ ...(lastDeliveredSeq !== undefined ? { fromSeq: lastDeliveredSeq } : undefined),
231
+ });
232
+ for await (const event of iterator) {
233
+ if (internalAbort.signal.aborted) break;
234
+ if (event.kind === "snapshot") {
235
+ snapshot = event.snapshot;
236
+ emit(snapshot);
237
+ attempt = 0;
238
+ } else if (event.kind === "delta" && snapshot) {
239
+ try {
240
+ const nextRoot = applyDelta(snapshot.root, event.delta);
241
+ snapshot = { ...snapshot, root: nextRoot, seq: event.delta.seq };
242
+ emit(snapshot);
243
+ attempt = 0;
244
+ } catch (err) {
245
+ input.stderr.write(`${formatCliErrorForStderr("InvalidDelta", err instanceof Error ? err.message : String(err))}\n`);
246
+ return { exitCode: EXIT_SERVER_ERROR };
247
+ }
248
+ }
249
+ }
250
+ // Iterator ended cleanly.
251
+ if (internalAbort.signal.aborted) {
252
+ return { exitCode: EXIT_SIGINT };
253
+ }
254
+ return { exitCode: EXIT_OK };
255
+ } catch (err) {
256
+ if (internalAbort.signal.aborted) {
257
+ return { exitCode: EXIT_SIGINT };
258
+ }
259
+ const code = err instanceof DevToolsRouteError ? err.code : undefined;
260
+ if (code && WATCH_FATAL_CODES.has(code)) {
261
+ input.stderr.write(`${formatCliErrorForStderr(code, err instanceof Error ? err.message : String(err))}\n`);
262
+ const mapping = getCliErrorMapping(code, err instanceof Error ? err.message : undefined);
263
+ return { exitCode: mapping.exitCode };
264
+ }
265
+ // SeqOutOfRange → re-subscribe without fromSeq so server
266
+ // re-bases from a fresh snapshot.
267
+ if (code === "SeqOutOfRange") {
268
+ input.stderr.write(`[watch] server reported SeqOutOfRange; rebasing from latest snapshot\n`);
269
+ lastDeliveredSeq = undefined;
270
+ }
271
+ attempt += 1;
272
+ const delay = backoffMs(attempt);
273
+ const reason = err instanceof Error ? err.message : String(err);
274
+ input.stderr.write(`[watch] stream error (${code ?? "unknown"}): ${reason}; reconnecting in ${delay}ms (attempt ${attempt})\n`);
275
+ await sleep(delay, internalAbort.signal);
276
+ if (internalAbort.signal.aborted) {
277
+ return { exitCode: EXIT_SIGINT };
278
+ }
279
+ }
280
+ }
281
+ return { exitCode: EXIT_SIGINT };
282
+ } finally {
283
+ if (input.abortSignal) {
284
+ input.abortSignal.removeEventListener?.("abort", onAbort);
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * @param {number} ms
291
+ * @param {AbortSignal} signal
292
+ * @returns {Promise<void>}
293
+ */
294
+ function sleep(ms, signal) {
295
+ return new Promise((resolve) => {
296
+ if (signal.aborted) return resolve();
297
+ const timer = setTimeout(() => {
298
+ signal.removeEventListener?.("abort", onAbort);
299
+ resolve();
300
+ }, ms);
301
+ const onAbort = () => {
302
+ clearTimeout(timer);
303
+ resolve();
304
+ };
305
+ signal.addEventListener?.("abort", onAbort, { once: true });
306
+ });
307
+ }
@@ -0,0 +1,139 @@
1
+ // @ts-nocheck
2
+ import React, { useEffect, useState } from "react";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { RunsList } from "./components/RunsList.jsx";
5
+ import { WorkflowLauncher } from "./components/WorkflowLauncher.jsx";
6
+ import { RunDetailView } from "./components/RunDetailView.jsx";
7
+ import { NodeDetailView } from "./components/NodeDetailView.jsx";
8
+ import { AskModal } from "./components/AskModal.jsx";
9
+ import { SqliteBrowser } from "./components/SqliteBrowser.jsx";
10
+ import { CronList } from "./components/CronList.jsx";
11
+ import { MetricsPane } from "./components/MetricsPane.jsx";
12
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
13
+
14
+ const TABS = [
15
+ { id: "runs", label: "Runs", access: "r" },
16
+ { id: "ask", label: "Agent Console", access: "a" },
17
+ { id: "crons", label: "Triggers", access: "t" },
18
+ { id: "metrics", label: "Telemetry", access: "m" },
19
+ { id: "sqlite", label: "Data Grid", access: "s" },
20
+ ];
21
+ /**
22
+ * @param {{ adapter: SmithersDb; onExit: () => void; }} value
23
+ */
24
+ export function TuiApp({ adapter, onExit, }) {
25
+ const [view, setView] = useState("runs");
26
+ const [selectedRunId, setSelectedRunId] = useState(null);
27
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
28
+ const activeTabId = (view === "detail" || view === "node" || view === "launcher") ? "runs" : view;
29
+ useKeyboard(async (key) => {
30
+ // Tab switching
31
+ if (key.name === "left" || key.name === "right") {
32
+ // only accept left/right at the root view so we don't clobber text inputs
33
+ if (view === "runs" || view === "crons" || view === "metrics") {
34
+ const currentIndex = TABS.findIndex((t) => t.id === activeTabId);
35
+ if (key.name === "right") {
36
+ const next = TABS[(currentIndex + 1) % TABS.length];
37
+ setView(next.id);
38
+ }
39
+ else {
40
+ const prev = TABS[(currentIndex - 1 + TABS.length) % TABS.length];
41
+ setView(prev.id);
42
+ }
43
+ return;
44
+ }
45
+ }
46
+ if (key.name === "s" && view !== "sqlite" && view !== "ask") {
47
+ setView("sqlite");
48
+ return;
49
+ }
50
+ if (key.name === "t" && view !== "crons" && view !== "ask") {
51
+ setView("crons");
52
+ return;
53
+ }
54
+ if (key.name === "m" && view !== "metrics" && view !== "ask") {
55
+ setView("metrics");
56
+ return;
57
+ }
58
+ if (view === "runs") {
59
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
60
+ onExit();
61
+ }
62
+ if (key.name === "n") {
63
+ setView("launcher");
64
+ }
65
+ if (key.name === "a") {
66
+ setView("ask");
67
+ }
68
+ }
69
+ else if (view === "detail") {
70
+ if (key.name === "escape") {
71
+ setView("runs");
72
+ }
73
+ else if (key.name === "c" && key.ctrl) {
74
+ onExit();
75
+ }
76
+ }
77
+ else if (view === "node") {
78
+ if (key.name === "escape") {
79
+ setView("detail");
80
+ }
81
+ else if (key.name === "c" && key.ctrl) {
82
+ onExit();
83
+ }
84
+ }
85
+ else if (view === "launcher") {
86
+ if (key.name === "escape") {
87
+ setView("runs");
88
+ }
89
+ else if (key.name === "c" && key.ctrl) {
90
+ onExit();
91
+ }
92
+ }
93
+ else if (view === "ask") {
94
+ if (key.name === "escape") {
95
+ setView("runs");
96
+ }
97
+ else if (key.name === "c" && key.ctrl) {
98
+ onExit();
99
+ }
100
+ }
101
+ });
102
+ return (<box style={{ flexGrow: 1, width: "100%", height: "100%", flexDirection: "column" }}>
103
+ {/* Global Tab Header */}
104
+ <box style={{ width: "100%", height: 3, borderBottom: true, borderColor: "gray", flexDirection: "row", paddingLeft: 1 }}>
105
+ {TABS.map((tab) => {
106
+ const isActive = activeTabId === tab.id;
107
+ return (<text key={tab.id} style={{ color: isActive ? "#a7f3d0" : "gray", marginRight: 3 }}>
108
+ {isActive ? "▶ " : " "}[{tab.access.toUpperCase()}] {tab.label}
109
+ </text>);
110
+ })}
111
+ </box>
112
+
113
+ {view === "runs" && (<box style={{ flexGrow: 1, width: "100%", height: "100%", border: true, borderColor: "#34d399", flexDirection: "column" }} title="Smithers Runs - [Enter] View Details | [N] New Run | [Esc] Exit">
114
+ <RunsList adapter={adapter} focused={view === "runs"} onChange={setSelectedRunId} onSubmit={(runId) => {
115
+ setSelectedRunId(runId);
116
+ setView("detail");
117
+ }}/>
118
+ </box>)}
119
+
120
+ {view === "detail" && selectedRunId && (<RunDetailView adapter={adapter} runId={selectedRunId} onBack={() => setView("runs")} onSelectNode={(nodeId) => {
121
+ setSelectedNodeId(nodeId);
122
+ setView("node");
123
+ }}/>)}
124
+
125
+ {view === "node" && selectedRunId && (<NodeDetailView adapter={adapter} runId={selectedRunId} nodeId={selectedNodeId} onBack={() => setView("detail")}/>)}
126
+
127
+ {view === "launcher" && (<WorkflowLauncher onClose={() => setView("runs")}/>)}
128
+ {view === "ask" && (<AskModal onClose={() => setView("runs")}/>)}
129
+ {view === "sqlite" && (<box style={{ flexGrow: 1, width: "100%", height: "100%", border: true, borderColor: "#34d399", flexDirection: "column" }} title="Smithers DB - [Esc] Return to Runs | [Tab] Switch Panes | [Up/Down] Query Table">
130
+ <SqliteBrowser adapter={adapter} onBack={() => setView("runs")}/>
131
+ </box>)}
132
+ {view === "crons" && (<box style={{ flexGrow: 1, width: "100%", height: "100%", border: true, borderColor: "#34d399", flexDirection: "column" }} title="Smithers Schedule Triggers - [Esc] Return to Runs | [Up/Down] Select | [Del] Remove">
133
+ <CronList adapter={adapter} onBack={() => setView("runs")}/>
134
+ </box>)}
135
+ {view === "metrics" && (<box style={{ flexGrow: 1, width: "100%", height: "100%", border: true, borderColor: "#34d399", flexDirection: "column" }} title="Smithers Telemetry (Prometheus Rollup) - [Esc] Return to Runs">
136
+ <MetricsPane adapter={adapter} onBack={() => setView("runs")}/>
137
+ </box>)}
138
+ </box>);
139
+ }
@@ -0,0 +1,5 @@
1
+ import type { SmithersDb } from "@smithers-orchestrator/db/adapter";
2
+ export declare function TuiApp({ adapter, onExit, }: {
3
+ adapter: SmithersDb;
4
+ onExit: () => void;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,109 @@
1
+ // @ts-nocheck
2
+ import React, { useState, useEffect } from "react";
3
+ import { useKeyboard } from "@opentui/react";
4
+ /**
5
+ * @param {{ onClose: () => void }} value
6
+ */
7
+ export function AskModal({ onClose }) {
8
+ const [question, setQuestion] = useState("");
9
+ const [answer, setAnswer] = useState("");
10
+ const [status, setStatus] = useState("input");
11
+ useKeyboard((key) => {
12
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
13
+ if (status !== "streaming") { // Or allow cancel mid-stream if we kill the proc? Keep simple for now
14
+ onClose();
15
+ return;
16
+ }
17
+ }
18
+ if (status === "input") {
19
+ if (key.name === "backspace") {
20
+ setQuestion((q) => q.slice(0, -1));
21
+ return;
22
+ }
23
+ if (key.name === "enter" || key.name === "return") {
24
+ if (question.trim().length > 0) {
25
+ startAsk();
26
+ }
27
+ return;
28
+ }
29
+ // Basic typing capture (sequence is the literal ansi char)
30
+ if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.name !== "up" && key.name !== "down" && key.name !== "left" && key.name !== "right") {
31
+ setQuestion((q) => q + key.sequence);
32
+ }
33
+ }
34
+ });
35
+ async function startAsk() {
36
+ setStatus("streaming");
37
+ setAnswer("");
38
+ try {
39
+ const proc = Bun.spawn(["bun", "run", "src/index.js", "ask", question], {
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ });
43
+ // Stream stdout async
44
+ (async () => {
45
+ try {
46
+ const stream = proc.stdout;
47
+ const reader = stream.getReader();
48
+ const decoder = new TextDecoder();
49
+ while (true) {
50
+ const { done, value } = await reader.read();
51
+ if (done)
52
+ break;
53
+ const text = decoder.decode(value);
54
+ setAnswer((a) => a + text);
55
+ }
56
+ }
57
+ catch { }
58
+ })();
59
+ // Stream stderr async (in case the agent prints progress or errors there)
60
+ (async () => {
61
+ try {
62
+ const stream = proc.stderr;
63
+ const reader = stream.getReader();
64
+ const decoder = new TextDecoder();
65
+ while (true) {
66
+ const { done, value } = await reader.read();
67
+ if (done)
68
+ break;
69
+ const text = decoder.decode(value);
70
+ setAnswer((a) => a + text);
71
+ }
72
+ }
73
+ catch { }
74
+ })();
75
+ await proc.exited;
76
+ if (proc.exitCode !== 0 && answer.trim().length === 0) {
77
+ setAnswer("Failed to run ask command. Is your agent installed?");
78
+ setStatus("error");
79
+ }
80
+ else {
81
+ setStatus("done");
82
+ }
83
+ }
84
+ catch (err) {
85
+ setAnswer(`Spawn error: ${err.message}`);
86
+ setStatus("error");
87
+ }
88
+ }
89
+ // Auto scroll logic in OpenTUI: scrollboxes generally stay at the top unless navigated?
90
+ // For streaming, we'll just append text.
91
+ return (<box style={{
92
+ flexGrow: 1,
93
+ width: "100%",
94
+ height: "100%",
95
+ border: true,
96
+ borderColor: "magenta",
97
+ flexDirection: "column",
98
+ }} title={`Ask Smithers ${status === "input" ? "[Type Question, Enter to Submit, Esc to Close]" : "[Streaming... Esc to Close]"}`}>
99
+ {status === "input" ? (<box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
100
+ <text style={{ color: "cyan" }}>What would you like to know about the Smithers orchestrator?</text>
101
+ <text style={{ color: "white", marginTop: 1 }}>{"> "}{question}█</text>
102
+ </box>) : (<scrollbox style={{ width: "100%", height: "100%", flexDirection: "column", paddingLeft: 1 }}>
103
+ <text style={{ color: "cyan", marginBottom: 1 }}>Q: {question}</text>
104
+ <text style={{ color: "white" }}>{answer}</text>
105
+ {status === "done" && <text style={{ color: "green", marginTop: 1 }}>[ Agent finished. Press Esc to close. ]</text>}
106
+ {status === "error" && <text style={{ color: "red", marginTop: 1 }}>[ Agent failed. Press Esc to close. ]</text>}
107
+ </scrollbox>)}
108
+ </box>);
109
+ }
@@ -0,0 +1,3 @@
1
+ export declare function AskModal({ onClose }: {
2
+ onClose: () => void;
3
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,112 @@
1
+ // @ts-nocheck
2
+ import React, { useEffect, useState, useCallback } from "react";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { formatAge } from "../../format.js";
5
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
6
+
7
+ /**
8
+ * @param {{ adapter: SmithersDb; focused: boolean; onSelectRun?: (runId: string) => void; }} value
9
+ */
10
+ export function AttentionPane({ adapter, focused, onSelectRun, }) {
11
+ const [items, setItems] = useState([]);
12
+ const [selectedIndex, setSelectedIndex] = useState(0);
13
+ useEffect(() => {
14
+ let mounted = true;
15
+ async function poll() {
16
+ if (!mounted)
17
+ return;
18
+ try {
19
+ const result = [];
20
+ // Active alerts
21
+ const alerts = await adapter.listAlerts(100, ["firing", "acknowledged"]);
22
+ for (const alert of alerts) {
23
+ result.push({
24
+ kind: "alert",
25
+ id: alert.alertId,
26
+ severity: alert.severity,
27
+ status: alert.status,
28
+ runId: alert.runId ?? null,
29
+ nodeId: alert.nodeId ?? null,
30
+ message: alert.message,
31
+ firedAtMs: alert.firedAtMs ?? null,
32
+ });
33
+ }
34
+ // Pending approvals
35
+ const runs = await adapter.listRuns(100);
36
+ for (const run of runs) {
37
+ const pending = await adapter.listPendingApprovals(run.runId);
38
+ for (const ap of pending) {
39
+ result.push({
40
+ kind: "approval",
41
+ id: `${ap.runId}:${ap.nodeId}:${ap.iteration ?? 0}`,
42
+ severity: "info",
43
+ status: "pending",
44
+ runId: ap.runId,
45
+ nodeId: ap.nodeId,
46
+ message: ap.note ?? `Approval for ${ap.nodeId}`,
47
+ firedAtMs: ap.requestedAtMs ?? null,
48
+ });
49
+ }
50
+ }
51
+ // Sort: critical first, then warning, then info
52
+ const order = { critical: 0, warning: 1, info: 2 };
53
+ result.sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
54
+ if (mounted)
55
+ setItems(result);
56
+ }
57
+ catch { }
58
+ if (mounted)
59
+ setTimeout(poll, 2000);
60
+ }
61
+ poll();
62
+ return () => { mounted = false; };
63
+ }, [adapter]);
64
+ const selected = items[selectedIndex];
65
+ useKeyboard(focused, useCallback((key) => {
66
+ if (key === "up" || key === "k") {
67
+ setSelectedIndex((i) => Math.max(0, i - 1));
68
+ }
69
+ else if (key === "down" || key === "j") {
70
+ setSelectedIndex((i) => Math.min(items.length - 1, i + 1));
71
+ }
72
+ else if (key === "a" && selected?.kind === "alert" && selected.status === "firing") {
73
+ // Ack
74
+ void adapter.acknowledgeAlert(selected.id, Date.now());
75
+ }
76
+ else if (key === "r" && selected?.kind === "alert") {
77
+ // Resolve
78
+ void adapter.resolveAlert(selected.id, Date.now());
79
+ }
80
+ else if (key === "s" && selected?.kind === "alert") {
81
+ // Silence for 1h
82
+ void adapter.silenceAlert(selected.id, Date.now() + 3_600_000);
83
+ }
84
+ else if (key === "enter" && selected?.runId && onSelectRun) {
85
+ onSelectRun(selected.runId);
86
+ }
87
+ }, [items, selectedIndex, selected, adapter, onSelectRun]));
88
+ const severityCounts = {
89
+ critical: items.filter((i) => i.severity === "critical").length,
90
+ warning: items.filter((i) => i.severity === "warning").length,
91
+ info: items.filter((i) => i.severity === "info").length,
92
+ };
93
+ const header = [
94
+ `Attention (${items.length})`,
95
+ severityCounts.critical > 0 ? ` 🔴${severityCounts.critical}` : "",
96
+ severityCounts.warning > 0 ? ` 🟡${severityCounts.warning}` : "",
97
+ severityCounts.info > 0 ? ` 🔵${severityCounts.info}` : "",
98
+ ].join("");
99
+ return (<box flexDirection="column">
100
+ <text bold>{header}</text>
101
+ <text dimColor> [a]ck [r]esolve [s]ilence [Enter] open run</text>
102
+ {items.length === 0 ? (<text dimColor> All clear — no attention items.</text>) : (items.map((item, i) => {
103
+ const isSelected = i === selectedIndex && focused;
104
+ const sev = item.severity === "critical" ? "🔴" : item.severity === "warning" ? "🟡" : "🔵";
105
+ const age = item.firedAtMs ? formatAge(item.firedAtMs) : "";
106
+ const prefix = isSelected ? "▸ " : " ";
107
+ return (<text key={item.id} inverse={isSelected}>
108
+ {prefix}{sev} [{item.kind}] {item.message} ({item.status}) {age}
109
+ </text>);
110
+ }))}
111
+ </box>);
112
+ }