@towles/tool 0.0.111 → 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.111",
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
  };
@@ -110,6 +110,16 @@ function App() {
110
110
  const [modal, setModal] = createSignal<"none" | "confirm-kill" | "help">("none");
111
111
  const [killTarget, setKillTarget] = createSignal<string | null>(null);
112
112
 
113
+ // --- Transient toast (footer) ---
114
+ type ToastTone = "error" | "info" | "success";
115
+ const [toast, setToast] = createSignal<{ message: string; tone: ToastTone } | null>(null);
116
+ let toastTimer: ReturnType<typeof setTimeout> | null = null;
117
+ function showToast(message: string, tone: ToastTone = "info") {
118
+ setToast({ message, tone });
119
+ if (toastTimer) clearTimeout(toastTimer);
120
+ toastTimer = setTimeout(() => setToast(null), 4000);
121
+ }
122
+
113
123
  const [clientTty, setClientTty] = createSignal(getClientTty(muxCtx));
114
124
  let ws: WebSocket | null = null;
115
125
  let startupFocusSynced = false;
@@ -119,8 +129,14 @@ function App() {
119
129
 
120
130
  const focusedData = createMemo(() => sessions.find((s) => s.name === focusedSession()) ?? null);
121
131
 
122
- function send(cmd: ClientCommand) {
123
- if (connected() && ws) ws.send(JSON.stringify(cmd));
132
+ function send(cmd: ClientCommand, successMsg?: string): boolean {
133
+ if (connected() && ws) {
134
+ ws.send(JSON.stringify(cmd));
135
+ if (successMsg) showToast(successMsg, "success");
136
+ return true;
137
+ }
138
+ showToast("not connected to agentboard server", "error");
139
+ return false;
124
140
  }
125
141
 
126
142
  function switchToSession(name: string) {
@@ -180,13 +196,16 @@ function App() {
180
196
  "/tmp/agentboard-tui-agent-click.log",
181
197
  `[${new Date().toISOString()}] keyboard focus-agent-pane session=${data.name} agent=${agent.agent} threadId=${agent.threadId} threadName=${agent.threadName}\n`,
182
198
  );
183
- send({
184
- type: "focus-agent-pane",
185
- session: data.name,
186
- agent: agent.agent,
187
- threadId: agent.threadId,
188
- threadName: agent.threadName,
189
- });
199
+ send(
200
+ {
201
+ type: "focus-agent-pane",
202
+ session: data.name,
203
+ agent: agent.agent,
204
+ threadId: agent.threadId,
205
+ threadName: agent.threadName,
206
+ },
207
+ `focusing ${agent.agent}`,
208
+ );
190
209
  }
191
210
 
192
211
  function dismissFocusedAgent() {
@@ -194,17 +213,13 @@ function App() {
194
213
  const agents = data?.agents ?? [];
195
214
  const agent = agents[focusedAgentIdx()];
196
215
  if (!agent || !data) return;
197
- send({
198
- type: "dismiss-agent",
199
- session: data.name,
200
- agent: agent.agent,
201
- threadId: agent.threadId,
202
- });
203
- // Adjust index if we dismissed the last item
216
+ send(
217
+ { type: "dismiss-agent", session: data.name, agent: agent.agent, threadId: agent.threadId },
218
+ `dismissed ${agent.agent}`,
219
+ );
204
220
  if (focusedAgentIdx() >= agents.length - 1 && agents.length > 1) {
205
221
  setFocusedAgentIdx(agents.length - 2);
206
222
  }
207
- // If no agents left, go back to sessions
208
223
  if (agents.length <= 1) setPanelFocus("sessions");
209
224
  }
210
225
 
@@ -213,13 +228,16 @@ function App() {
213
228
  const agents = data?.agents ?? [];
214
229
  const agent = agents[focusedAgentIdx()];
215
230
  if (!agent || !data) return;
216
- send({
217
- type: "kill-agent-pane",
218
- session: data.name,
219
- agent: agent.agent,
220
- threadId: agent.threadId,
221
- threadName: agent.threadName,
222
- });
231
+ send(
232
+ {
233
+ type: "kill-agent-pane",
234
+ session: data.name,
235
+ agent: agent.agent,
236
+ threadId: agent.threadId,
237
+ threadName: agent.threadName,
238
+ },
239
+ `killed ${agent.agent} pane`,
240
+ );
223
241
  }
224
242
 
225
243
  function beginDetailResize(event: MouseEvent) {
@@ -311,11 +329,27 @@ function App() {
311
329
  const data = focusedData();
312
330
  if (!data?.dir) return;
313
331
  const editor = preferredEditor();
314
- Bun.spawn([editor, data.dir], {
315
- stdout: "ignore",
316
- stderr: "ignore",
317
- stdin: "ignore",
318
- });
332
+ try {
333
+ const proc = Bun.spawn([editor, data.dir], {
334
+ stdout: "ignore",
335
+ stderr: "ignore",
336
+ stdin: "ignore",
337
+ });
338
+ showToast(`opening ${data.dir} in ${editor}`, "success");
339
+ void proc.exited.then((code) => {
340
+ if (code !== 0) {
341
+ logResizeDebug("openInEditor failed", { editor, dir: data.dir, code });
342
+ showToast(`failed to open editor "${editor}" (exit ${code})`, "error");
343
+ }
344
+ });
345
+ } catch (err) {
346
+ logResizeDebug("openInEditor spawn threw", {
347
+ editor,
348
+ dir: data.dir,
349
+ error: String(err),
350
+ });
351
+ showToast(`failed to spawn editor "${editor}": ${String(err)}`, "error");
352
+ }
319
353
  }
320
354
 
321
355
  onMount(() => {
@@ -340,6 +374,7 @@ function App() {
340
374
  const refocusTimeout = setTimeout(doStartupRefocus, 2000);
341
375
 
342
376
  onCleanup(() => {
377
+ if (toastTimer) clearTimeout(toastTimer);
343
378
  clearTimeout(refocusTimeout);
344
379
  renderer.removeListener("capabilities", doStartupRefocus);
345
380
  });
@@ -460,7 +495,7 @@ function App() {
460
495
  if (currentModal === "confirm-kill") {
461
496
  if (key.name === "y") {
462
497
  const target = killTarget();
463
- if (target) send({ type: "kill-session", name: target });
498
+ if (target) send({ type: "kill-session", name: target }, `killed ${target}`);
464
499
  setKillTarget(null);
465
500
  setModal("none");
466
501
  } else {
@@ -541,20 +576,11 @@ function App() {
541
576
  break;
542
577
  }
543
578
  case "r":
544
- send({ type: "refresh" });
579
+ send({ type: "refresh" }, "refreshing sessions");
545
580
  break;
546
- case "u":
547
- send({ type: "show-all-sessions" });
581
+ case "d":
582
+ if (panelFocus() === "agents") dismissFocusedAgent();
548
583
  break;
549
- case "d": {
550
- if (panelFocus() === "agents") {
551
- dismissFocusedAgent();
552
- } else {
553
- const focused = focusedSession();
554
- if (focused) send({ type: "hide-session", name: focused });
555
- }
556
- break;
557
- }
558
584
  case "x": {
559
585
  if (panelFocus() === "agents") {
560
586
  killFocusedAgentPane();
@@ -682,6 +708,20 @@ function App() {
682
708
  <box height={1}>
683
709
  <text style={{ fg: P().surface2 }}>{DIVIDER}</text>
684
710
  </box>
711
+ <Show when={toast()}>
712
+ {(t) => (
713
+ <box height={1}>
714
+ <text
715
+ style={{
716
+ fg:
717
+ t().tone === "error" ? P().red : t().tone === "success" ? P().green : P().blue,
718
+ }}
719
+ >
720
+ {t().message}
721
+ </text>
722
+ </box>
723
+ )}
724
+ </Show>
685
725
  <Show
686
726
  when={panelFocus() === "sessions"}
687
727
  fallback={
@@ -700,11 +740,10 @@ function App() {
700
740
  palette={P}
701
741
  hints={[
702
742
  ["⇥", "cycle"],
703
- ["⏎", "go"],
704
- ["→", "select"],
743
+ ["⏎", "switch"],
744
+ ["→", "agents"],
705
745
  ["n", "new"],
706
746
  ["e", "edit"],
707
- ["d", "hide"],
708
747
  ["x", "kill"],
709
748
  ["r", "refresh"],
710
749
  ["q", "quit"],
@@ -768,11 +807,9 @@ const HELP_KEYS: [string, string][] = [
768
807
  ["Tab", "Cycle sessions"],
769
808
  ["n", "New session"],
770
809
  ["e", "Open in editor"],
771
- ["d", "Hide session"],
772
810
  ["x", "Kill session"],
773
811
  ["r", "Refresh"],
774
- ["u", "Show all sessions"],
775
- ["→/l", "Select panel"],
812
+ ["→/l", "Agents panel"],
776
813
  ["←/h/Esc", "Back to sessions"],
777
814
  ["Alt+↑↓", "Reorder sessions"],
778
815
  ["q", "Quit"],
@@ -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;
@@ -248,13 +263,8 @@ export function startServer(
248
263
  return a.name.localeCompare(b.name);
249
264
  });
250
265
 
251
- const currentSession = getCurrentSession();
252
-
253
266
  // Sync custom ordering with current session list
254
267
  sessionOrder.sync(allMuxSessions.map((s) => s.name));
255
- if (currentSession) {
256
- sessionOrder.show(currentSession);
257
- }
258
268
 
259
269
  // Apply custom ordering
260
270
  const orderedNames = sessionOrder.apply(allMuxSessions.map((s) => s.name));
@@ -303,7 +313,7 @@ export function startServer(
303
313
  ports: getSessionPorts(name),
304
314
  windows,
305
315
  uptime,
306
- agentState: tracker.getState(name),
316
+ agentState: overrideTerminalIfPaneAlive(name, tracker.getState(name)),
307
317
  agents: mergeAgentsWithPanePresence(name, tracker.getAgents(name)),
308
318
  eventTimestamps: tracker.getEventTimestamps(name),
309
319
  metadata: metadataStore.get(name),
@@ -1084,8 +1094,12 @@ export function startServer(
1084
1094
  const data = JSON.parse(readFileSync(join(sessionsDir, `${agentPid}.json`), "utf-8"));
1085
1095
  const threadId: string | undefined = data.sessionId;
1086
1096
  if (!threadId) return {};
1087
- // Try to get thread name and status from the journal
1088
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
+ }
1089
1103
  return { threadId, ...journalInfo };
1090
1104
  } catch {
1091
1105
  return {};
@@ -1124,10 +1138,16 @@ export function startServer(
1124
1138
  if (t && !t.startsWith("<") && !t.startsWith("{")) threadName = t.slice(0, 80);
1125
1139
  }
1126
1140
 
1127
- // Determine status from last entry (same logic as ClaudeCodeAgentWatcher)
1128
1141
  if (msg.role === "assistant") {
1129
1142
  const items = Array.isArray(msg.content) ? msg.content : [];
1130
- 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
+ }
1131
1151
  } else if (msg.role === "user") {
1132
1152
  lastStatus = "running";
1133
1153
  }
@@ -1366,14 +1386,6 @@ export function startServer(
1366
1386
  mux.createSession();
1367
1387
  broadcastState();
1368
1388
  break;
1369
- case "hide-session":
1370
- sessionOrder.hide(cmd.name);
1371
- broadcastState();
1372
- break;
1373
- case "show-all-sessions":
1374
- sessionOrder.showAll();
1375
- broadcastState();
1376
- break;
1377
1389
  case "kill-session": {
1378
1390
  const p = sessionProviders.get(cmd.name) ?? mux;
1379
1391
  p.killSession(cmd.name);
@@ -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
  }
@@ -3,7 +3,6 @@ import { dirname } from "node:path";
3
3
 
4
4
  interface PersistedSessionOrder {
5
5
  order?: unknown;
6
- hidden?: unknown;
7
6
  }
8
7
 
9
8
  /**
@@ -16,7 +15,6 @@ interface PersistedSessionOrder {
16
15
  */
17
16
  export class SessionOrder {
18
17
  private order: string[] = [];
19
- private hidden = new Set<string>();
20
18
  private readonly persistPath: string | null;
21
19
 
22
20
  constructor(persistPath?: string) {
@@ -33,11 +31,6 @@ export class SessionOrder {
33
31
  if (Array.isArray(persisted.order)) {
34
32
  this.order = persisted.order.filter((n): n is string => typeof n === "string");
35
33
  }
36
- if (Array.isArray(persisted.hidden)) {
37
- this.hidden = new Set(
38
- persisted.hidden.filter((n): n is string => typeof n === "string"),
39
- );
40
- }
41
34
  }
42
35
  }
43
36
  } catch {
@@ -51,7 +44,6 @@ export class SessionOrder {
51
44
  const nameSet = new Set(names);
52
45
  // Remove sessions that no longer exist
53
46
  this.order = this.order.filter((n) => nameSet.has(n));
54
- this.hidden = new Set([...this.hidden].filter((n) => nameSet.has(n)));
55
47
  // Add new sessions in sorted position
56
48
  const newNames = names
57
49
  .filter((n) => !this.order.includes(n))
@@ -78,48 +70,21 @@ export class SessionOrder {
78
70
  this.save();
79
71
  }
80
72
 
81
- /** Hide a session from the panel without touching the underlying mux session. */
82
- hide(name: string): void {
83
- if (!this.order.includes(name) || this.hidden.has(name)) return;
84
- this.hidden.add(name);
85
- this.save();
86
- }
87
-
88
- /** Make a previously hidden session visible again. */
89
- show(name: string): void {
90
- if (!this.hidden.delete(name)) return;
91
- if (!this.order.includes(name)) {
92
- this.order.push(name);
93
- }
94
- this.save();
95
- }
96
-
97
- /** Restore all hidden sessions back into the panel. */
98
- showAll(): void {
99
- if (this.hidden.size === 0) return;
100
- this.hidden.clear();
101
- this.save();
102
- }
103
-
104
73
  /** Apply the custom order to a list of session names. Returns sorted names. */
105
74
  apply(names: string[]): string[] {
106
75
  const posMap = new Map(this.order.map((n, i) => [n, i]));
107
- return names
108
- .filter((n) => !this.hidden.has(n))
109
- .sort((a, b) => {
110
- const pa = posMap.get(a) ?? Infinity;
111
- const pb = posMap.get(b) ?? Infinity;
112
- return pa - pb;
113
- });
76
+ return [...names].sort((a, b) => {
77
+ const pa = posMap.get(a) ?? Infinity;
78
+ const pb = posMap.get(b) ?? Infinity;
79
+ return pa - pb;
80
+ });
114
81
  }
115
82
 
116
83
  private save(): void {
117
84
  if (!this.persistPath) return;
118
85
  try {
119
86
  mkdirSync(dirname(this.persistPath), { recursive: true });
120
- const serialized =
121
- this.hidden.size === 0 ? this.order : { order: this.order, hidden: [...this.hidden] };
122
- writeFileSync(this.persistPath, JSON.stringify(serialized) + "\n");
87
+ writeFileSync(this.persistPath, JSON.stringify(this.order) + "\n");
123
88
  } catch {
124
89
  // Best-effort — don't crash if write fails
125
90
  }
@@ -111,8 +111,6 @@ export type ClientCommand =
111
111
  | { type: "switch-session"; name: string; clientTty?: string }
112
112
  | { type: "switch-index"; index: number }
113
113
  | { type: "new-session" }
114
- | { type: "hide-session"; name: string }
115
- | { type: "show-all-sessions" }
116
114
  | { type: "kill-session"; name: string }
117
115
  | { type: "reorder-session"; name: string; delta: -1 | 1 }
118
116
  | { type: "refresh" }
@@ -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);