@towles/tool 0.0.112 → 0.0.114

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.114",
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": {
@@ -252,6 +252,7 @@ function AgentListItem(props: AgentListItemProps) {
252
252
  return props.agent.status === "done" ? "✓" : props.agent.status === "error" ? "✗" : "⚠";
253
253
  if (props.agent.status === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
254
254
  if (props.agent.status === "waiting") return "◉";
255
+ if (props.agent.status === "question") return "?";
255
256
  return "○";
256
257
  };
257
258
 
@@ -270,6 +271,7 @@ function AgentListItem(props: AgentListItemProps) {
270
271
  if (props.agent.status === "error") return "error";
271
272
  if (props.agent.status === "interrupted") return "stopped";
272
273
  if (props.agent.status === "waiting") return "waiting";
274
+ if (props.agent.status === "question") return "question";
273
275
  return "";
274
276
  };
275
277
 
@@ -33,6 +33,8 @@ 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;
37
+ if (s === "question") return P().green;
36
38
  if (props.isFocused) return P().lavender;
37
39
  return "transparent";
38
40
  };
@@ -47,6 +49,8 @@ export function SessionCard(props: SessionCardProps) {
47
49
  const statusIcon = () => {
48
50
  const s = status();
49
51
  if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
52
+ if (s === "waiting") return "◉";
53
+ if (s === "question") return "?";
50
54
  if (isUnseenTerminal()) return UNSEEN_ICON;
51
55
  return "";
52
56
  };
@@ -7,7 +7,7 @@ export function computeSessionStatusCounts(sessions: SessionData[]): SessionStat
7
7
  let idle = 0;
8
8
  for (const s of sessions) {
9
9
  const status = s.agentState?.status;
10
- if (status === "running" || status === "waiting") {
10
+ if (status === "running" || status === "waiting" || status === "question") {
11
11
  active++;
12
12
  } else if (status === "error") {
13
13
  error++;
@@ -6,6 +6,7 @@ const TERMINAL_PRUNE_MS = 5 * 60 * 1000;
6
6
 
7
7
  const STATUS_PRIORITY: Record<string, number> = {
8
8
  running: 5,
9
+ question: 4,
9
10
  error: 4,
10
11
  interrupted: 3,
11
12
  waiting: 2,
@@ -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,15 @@ 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
+ const allAsking = toolUses.every((c) => (c as any).name === "AskUserQuestion");
65
+ return allAsking ? "question" : "running";
64
66
  }
65
67
 
66
68
  if (msg.role === "user") return "running";
67
69
 
68
- return "idle";
70
+ return null;
69
71
  }
70
72
 
71
73
  function extractThreadName(entry: JournalEntry): string | undefined {
@@ -195,7 +197,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
195
197
  const name = extractThreadName(entry);
196
198
  if (name) threadName = name;
197
199
  }
198
- latestStatus = determineStatus(entry);
200
+ latestStatus = determineStatus(entry) ?? latestStatus;
199
201
  }
200
202
 
201
203
  // If "running" but journal file is stale, the process likely exited
@@ -238,7 +240,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
238
240
  if (name) threadName = name;
239
241
  }
240
242
 
241
- latestStatus = determineStatus(entry);
243
+ latestStatus = determineStatus(entry) ?? latestStatus;
242
244
  }
243
245
 
244
246
  const prevStatus = prev?.status;
@@ -1,4 +1,4 @@
1
- export type AgentStatus = "idle" | "running" | "done" | "error" | "waiting" | "interrupted";
1
+ export type AgentStatus = "idle" | "running" | "done" | "error" | "waiting" | "question" | "interrupted";
2
2
 
3
3
  export interface AgentEvent {
4
4
  agent: string;
@@ -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
+ ? "question"
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
+ ? "question"
152
+ : "running";
153
+ }
147
154
  } else if (msg.role === "user") {
148
155
  lastStatus = "running";
149
156
  }
@@ -62,6 +62,7 @@ const CATPPUCCIN_MOCHA: Theme = {
62
62
  done: "#a6e3a1",
63
63
  error: "#f38ba8",
64
64
  waiting: "#89b4fa",
65
+ question: "#a6e3a1",
65
66
  interrupted: "#fab387",
66
67
  },
67
68
  icons: {
@@ -70,6 +71,7 @@ const CATPPUCCIN_MOCHA: Theme = {
70
71
  done: "✓",
71
72
  error: "✗",
72
73
  waiting: "◉",
74
+ question: "?",
73
75
  interrupted: "⚠",
74
76
  },
75
77
  };
@@ -104,6 +106,7 @@ const CATPPUCCIN_LATTE: Theme = {
104
106
  done: "#40a02b",
105
107
  error: "#d20f39",
106
108
  waiting: "#1e66f5",
109
+ question: "#40a02b",
107
110
  interrupted: "#fe640b",
108
111
  },
109
112
  icons: CATPPUCCIN_MOCHA.icons,
@@ -139,6 +142,7 @@ const TOKYO_NIGHT: Theme = {
139
142
  done: "#9ece6a",
140
143
  error: "#f7768e",
141
144
  waiting: "#7aa2f7",
145
+ question: "#9ece6a",
142
146
  interrupted: "#ff9e64",
143
147
  },
144
148
  icons: CATPPUCCIN_MOCHA.icons,
