@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.112",
3
+ "version": "0.0.113",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -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('returns "idle" when no message', () => {
6
- expect(determineStatus({})).toBe("idle");
7
- expect(determineStatus({ message: undefined })).toBe("idle");
5
+ it("returns null when no message", () => {
6
+ expect(determineStatus({})).toBeNull();
7
+ expect(determineStatus({ message: undefined })).toBeNull();
8
8
  });
9
9
 
10
- it('returns "idle" when message has no role', () => {
11
- expect(determineStatus({ message: { content: "hi" } })).toBe("idle");
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('returns "idle" for unknown roles', () => {
56
+ it("returns null for unknown roles", () => {
57
57
  expect(
58
58
  determineStatus({
59
59
  message: { role: "system", content: "system message" },
60
60
  }),
61
- ).toBe("idle");
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 "idle";
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 hasToolUse = items.some((c) => c.type === "tool_use");
63
- return hasToolUse ? "running" : "done";
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 "idle";
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 — correct terminal statuses
209
- // using pane liveness (process is confirmed alive, so terminal
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 = "running";
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
- lastStatus = items.some((c: any) => c.type === "tool_use") ? "running" : "done";
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
- // is a between-turn artifact override to running.
103
+ // means it's waiting for user input.
104
104
  if (journalInfo.status && TERMINAL_STATUSES.has(journalInfo.status)) {
105
- journalInfo.status = "running";
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
- lastStatus = items.some((c: any) => c.type === "tool_use") ? "running" : "done";
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. Ensure server is running
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);