@towles/tool 0.0.112 → 0.0.113
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 +1 -1
- package/packages/agentboard/apps/tui/src/components/SessionCard.tsx +2 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.test.ts +7 -7
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.ts +10 -7
- package/packages/agentboard/packages/runtime/src/server/index.ts +33 -8
- package/packages/agentboard/packages/runtime/src/server/pane-scanner.ts +10 -3
- package/src/commands/agentboard.ts +27 -2
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
33
33
|
if (s === "error") return P().red;
|
|
34
34
|
if (s === "interrupted") return P().peach;
|
|
35
35
|
if (s === "running") return P().yellow;
|
|
36
|
+
if (s === "waiting") return P().blue;
|
|
36
37
|
if (props.isFocused) return P().lavender;
|
|
37
38
|
return "transparent";
|
|
38
39
|
};
|
|
@@ -47,6 +48,7 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
47
48
|
const statusIcon = () => {
|
|
48
49
|
const s = status();
|
|
49
50
|
if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
|
|
51
|
+
if (s === "waiting") return "◉";
|
|
50
52
|
if (isUnseenTerminal()) return UNSEEN_ICON;
|
|
51
53
|
return "";
|
|
52
54
|
};
|
|
@@ -2,13 +2,13 @@ import { describe, it, expect } from "bun:test";
|
|
|
2
2
|
import { determineStatus } from "./claude-code";
|
|
3
3
|
|
|
4
4
|
describe("determineStatus", () => {
|
|
5
|
-
it(
|
|
6
|
-
expect(determineStatus({})).
|
|
7
|
-
expect(determineStatus({ message: undefined })).
|
|
5
|
+
it("returns null when no message", () => {
|
|
6
|
+
expect(determineStatus({})).toBeNull();
|
|
7
|
+
expect(determineStatus({ message: undefined })).toBeNull();
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
it(
|
|
11
|
-
expect(determineStatus({ message: { content: "hi" } })).
|
|
10
|
+
it("returns null when message has no role", () => {
|
|
11
|
+
expect(determineStatus({ message: { content: "hi" } })).toBeNull();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
it('returns "running" for user messages', () => {
|
|
@@ -53,11 +53,11 @@ describe("determineStatus", () => {
|
|
|
53
53
|
).toBe("done");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it(
|
|
56
|
+
it("returns null for unknown roles", () => {
|
|
57
57
|
expect(
|
|
58
58
|
determineStatus({
|
|
59
59
|
message: { role: "system", content: "system message" },
|
|
60
60
|
}),
|
|
61
|
-
).
|
|
61
|
+
).toBeNull();
|
|
62
62
|
});
|
|
63
63
|
});
|
|
@@ -47,9 +47,9 @@ const STALE_MS = 5 * 60 * 1000;
|
|
|
47
47
|
|
|
48
48
|
// --- Status detection ---
|
|
49
49
|
|
|
50
|
-
export function determineStatus(entry: JournalEntry): AgentStatus {
|
|
50
|
+
export function determineStatus(entry: JournalEntry): AgentStatus | null {
|
|
51
51
|
const msg = entry.message;
|
|
52
|
-
if (!msg?.role) return
|
|
52
|
+
if (!msg?.role) return null;
|
|
53
53
|
|
|
54
54
|
const content = msg.content;
|
|
55
55
|
const items: ContentItem[] = Array.isArray(content)
|
|
@@ -59,13 +59,16 @@ export function determineStatus(entry: JournalEntry): AgentStatus {
|
|
|
59
59
|
: [];
|
|
60
60
|
|
|
61
61
|
if (msg.role === "assistant") {
|
|
62
|
-
const
|
|
63
|
-
|
|
62
|
+
const toolUses = items.filter((c) => c.type === "tool_use");
|
|
63
|
+
if (toolUses.length === 0) return "done";
|
|
64
|
+
// AskUserQuestion means the agent is waiting for user input, not running
|
|
65
|
+
const allWaiting = toolUses.every((c) => (c as any).name === "AskUserQuestion");
|
|
66
|
+
return allWaiting ? "waiting" : "running";
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
if (msg.role === "user") return "running";
|
|
67
70
|
|
|
68
|
-
return
|
|
71
|
+
return null;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
function extractThreadName(entry: JournalEntry): string | undefined {
|
|
@@ -195,7 +198,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
195
198
|
const name = extractThreadName(entry);
|
|
196
199
|
if (name) threadName = name;
|
|
197
200
|
}
|
|
198
|
-
latestStatus = determineStatus(entry);
|
|
201
|
+
latestStatus = determineStatus(entry) ?? latestStatus;
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
// If "running" but journal file is stale, the process likely exited
|
|
@@ -238,7 +241,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
|
|
|
238
241
|
if (name) threadName = name;
|
|
239
242
|
}
|
|
240
243
|
|
|
241
|
-
latestStatus = determineStatus(entry);
|
|
244
|
+
latestStatus = determineStatus(entry) ?? latestStatus;
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
const prevStatus = prev?.status;
|
|
@@ -187,6 +187,22 @@ export function startServer(
|
|
|
187
187
|
return null;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** If any pane agent is alive for this session, override terminal agentState to waiting. */
|
|
191
|
+
function overrideTerminalIfPaneAlive(
|
|
192
|
+
sessionName: string,
|
|
193
|
+
state: AgentEvent | null,
|
|
194
|
+
): AgentEvent | null {
|
|
195
|
+
if (!state || !TERMINAL_STATUSES.has(state.status)) return state;
|
|
196
|
+
const paneAgents = paneAgentsBySession.get(sessionName);
|
|
197
|
+
if (!paneAgents || paneAgents.size === 0) return state;
|
|
198
|
+
for (const presence of paneAgents.values()) {
|
|
199
|
+
if (presence.agent === state.agent && presence.threadId === state.threadId) {
|
|
200
|
+
return { ...state, status: "waiting" };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return state;
|
|
204
|
+
}
|
|
205
|
+
|
|
190
206
|
/** Merge pane-detected agents into watcher-provided agents for a session.
|
|
191
207
|
* Watcher events take precedence — pane presence only adds synthetic entries
|
|
192
208
|
* for agents that aren't already tracked by watchers. */
|
|
@@ -205,12 +221,11 @@ export function startServer(
|
|
|
205
221
|
const trackedIdx = trackedByKey.get(key);
|
|
206
222
|
|
|
207
223
|
if (trackedIdx != null) {
|
|
208
|
-
// Watcher already tracks this agent —
|
|
209
|
-
//
|
|
210
|
-
// journal status is a between-turn artifact).
|
|
224
|
+
// Watcher already tracks this agent — process is confirmed alive,
|
|
225
|
+
// so terminal journal status means it's waiting for user input.
|
|
211
226
|
const tracked = result[trackedIdx]!;
|
|
212
227
|
if (TERMINAL_STATUSES.has(tracked.status)) {
|
|
213
|
-
tracked.status = "
|
|
228
|
+
tracked.status = "waiting";
|
|
214
229
|
tracked.paneId = presence.paneId;
|
|
215
230
|
}
|
|
216
231
|
continue;
|
|
@@ -298,7 +313,7 @@ export function startServer(
|
|
|
298
313
|
ports: getSessionPorts(name),
|
|
299
314
|
windows,
|
|
300
315
|
uptime,
|
|
301
|
-
agentState: tracker.getState(name),
|
|
316
|
+
agentState: overrideTerminalIfPaneAlive(name, tracker.getState(name)),
|
|
302
317
|
agents: mergeAgentsWithPanePresence(name, tracker.getAgents(name)),
|
|
303
318
|
eventTimestamps: tracker.getEventTimestamps(name),
|
|
304
319
|
metadata: metadataStore.get(name),
|
|
@@ -1079,8 +1094,12 @@ export function startServer(
|
|
|
1079
1094
|
const data = JSON.parse(readFileSync(join(sessionsDir, `${agentPid}.json`), "utf-8"));
|
|
1080
1095
|
const threadId: string | undefined = data.sessionId;
|
|
1081
1096
|
if (!threadId) return {};
|
|
1082
|
-
// Try to get thread name and status from the journal
|
|
1083
1097
|
const journalInfo = resolveClaudeCodeJournalInfo(threadId);
|
|
1098
|
+
// Process is alive (found via process tree), so terminal journal status
|
|
1099
|
+
// means it's waiting for user input.
|
|
1100
|
+
if (journalInfo.status && TERMINAL_STATUSES.has(journalInfo.status)) {
|
|
1101
|
+
journalInfo.status = "waiting";
|
|
1102
|
+
}
|
|
1084
1103
|
return { threadId, ...journalInfo };
|
|
1085
1104
|
} catch {
|
|
1086
1105
|
return {};
|
|
@@ -1119,10 +1138,16 @@ export function startServer(
|
|
|
1119
1138
|
if (t && !t.startsWith("<") && !t.startsWith("{")) threadName = t.slice(0, 80);
|
|
1120
1139
|
}
|
|
1121
1140
|
|
|
1122
|
-
// Determine status from last entry (same logic as ClaudeCodeAgentWatcher)
|
|
1123
1141
|
if (msg.role === "assistant") {
|
|
1124
1142
|
const items = Array.isArray(msg.content) ? msg.content : [];
|
|
1125
|
-
|
|
1143
|
+
const toolUses = items.filter((c: any) => c.type === "tool_use");
|
|
1144
|
+
if (toolUses.length === 0) {
|
|
1145
|
+
lastStatus = "done";
|
|
1146
|
+
} else {
|
|
1147
|
+
lastStatus = toolUses.every((c: any) => c.name === "AskUserQuestion")
|
|
1148
|
+
? "waiting"
|
|
1149
|
+
: "running";
|
|
1150
|
+
}
|
|
1126
1151
|
} else if (msg.role === "user") {
|
|
1127
1152
|
lastStatus = "running";
|
|
1128
1153
|
}
|
|
@@ -100,9 +100,9 @@ function resolveClaudeCodePaneInfo(
|
|
|
100
100
|
if (!threadId) return {};
|
|
101
101
|
const journalInfo = resolveClaudeCodeJournalInfo(threadId);
|
|
102
102
|
// Process is alive (found via process tree), so terminal journal status
|
|
103
|
-
//
|
|
103
|
+
// means it's waiting for user input.
|
|
104
104
|
if (journalInfo.status && TERMINAL_STATUSES.has(journalInfo.status)) {
|
|
105
|
-
journalInfo.status = "
|
|
105
|
+
journalInfo.status = "waiting";
|
|
106
106
|
}
|
|
107
107
|
return { threadId, ...journalInfo };
|
|
108
108
|
} catch {
|
|
@@ -143,7 +143,14 @@ function resolveClaudeCodeJournalInfo(threadId: string): {
|
|
|
143
143
|
|
|
144
144
|
if (msg.role === "assistant") {
|
|
145
145
|
const items = Array.isArray(msg.content) ? msg.content : [];
|
|
146
|
-
|
|
146
|
+
const toolUses = items.filter((c: any) => c.type === "tool_use");
|
|
147
|
+
if (toolUses.length === 0) {
|
|
148
|
+
lastStatus = "done";
|
|
149
|
+
} else {
|
|
150
|
+
lastStatus = toolUses.every((c: any) => c.name === "AskUserQuestion")
|
|
151
|
+
? "waiting"
|
|
152
|
+
: "running";
|
|
153
|
+
}
|
|
147
154
|
} else if (msg.role === "user") {
|
|
148
155
|
lastStatus = "running";
|
|
149
156
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import consola from "consola";
|
|
6
6
|
import { colors } from "consola/utils";
|
|
@@ -186,6 +186,23 @@ async function serverAlive(): Promise<boolean> {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
const PID_FILE = "/tmp/agentboard.pid";
|
|
190
|
+
|
|
191
|
+
function stopServer(): boolean {
|
|
192
|
+
if (!existsSync(PID_FILE)) return false;
|
|
193
|
+
const pid = Number.parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
|
|
194
|
+
if (Number.isNaN(pid)) return false;
|
|
195
|
+
try {
|
|
196
|
+
process.kill(pid, "SIGTERM");
|
|
197
|
+
} catch {
|
|
198
|
+
// process already dead
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
unlinkSync(PID_FILE);
|
|
202
|
+
} catch {}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
189
206
|
async function ensureServerUp(): Promise<boolean> {
|
|
190
207
|
if (await serverAlive()) return true;
|
|
191
208
|
|
|
@@ -374,7 +391,15 @@ async function restart(): Promise<void> {
|
|
|
374
391
|
// no tmux or no sessions
|
|
375
392
|
}
|
|
376
393
|
|
|
377
|
-
// 2.
|
|
394
|
+
// 2. Stop existing server, then start fresh
|
|
395
|
+
const wasStopped = stopServer();
|
|
396
|
+
if (wasStopped) {
|
|
397
|
+
// Wait for port to free up
|
|
398
|
+
for (let i = 0; i < 20; i++) {
|
|
399
|
+
if (!(await serverAlive())) break;
|
|
400
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
378
403
|
if (!(await ensureServerUp())) {
|
|
379
404
|
consola.error("Failed to start agentboard server");
|
|
380
405
|
process.exit(1);
|