@@ -174,6 +178,7 @@ const GRUVBOX_DARK: Theme = {
174
178
  done: "#b8bb26",
175
179
  error: "#fb4934",
176
180
  waiting: "#83a598",
181
+ question: "#b8bb26",
177
182
  interrupted: "#fe8019",
178
183
  },
179
184
  icons: CATPPUCCIN_MOCHA.icons,
@@ -209,6 +214,7 @@ const NORD: Theme = {
209
214
  done: "#a3be8c",
210
215
  error: "#bf616a",
211
216
  waiting: "#81a1c1",
217
+ question: "#a3be8c",
212
218
  interrupted: "#d08770",
213
219
  },
214
220
  icons: CATPPUCCIN_MOCHA.icons,
@@ -244,6 +250,7 @@ const DRACULA: Theme = {
244
250
  done: "#50fa7b",
245
251
  error: "#ff5555",
246
252
  waiting: "#8be9fd",
253
+ question: "#50fa7b",
247
254
  interrupted: "#ffb86c",
248
255
  },
249
256
  icons: CATPPUCCIN_MOCHA.icons,
@@ -279,6 +286,7 @@ const CATPPUCCIN_FRAPPE: Theme = {
279
286
  done: "#a6d189",
280
287
  error: "#e78284",
281
288
  waiting: "#8da4e2",
289
+ question: "#a6d189",
282
290
  interrupted: "#ef9f76",
283
291
  },
284
292
  icons: CATPPUCCIN_MOCHA.icons,
@@ -314,6 +322,7 @@ const CATPPUCCIN_MACCHIATO: Theme = {
314
322
  done: "#a6da95",
315
323
  error: "#ed8796",
316
324
  waiting: "#8aadf4",
325
+ question: "#a6da95",
317
326
  interrupted: "#f5a97f",
318
327
  },
319
328
  icons: CATPPUCCIN_MOCHA.icons,
@@ -349,6 +358,7 @@ const GITHUB_DARK: Theme = {
349
358
  done: "#3fb950",
350
359
  error: "#f85149",
351
360
  waiting: "#58a6ff",
361
+ question: "#3fb950",
352
362
  interrupted: "#d29922",
353
363
  },
354
364
  icons: CATPPUCCIN_MOCHA.icons,
@@ -384,6 +394,7 @@ const ONE_DARK: Theme = {
384
394
  done: "#98c379",
385
395
  error: "#e06c75",
386
396
  waiting: "#61afef",
397
+ question: "#98c379",
387
398
  interrupted: "#d19a66",
388
399
  },
389
400
  icons: CATPPUCCIN_MOCHA.icons,
@@ -419,6 +430,7 @@ const KANAGAWA: Theme = {
419
430
  done: "#98BB6C",
420
431
  error: "#E82424",
421
432
  waiting: "#7E9CD8",
433
+ question: "#98BB6C",
422
434
  interrupted: "#FFA066",
423
435
  },
424
436
  icons: CATPPUCCIN_MOCHA.icons,
@@ -454,6 +466,7 @@ const EVERFOREST: Theme = {
454
466
  done: "#a7c080",
455
467
  error: "#e67e80",
456
468
  waiting: "#7fbbb3",
469
+ question: "#a7c080",
457
470
  interrupted: "#e69875",
458
471
  },
459
472
  icons: CATPPUCCIN_MOCHA.icons,
@@ -489,6 +502,7 @@ const MATERIAL: Theme = {
489
502
  done: "#c3e88d",
490
503
  error: "#f07178",
491
504
  waiting: "#82aaff",
505
+ question: "#c3e88d",
492
506
  interrupted: "#f78c6c",
493
507
  },
494
508
  icons: CATPPUCCIN_MOCHA.icons,
@@ -524,6 +538,7 @@ const COBALT2: Theme = {
524
538
  done: "#9eff80",
525
539
  error: "#ff0088",
526
540
  waiting: "#0088ff",
541
+ question: "#9eff80",
527
542
  interrupted: "#ff628c",
528
543
  },
529
544
  icons: CATPPUCCIN_MOCHA.icons,
@@ -559,6 +574,7 @@ const FLEXOKI: Theme = {
559
574
  done: "#879A39",
560
575
  error: "#D14D41",
561
576
  waiting: "#4385BE",
577
+ question: "#879A39",
562
578
  interrupted: "#DA702C",
563
579
  },
564
580
  icons: CATPPUCCIN_MOCHA.icons,
@@ -594,6 +610,7 @@ const AYU: Theme = {
594
610
  done: "#7FD962",
595
611
  error: "#D95757",
596
612
  waiting: "#59C2FF",
613
+ question: "#7FD962",
597
614
  interrupted: "#FF8F40",
598
615
  },
599
616
  icons: CATPPUCCIN_MOCHA.icons,
@@ -629,6 +646,7 @@ const AURA: Theme = {
629
646
  done: "#61ffca",
630
647
  error: "#ff6767",
631
648
  waiting: "#a277ff",
649
+ question: "#61ffca",
632
650
  interrupted: "#ffca85",
633
651
  },
634
652
  icons: CATPPUCCIN_MOCHA.icons,
@@ -664,6 +682,7 @@ const MATRIX: Theme = {
664
682
  done: "#62ff94",
665
683
  error: "#ff4b4b",
666
684
  waiting: "#30b3ff",
685
+ question: "#62ff94",
667
686
  interrupted: "#ffa83d",
668
687
  },
669
688
  icons: CATPPUCCIN_MOCHA.icons,
@@ -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);