terminal-agent-workboard 0.0.1

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/dist/ui.js ADDED
@@ -0,0 +1,450 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { Box, Text, useApp, useStdout } from "ink";
4
+ import { orderedStatuses } from "./status.js";
5
+ import { discoverHistory } from "./history.js";
6
+ import { EMPTY_BUFFER, backspace, deleteForward, deleteWordBefore, getCurrentSlashToken, insertText, killLine, moveDown, moveLeft, moveLineEnd, moveLineStart, moveRight, moveUp, moveWordLeft, moveWordRight } from "./promptBuffer.js";
7
+ import { useTerminalInput } from "./terminalInput.js";
8
+ import { SlashCommandMenu } from "./SlashCommandMenu.js";
9
+ import { buildSlashCommands, filterSlashCommands, isExactSlashCommand } from "./slashCommands.js";
10
+ import { runWorkboardCommand } from "./workboardCommands.js";
11
+ import { decideCtrlC } from "./ctrlCBehavior.js";
12
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
13
+ export function WorkboardApp({ workboard, config }) {
14
+ const { exit } = useApp();
15
+ const { stdout } = useStdout();
16
+ const [sessions, setSessions] = useState(() => workboard.list());
17
+ const [history, setHistory] = useState(() => discoverHistory());
18
+ const [selected, setSelected] = useState(0);
19
+ const [buffer, setBuffer] = useState(EMPTY_BUFFER);
20
+ const [message, setMessage] = useState("");
21
+ const [review, setReview] = useState(null);
22
+ const [agentIndex, setAgentIndex] = useState(0);
23
+ const [menuIndex, setMenuIndex] = useState(0);
24
+ const [historyOpen, setHistoryOpen] = useState(false);
25
+ const [historyIndex, setHistoryIndex] = useState(0);
26
+ const [pendingExitAt, setPendingExitAt] = useState(null);
27
+ const [spinnerIndex, setSpinnerIndex] = useState(0);
28
+ const [attaching, setAttaching] = useState(false);
29
+ const agents = useMemo(() => Object.keys(config.agents), [config.agents]);
30
+ const currentAgent = agents[agentIndex] ?? config.defaultAgent;
31
+ const visibleSessions = useMemo(() => buildVisibleSessions(sessions), [sessions]);
32
+ const selectedSession = visibleSessions[selected];
33
+ const selectedWorking = selectedSession?.status === "Working";
34
+ const slashCommands = useMemo(() => buildSlashCommands(agents), [agents]);
35
+ const slashToken = getCurrentSlashToken(buffer);
36
+ const slashMenu = useMemo(() => (slashToken ? filterSlashCommands(slashCommands, slashToken) : []), [slashCommands, slashToken]);
37
+ const showSlashMenu = slashMenu.length > 0;
38
+ const refresh = useCallback(() => {
39
+ const next = workboard.list();
40
+ setSessions(next);
41
+ setHistory(discoverHistory());
42
+ setSelected((current) => Math.min(current, Math.max(0, buildVisibleSessions(next).length - 1)));
43
+ }, [workboard]);
44
+ useEffect(() => {
45
+ const timer = setInterval(refresh, 1000);
46
+ return () => clearInterval(timer);
47
+ }, [refresh]);
48
+ useEffect(() => {
49
+ const hide = () => stdout.write("[?25l");
50
+ const show = () => stdout.write("[?25h");
51
+ hide();
52
+ process.on("exit", show);
53
+ return () => {
54
+ show();
55
+ process.off("exit", show);
56
+ };
57
+ }, [stdout]);
58
+ useEffect(() => {
59
+ refresh();
60
+ }, [refresh]);
61
+ useEffect(() => {
62
+ if (!selectedWorking) {
63
+ setSpinnerIndex(0);
64
+ return;
65
+ }
66
+ const timer = setInterval(() => {
67
+ setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length);
68
+ }, 80);
69
+ return () => clearInterval(timer);
70
+ }, [selectedWorking]);
71
+ const rows = useMemo(() => buildRows(sessions), [sessions]);
72
+ useEffect(() => {
73
+ if (!showSlashMenu) {
74
+ setMenuIndex(0);
75
+ return;
76
+ }
77
+ if (menuIndex >= slashMenu.length) {
78
+ setMenuIndex(Math.max(0, slashMenu.length - 1));
79
+ }
80
+ }, [menuIndex, showSlashMenu, slashMenu.length]);
81
+ useEffect(() => {
82
+ setHistoryIndex((index) => Math.min(index, Math.max(0, history.length - 1)));
83
+ }, [history.length]);
84
+ const restoreHistoryAt = useCallback((index) => {
85
+ const entry = history[index];
86
+ if (!entry) {
87
+ setMessage("no history session selected");
88
+ return;
89
+ }
90
+ try {
91
+ const restored = workboard.restore(entry);
92
+ setHistoryOpen(false);
93
+ setBuffer(EMPTY_BUFFER);
94
+ setMessage(`restored ${restored.id}`);
95
+ refresh();
96
+ }
97
+ catch (error) {
98
+ setMessage(error instanceof Error ? error.message : String(error));
99
+ }
100
+ }, [history, refresh, workboard]);
101
+ const attachSession = useCallback((sessionId) => {
102
+ const session = sessions.find((candidate) => candidate.id === sessionId || candidate.tmuxSession === sessionId);
103
+ if (!session) {
104
+ setMessage(`unknown session ${sessionId}`);
105
+ return;
106
+ }
107
+ setAttaching(true);
108
+ setMessage(`attached to ${session.id}; detach tmux to return`);
109
+ setTimeout(() => {
110
+ try {
111
+ stdout.write("\u001B[2J\u001B[3J\u001B[H");
112
+ workboard.attach(session.id);
113
+ stdout.write("[?25l");
114
+ setMessage(`returned from ${session.id}`);
115
+ refresh();
116
+ }
117
+ catch (error) {
118
+ setMessage(error instanceof Error ? error.message : String(error));
119
+ }
120
+ finally {
121
+ setAttaching(false);
122
+ }
123
+ }, 20);
124
+ }, [refresh, sessions, stdout, workboard]);
125
+ const submit = useCallback(() => {
126
+ const value = buffer.text.trim();
127
+ if (!value)
128
+ return;
129
+ setBuffer(EMPTY_BUFFER);
130
+ try {
131
+ if (value.startsWith("/")) {
132
+ if (value === "/history") {
133
+ const nextHistory = discoverHistory();
134
+ setHistory(nextHistory);
135
+ setHistoryOpen(true);
136
+ setMessage(nextHistory.length > 0 ? "history browser opened" : "no history sessions found");
137
+ return;
138
+ }
139
+ runWorkboardCommand(value, {
140
+ sessions: visibleSessions,
141
+ history,
142
+ selected,
143
+ currentAgent,
144
+ agents,
145
+ workboard: {
146
+ create: workboard.create.bind(workboard),
147
+ restore: workboard.restore.bind(workboard),
148
+ send: workboard.send.bind(workboard),
149
+ setStatus: workboard.setStatus.bind(workboard),
150
+ review: workboard.review.bind(workboard),
151
+ kill: workboard.kill.bind(workboard),
152
+ attach: attachSession
153
+ },
154
+ setSelected,
155
+ setAgentIndex,
156
+ setMessage,
157
+ setReview: (title, content) => setReview({ title, content }),
158
+ exit
159
+ });
160
+ }
161
+ else if (selectedSession?.exists) {
162
+ workboard.send(selectedSession.id, value);
163
+ setMessage(`sent to ${selectedSession.id}`);
164
+ }
165
+ else {
166
+ const created = workboard.create(currentAgent, value);
167
+ setMessage(`created ${created.id}`);
168
+ }
169
+ refresh();
170
+ }
171
+ catch (error) {
172
+ setMessage(error instanceof Error ? error.message : String(error));
173
+ }
174
+ }, [agents, attachSession, buffer.text, currentAgent, exit, history, refresh, selected, selectedSession, visibleSessions, workboard]);
175
+ const openSelected = useCallback(() => {
176
+ const session = selectedSession;
177
+ if (!session)
178
+ return;
179
+ attachSession(session.id);
180
+ }, [attachSession, selectedSession]);
181
+ const killSelected = useCallback(() => {
182
+ const session = selectedSession;
183
+ if (!session)
184
+ return;
185
+ workboard.kill(session.id);
186
+ setMessage(`stopped ${session.id}`);
187
+ refresh();
188
+ }, [refresh, selectedSession, workboard]);
189
+ const applySlashCommand = useCallback((item) => {
190
+ if (item.kind === "history") {
191
+ const nextHistory = discoverHistory();
192
+ setHistory(nextHistory);
193
+ setHistoryOpen(true);
194
+ setBuffer(EMPTY_BUFFER);
195
+ setMessage(nextHistory.length > 0 ? "history browser opened" : "no history sessions found");
196
+ return;
197
+ }
198
+ if (item.insertText) {
199
+ setBuffer({ text: item.insertText, cursor: item.insertText.length });
200
+ return;
201
+ }
202
+ setBuffer({ text: item.label, cursor: item.label.length });
203
+ }, []);
204
+ useTerminalInput((value, key) => {
205
+ if (historyOpen) {
206
+ if (key.escape) {
207
+ setHistoryOpen(false);
208
+ setMessage("history browser closed");
209
+ return;
210
+ }
211
+ if (key.upArrow || value === "k") {
212
+ setHistoryIndex((index) => Math.max(0, index - 1));
213
+ return;
214
+ }
215
+ if (key.downArrow || value === "j") {
216
+ setHistoryIndex((index) => Math.min(Math.max(0, history.length - 1), index + 1));
217
+ return;
218
+ }
219
+ if (key.return) {
220
+ restoreHistoryAt(historyIndex);
221
+ return;
222
+ }
223
+ if (key.ctrl && value === "c") {
224
+ setHistoryOpen(false);
225
+ setMessage("history browser closed");
226
+ return;
227
+ }
228
+ return;
229
+ }
230
+ if (key.ctrl && value === "c") {
231
+ const decision = decideCtrlC(buffer.text, pendingExitAt, Date.now());
232
+ setMessage(decision.message);
233
+ setPendingExitAt(decision.pendingExitAt);
234
+ if (decision.action === "clearPrompt") {
235
+ setBuffer(EMPTY_BUFFER);
236
+ }
237
+ else if (decision.action === "exit") {
238
+ exit();
239
+ }
240
+ return;
241
+ }
242
+ setPendingExitAt(null);
243
+ if (key.escape) {
244
+ setBuffer(EMPTY_BUFFER);
245
+ setMenuIndex(0);
246
+ setReview(null);
247
+ setHistoryOpen(false);
248
+ return;
249
+ }
250
+ if (key.ctrl && value === "n") {
251
+ setBuffer({ text: `/new ${currentAgent} `, cursor: `/new ${currentAgent} `.length });
252
+ return;
253
+ }
254
+ if (key.ctrl && value === "o") {
255
+ openSelected();
256
+ return;
257
+ }
258
+ if (key.ctrl && value === "x") {
259
+ killSelected();
260
+ return;
261
+ }
262
+ if (showSlashMenu && key.tab) {
263
+ setMenuIndex((index) => (index + 1) % slashMenu.length);
264
+ return;
265
+ }
266
+ if (!showSlashMenu && key.tab) {
267
+ setAgentIndex((index) => (index + 1) % Math.max(1, agents.length));
268
+ return;
269
+ }
270
+ if (key.return) {
271
+ if (showSlashMenu && slashMenu[menuIndex] && !key.shift) {
272
+ if (isExactSlashCommand(buffer.text, slashMenu[menuIndex])) {
273
+ submit();
274
+ return;
275
+ }
276
+ applySlashCommand(slashMenu[menuIndex]);
277
+ return;
278
+ }
279
+ if (key.shift) {
280
+ setBuffer((current) => insertText(current, "\n"));
281
+ return;
282
+ }
283
+ submit();
284
+ return;
285
+ }
286
+ if (key.backspace) {
287
+ setBuffer((current) => backspace(current));
288
+ return;
289
+ }
290
+ if (key.delete) {
291
+ setBuffer((current) => deleteForward(current));
292
+ return;
293
+ }
294
+ if (key.ctrl && value === "w") {
295
+ setBuffer((current) => deleteWordBefore(current));
296
+ return;
297
+ }
298
+ if (key.ctrl && value === "k") {
299
+ setBuffer((current) => killLine(current));
300
+ return;
301
+ }
302
+ if (key.home || (key.ctrl && value === "a")) {
303
+ setBuffer((current) => moveLineStart(current));
304
+ return;
305
+ }
306
+ if (key.end || (key.ctrl && value === "e")) {
307
+ setBuffer((current) => moveLineEnd(current));
308
+ return;
309
+ }
310
+ if (key.leftArrow) {
311
+ setBuffer((current) => (key.ctrl || key.meta ? moveWordLeft(current) : moveLeft(current)));
312
+ return;
313
+ }
314
+ if (key.rightArrow) {
315
+ setBuffer((current) => (key.ctrl || key.meta ? moveWordRight(current) : moveRight(current)));
316
+ return;
317
+ }
318
+ if (showSlashMenu && key.upArrow) {
319
+ setMenuIndex((index) => (index - 1 + slashMenu.length) % slashMenu.length);
320
+ return;
321
+ }
322
+ if (showSlashMenu && key.downArrow) {
323
+ setMenuIndex((index) => (index + 1) % slashMenu.length);
324
+ return;
325
+ }
326
+ if (key.upArrow && buffer.text.includes("\n")) {
327
+ setBuffer((current) => moveUp(current));
328
+ return;
329
+ }
330
+ if (key.downArrow && buffer.text.includes("\n")) {
331
+ setBuffer((current) => moveDown(current));
332
+ return;
333
+ }
334
+ if ((key.upArrow || value === "k") && buffer.text.length === 0) {
335
+ setSelected((index) => Math.max(0, index - 1));
336
+ return;
337
+ }
338
+ if ((key.downArrow || value === "j") && buffer.text.length === 0) {
339
+ setSelected((index) => Math.min(Math.max(0, visibleSessions.length - 1), index + 1));
340
+ return;
341
+ }
342
+ if (value >= " " && !key.ctrl && !key.meta) {
343
+ setBuffer((current) => insertText(current, value));
344
+ }
345
+ }, { isActive: !attaching });
346
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Agent Workboard" }), _jsxs(Text, { color: "gray", children: [" tmux router / target: ", currentAgent, " / Tab cycles agent"] })] }), _jsx(Box, { flexDirection: "column", minHeight: 18, children: rows.length === 0 ? (_jsx(Text, { color: "gray", children: "No sessions yet. Type a task and press enter to create one." })) : (rows.map((row, rowIndex) => {
347
+ if (row.kind === "heading") {
348
+ return _jsx(StatusHeading, { status: row.status }, `heading-${row.status}-${rowIndex}`);
349
+ }
350
+ return (_jsx(SessionRow, { session: row.session, agentLabel: config.agents[row.session.agent]?.label ?? row.session.agent, selected: row.index === selected }, row.session.id));
351
+ })) }), historyOpen ? (_jsx(HistoryPicker, { history: history, selected: historyIndex })) : (_jsx(CurrentSessionPane, { session: selectedSession })), review ? _jsx(ReviewPane, { title: review.title, content: review.content }) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "─".repeat(Math.max(20, stdout.columns || 80)) }) }), showSlashMenu ? _jsx(SlashCommandMenu, { items: slashMenu, activeIndex: menuIndex, width: stdout.columns || 80 }) : null, _jsx(PromptMeta, { session: selectedSession, fallbackAgent: currentAgent }), _jsxs(Box, { children: [_jsx(PromptPrefix, { working: selectedWorking, spinnerIndex: spinnerIndex }), _jsx(PromptText, { buffer: buffer, placeholder: "describe a task or type / for commands" })] }), _jsx(Box, { children: _jsx(Text, { color: "gray", children: "─".repeat(Math.max(20, stdout.columns || 80)) }) }), _jsxs(Text, { color: "gray", children: ["enter send/create \u00B7 shift+enter newline \u00B7 / commands \u00B7 arrows edit/select \u00B7 ctrl+x stop", message ? ` · ${message}` : ""] })] }));
352
+ }
353
+ function StatusHeading({ status }) {
354
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: status === "Working", color: status === "Working" ? "white" : "gray", children: status }) }));
355
+ }
356
+ function SessionRow({ session, agentLabel, selected }) {
357
+ const content = `${iconFor(session.status)} ${session.id.padEnd(30)} ${agentLabel.padEnd(13)} ${session.summary}`;
358
+ return (_jsxs(Box, { children: [_jsx(Text, { inverse: selected, bold: selected, color: selected ? "white" : colorFor(session.status), children: content }), _jsxs(Text, { inverse: selected, color: "gray", children: [" ", session.ageLabel] })] }));
359
+ }
360
+ function CurrentSessionPane({ session }) {
361
+ const title = session ? `${session.id} · ${session.status}` : "No session selected";
362
+ const lines = session?.latestMessageLines.length ? session.latestMessageLines : [session?.summary || "No current session message."];
363
+ const maxLines = 8;
364
+ const visibleLines = lines.slice(0, maxLines);
365
+ const truncated = lines.length > maxLines;
366
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", marginTop: 1, paddingX: 1, children: [_jsx(Text, { bold: true, children: "Current Session" }), _jsx(Text, { color: "gray", children: title }), visibleLines.map((line, index) => (_jsx(Text, { wrap: "truncate-end", children: line || " " }, `${index}-${line}`))), _jsx(Text, { color: "gray", children: truncated ? "… truncated · Ctrl+O or /open to view full tmux session." : "Ctrl+O or /open to view tmux session · /history for JSONL history." })] }));
367
+ }
368
+ function HistoryPicker({ history, selected }) {
369
+ const maxVisible = 10;
370
+ const visibleStart = Math.min(Math.max(0, selected - Math.floor((maxVisible - 1) / 2)), Math.max(0, history.length - maxVisible));
371
+ const visible = history.slice(visibleStart, visibleStart + maxVisible);
372
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", marginTop: 1, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "Discovered History" }), _jsx(Text, { color: "gray", children: "\u2191\u2193 select \u00B7 Enter resume \u00B7 Esc close" })] }), history.length === 0 ? (_jsx(Text, { color: "gray", children: "No JSONL history sessions found." })) : (visible.map((entry, index) => {
373
+ const actualIndex = visibleStart + index;
374
+ const active = actualIndex === selected;
375
+ return (_jsxs(Box, { children: [_jsxs(Text, { inverse: active, color: entry.agent === "codex" ? "cyan" : "magenta", children: [active ? "› " : " ", entry.agent.padEnd(7)] }), _jsxs(Text, { inverse: active, color: "gray", children: [" ", entry.id.slice(0, 8).padEnd(10)] }), _jsx(Text, { inverse: active, wrap: "truncate-end", children: entry.title }), _jsxs(Text, { inverse: active, color: "gray", children: [" ", entry.ageLabel] })] }, `${entry.agent}-${entry.id}`));
376
+ })), history.length > 0 ? (_jsxs(Text, { color: "gray", children: [selected + 1, "/", history.length] })) : null] }));
377
+ }
378
+ function ReviewPane({ title, content }) {
379
+ const lines = content.split(/\r?\n/).slice(-18);
380
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", marginTop: 1, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, children: ["Review ", title] }), _jsx(Text, { color: "gray", children: "Esc clears prompt \u00B7 /review refreshes" })] }), lines.map((line, index) => (_jsx(Text, { color: line.trim() ? undefined : "gray", wrap: "truncate-end", children: line || " " }, `${index}-${line}`))), _jsx(Box, { children: _jsx(Text, { color: "gray", children: "Type /open to attach \u00B7 Esc closes preview." }) })] }));
381
+ }
382
+ function PromptText({ buffer, placeholder }) {
383
+ if (buffer.text.length === 0) {
384
+ return (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { color: "gray", children: placeholder })] }));
385
+ }
386
+ const before = buffer.text.slice(0, buffer.cursor);
387
+ const cursorChar = buffer.text[buffer.cursor] ?? " ";
388
+ const after = buffer.text.slice(buffer.cursor + (buffer.cursor < buffer.text.length ? 1 : 0));
389
+ return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: cursorChar }), after] }));
390
+ }
391
+ function PromptPrefix({ working, spinnerIndex }) {
392
+ if (working) {
393
+ return (_jsxs(Text, { bold: true, color: "yellow", children: [SPINNER_FRAMES[spinnerIndex] ?? SPINNER_FRAMES[0], " "] }));
394
+ }
395
+ return (_jsxs(Text, { bold: true, color: "green", children: ["\u203A", " "] }));
396
+ }
397
+ function PromptMeta({ session, fallbackAgent }) {
398
+ if (session?.exists) {
399
+ return (_jsxs(Text, { color: "gray", children: ["session: ", session.id, " agent: ", session.agent] }));
400
+ }
401
+ return _jsxs(Text, { color: "gray", children: ["session: new agent: ", fallbackAgent] });
402
+ }
403
+ export function buildRows(sessions) {
404
+ const rows = [];
405
+ let displayIndex = 0;
406
+ for (const status of orderedStatuses()) {
407
+ const group = sessions.filter((session) => session.status === status);
408
+ if (group.length === 0)
409
+ continue;
410
+ rows.push({ kind: "heading", status });
411
+ for (const session of group) {
412
+ rows.push({ kind: "session", session, index: displayIndex });
413
+ displayIndex += 1;
414
+ }
415
+ }
416
+ return rows;
417
+ }
418
+ export function buildVisibleSessions(sessions) {
419
+ return orderedStatuses().flatMap((status) => sessions.filter((session) => session.status === status));
420
+ }
421
+ function iconFor(status) {
422
+ if (status === "Needs input")
423
+ return "*";
424
+ if (status === "Rate limited")
425
+ return "!";
426
+ if (status === "Completed")
427
+ return "*";
428
+ if (status === "Return pending")
429
+ return "*";
430
+ if (status === "human handoff")
431
+ return "!";
432
+ if (status === "agent routing")
433
+ return ">";
434
+ return "+";
435
+ }
436
+ function colorFor(status) {
437
+ if (status === "Needs input")
438
+ return "yellow";
439
+ if (status === "Rate limited")
440
+ return "red";
441
+ if (status === "Completed")
442
+ return "green";
443
+ if (status === "Return pending")
444
+ return "magenta";
445
+ if (status === "human handoff")
446
+ return "red";
447
+ if (status === "agent routing")
448
+ return "cyan";
449
+ return "gray";
450
+ }
@@ -0,0 +1,194 @@
1
+ import { ageLabel, extractLatestMessageLines, inferStatus, initializationBlockedMessage, isAgentInitializationBlocked, summarize } from "./status.js";
2
+ import { GlobalStateStore } from "./globalState.js";
3
+ export class Workboard {
4
+ cwd;
5
+ config;
6
+ store;
7
+ tmux;
8
+ globalState;
9
+ constructor(cwd, config, store, tmux, globalState = new GlobalStateStore()) {
10
+ this.cwd = cwd;
11
+ this.config = config;
12
+ this.store = store;
13
+ this.tmux = tmux;
14
+ this.globalState = globalState;
15
+ }
16
+ list() {
17
+ return this.store.read().map((session) => {
18
+ const exists = this.tmux.hasSession(session.tmuxSession);
19
+ const capture = exists ? this.tmux.capture(session.tmuxSession) : "";
20
+ const initializationBlocked = isAgentInitializationBlocked(capture);
21
+ const status = session.statusOverride ?? inferStatus(capture, exists);
22
+ return {
23
+ ...session,
24
+ exists,
25
+ status,
26
+ summary: initializationBlocked ? initializationBlockedMessage() : capture.trim() ? summarize(capture) : session.title,
27
+ latestMessageLines: initializationBlocked
28
+ ? [initializationBlockedMessage()]
29
+ : capture.trim()
30
+ ? extractLatestMessageLines(capture)
31
+ : [],
32
+ ageLabel: ageLabel(session.updatedAt)
33
+ };
34
+ });
35
+ }
36
+ create(agent, task) {
37
+ const agentConfig = this.config.agents[agent];
38
+ if (!agentConfig) {
39
+ throw new Error(`Unknown agent "${agent}". Known agents: ${Object.keys(this.config.agents).join(", ")}`);
40
+ }
41
+ const id = makeId(task);
42
+ const tmuxSession = `${this.config.tmuxPrefix}-${id}`;
43
+ const now = new Date().toISOString();
44
+ const record = {
45
+ id,
46
+ title: task.slice(0, 80) || id,
47
+ agent,
48
+ tmuxSession,
49
+ createdAt: now,
50
+ updatedAt: now
51
+ };
52
+ if (!this.tmux.hasSession(tmuxSession)) {
53
+ this.tmux.newSession(tmuxSession, this.cwd, agentConfig.command);
54
+ }
55
+ this.store.upsert(record);
56
+ const startupCapture = waitForStartupCapture(this.tmux, tmuxSession);
57
+ if (isAgentInitializationBlocked(startupCapture)) {
58
+ this.store.upsert({ ...record, statusOverride: "Needs input" });
59
+ return record;
60
+ }
61
+ this.tmux.sendKeys(tmuxSession, task);
62
+ return record;
63
+ }
64
+ restore(history) {
65
+ const agentConfig = this.config.agents[history.agent];
66
+ if (!agentConfig) {
67
+ throw new Error(`Unknown agent "${history.agent}". Known agents: ${Object.keys(this.config.agents).join(", ")}`);
68
+ }
69
+ const mapping = this.globalState.findByHistoryId(history.id);
70
+ const existing = this.store.read().find((session) => session.historyId === history.id);
71
+ const now = new Date().toISOString();
72
+ const record = existing ?? {
73
+ id: mapping?.sessionId ?? `${history.agent}-${safeId(history.id)}`,
74
+ title: history.title,
75
+ agent: history.agent,
76
+ tmuxSession: mapping?.tmuxSession ?? `${this.config.tmuxPrefix}-${history.agent}-${safeId(history.id)}`,
77
+ createdAt: mapping?.createdAt ?? now,
78
+ updatedAt: now,
79
+ historyId: history.id,
80
+ historyPath: history.sourcePath
81
+ };
82
+ if (!this.tmux.hasSession(record.tmuxSession)) {
83
+ this.tmux.newSession(record.tmuxSession, history.cwd ?? this.cwd, renderResumeCommand(agentConfig.resumeCommand ?? agentConfig.command, history));
84
+ }
85
+ const next = {
86
+ ...record,
87
+ title: history.title,
88
+ updatedAt: now,
89
+ historyId: history.id,
90
+ historyPath: history.sourcePath,
91
+ statusOverride: undefined
92
+ };
93
+ this.store.upsert(next);
94
+ this.globalState.upsertMapping({
95
+ historyId: history.id,
96
+ historyPath: history.sourcePath,
97
+ agent: history.agent,
98
+ sessionId: next.id,
99
+ tmuxSession: next.tmuxSession,
100
+ cwd: history.cwd ?? this.cwd,
101
+ title: history.title,
102
+ createdAt: next.createdAt,
103
+ updatedAt: now
104
+ });
105
+ return next;
106
+ }
107
+ send(id, input) {
108
+ const session = this.find(id);
109
+ if (!session)
110
+ throw new Error(`Unknown session "${id}".`);
111
+ if (!this.tmux.hasSession(session.tmuxSession))
112
+ throw new Error(`tmux session "${session.tmuxSession}" is not running.`);
113
+ const capture = this.tmux.capture(session.tmuxSession);
114
+ if (isAgentInitializationBlocked(capture)) {
115
+ this.store.upsert({ ...session, updatedAt: new Date().toISOString(), statusOverride: "Needs input" });
116
+ throw new Error(initializationBlockedMessage());
117
+ }
118
+ this.tmux.sendKeys(session.tmuxSession, input);
119
+ this.store.upsert({ ...session, updatedAt: new Date().toISOString(), statusOverride: undefined });
120
+ }
121
+ setStatus(id, status) {
122
+ const session = this.find(id);
123
+ if (!session)
124
+ throw new Error(`Unknown session "${id}".`);
125
+ this.store.upsert({ ...session, statusOverride: status, updatedAt: new Date().toISOString() });
126
+ }
127
+ kill(id) {
128
+ const session = this.find(id);
129
+ if (!session)
130
+ return;
131
+ this.tmux.kill(session.tmuxSession);
132
+ this.store.upsert({ ...session, updatedAt: new Date().toISOString(), statusOverride: "Completed" });
133
+ }
134
+ attach(id) {
135
+ const session = this.find(id);
136
+ if (!session)
137
+ throw new Error(`Unknown session "${id}".`);
138
+ this.tmux.attach(session.tmuxSession);
139
+ }
140
+ review(id, lines = 30) {
141
+ const session = this.find(id);
142
+ if (!session)
143
+ throw new Error(`Unknown session "${id}".`);
144
+ if (!this.tmux.hasSession(session.tmuxSession)) {
145
+ throw new Error(`tmux session "${session.tmuxSession}" is not running.`);
146
+ }
147
+ return this.tmux.capture(session.tmuxSession, lines);
148
+ }
149
+ find(id) {
150
+ const sessions = this.store.read();
151
+ return sessions.find((session) => session.id === id || session.tmuxSession === id || session.title === id);
152
+ }
153
+ }
154
+ function renderResumeCommand(template, history) {
155
+ return template
156
+ .replaceAll("{sessionId}", shellQuote(history.id))
157
+ .replaceAll("{sourcePath}", shellQuote(history.sourcePath))
158
+ .replaceAll("{cwd}", shellQuote(history.cwd ?? ""))
159
+ .replaceAll("{title}", shellQuote(history.title));
160
+ }
161
+ function makeId(input) {
162
+ const base = input
163
+ .toLowerCase()
164
+ .replace(/[^a-z0-9]+/g, "-")
165
+ .replace(/^-+|-+$/g, "")
166
+ .slice(0, 28);
167
+ const suffix = Math.random().toString(36).slice(2, 6);
168
+ return `${base || "session"}-${suffix}`;
169
+ }
170
+ function safeId(input) {
171
+ return input
172
+ .toLowerCase()
173
+ .replace(/[^a-z0-9]+/g, "-")
174
+ .replace(/^-+|-+$/g, "")
175
+ .slice(0, 36) || "history";
176
+ }
177
+ function shellQuote(input) {
178
+ return `'${input.replaceAll("'", "'\\''")}'`;
179
+ }
180
+ function waitForStartupCapture(tmux, tmuxSession) {
181
+ let capture = "";
182
+ for (let attempt = 0; attempt < 8; attempt++) {
183
+ capture = tmux.capture(tmuxSession, 40);
184
+ if (isAgentInitializationBlocked(capture))
185
+ return capture;
186
+ if (capture.trim())
187
+ return capture;
188
+ sleep(100);
189
+ }
190
+ return capture;
191
+ }
192
+ function sleep(ms) {
193
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
194
+ }