@towles/tool 0.0.129 → 0.0.130

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.129",
3
+ "version": "0.0.130",
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": {
@@ -232,6 +232,15 @@ function AgentRow(props: AgentRowProps) {
232
232
 
233
233
  const statusText = () => STATUS_TEXT[props.agent.status];
234
234
 
235
+ const isCacheExpired = () => {
236
+ const details = props.agent.details;
237
+ if (!details) return false;
238
+ const now = props.now();
239
+ if (details.cacheExpiresAt != null) return now > details.cacheExpiresAt;
240
+ if (details.lastActivityAt != null) return now - details.lastActivityAt > 60 * 60 * 1000;
241
+ return false;
242
+ };
243
+
235
244
  let flashTimer: ReturnType<typeof setTimeout> | null = null;
236
245
  const triggerFlash = () => {
237
246
  setIsFlash(true);
@@ -324,6 +333,12 @@ function AgentRow(props: AgentRowProps) {
324
333
  );
325
334
  }}
326
335
  </Show>
336
+
337
+ <Show when={isCacheExpired()}>
338
+ <text truncate>
339
+ <span style={{ fg: P().overlay0, attributes: DIM }}>cache expired</span>
340
+ </text>
341
+ </Show>
327
342
  </box>
328
343
  );
329
344
  }
@@ -152,6 +152,109 @@ describe("AgentTracker", () => {
152
152
  });
153
153
  });
154
154
 
155
+ describe("pruneStale", () => {
156
+ const TWELVE_HOURS = 12 * 60 * 60 * 1000;
157
+
158
+ it("prunes waiting agents older than threshold", () => {
159
+ tracker.applyEvent(makeEvent({ status: "waiting", ts: Date.now() - TWELVE_HOURS - 1000 }));
160
+ tracker.pruneStale(TWELVE_HOURS);
161
+ expect(tracker.getState("main")).toBeNull();
162
+ });
163
+
164
+ it("prunes running agents older than threshold", () => {
165
+ tracker.applyEvent(makeEvent({ status: "running", ts: Date.now() - TWELVE_HOURS - 1000 }));
166
+ tracker.pruneStale(TWELVE_HOURS);
167
+ expect(tracker.getState("main")).toBeNull();
168
+ });
169
+
170
+ it("leaves fresh agents alone", () => {
171
+ tracker.applyEvent(makeEvent({ status: "waiting", ts: Date.now() - 1000 }));
172
+ tracker.pruneStale(TWELVE_HOURS);
173
+ expect(tracker.getState("main")).not.toBeNull();
174
+ });
175
+
176
+ it("respects pinned instances", () => {
177
+ tracker.applyEvent(makeEvent({ status: "waiting", ts: Date.now() - TWELVE_HOURS - 1000 }));
178
+ tracker.setPinnedInstances("main", ["claude-code"]);
179
+ tracker.pruneStale(TWELVE_HOURS);
180
+ expect(tracker.getState("main")).not.toBeNull();
181
+ });
182
+
183
+ it("uses details.lastActivityAt when present", () => {
184
+ tracker.applyEvent(
185
+ makeEvent({
186
+ status: "waiting",
187
+ ts: Date.now() - TWELVE_HOURS - 1000,
188
+ details: { lastActivityAt: Date.now() - 1000 },
189
+ }),
190
+ );
191
+ tracker.pruneStale(TWELVE_HOURS);
192
+ expect(tracker.getState("main")).not.toBeNull();
193
+ });
194
+
195
+ it("falls back to event.ts when lastActivityAt missing", () => {
196
+ tracker.applyEvent(makeEvent({ status: "waiting", ts: Date.now() - TWELVE_HOURS - 1000 }));
197
+ tracker.pruneStale(TWELVE_HOURS);
198
+ expect(tracker.getState("main")).toBeNull();
199
+ });
200
+ });
201
+
202
+ describe("pruneSupersededByPane", () => {
203
+ it("drops older instance when same agent reappears with new threadId in same pane", () => {
204
+ tracker.applyEvent(makeEvent({ threadId: "t1", status: "waiting", paneId: "%5", ts: 1000 }));
205
+ tracker.applyEvent(makeEvent({ threadId: "t2", status: "running", paneId: "%5", ts: 2000 }));
206
+ tracker.setPinnedInstances("main", ["claude-code:t2"]);
207
+ tracker.pruneSupersededByPane();
208
+ const agents = tracker.getAgents("main");
209
+ expect(agents).toHaveLength(1);
210
+ expect(agents[0]!.threadId).toBe("t2");
211
+ });
212
+
213
+ it("keeps both when instances are in different panes", () => {
214
+ tracker.applyEvent(makeEvent({ threadId: "t1", status: "waiting", paneId: "%5", ts: 1000 }));
215
+ tracker.applyEvent(makeEvent({ threadId: "t2", status: "running", paneId: "%7", ts: 2000 }));
216
+ tracker.pruneSupersededByPane();
217
+ expect(tracker.getAgents("main")).toHaveLength(2);
218
+ });
219
+
220
+ it("keeps both when they're different agent types in the same pane", () => {
221
+ tracker.applyEvent(makeEvent({ agent: "claude-code", paneId: "%5", ts: 1000 }));
222
+ tracker.applyEvent(makeEvent({ agent: "amp", paneId: "%5", ts: 2000 }));
223
+ tracker.pruneSupersededByPane();
224
+ expect(tracker.getAgents("main")).toHaveLength(2);
225
+ });
226
+
227
+ it("ignores instances without a paneId", () => {
228
+ tracker.applyEvent(makeEvent({ threadId: "t1", paneId: undefined, ts: 1000 }));
229
+ tracker.applyEvent(makeEvent({ threadId: "t2", paneId: undefined, ts: 2000 }));
230
+ tracker.pruneSupersededByPane();
231
+ expect(tracker.getAgents("main")).toHaveLength(2);
232
+ });
233
+
234
+ it("uses details.lastActivityAt for recency comparison", () => {
235
+ tracker.applyEvent(
236
+ makeEvent({
237
+ threadId: "t1",
238
+ paneId: "%5",
239
+ ts: 5000,
240
+ details: { lastActivityAt: 9000 },
241
+ }),
242
+ );
243
+ tracker.applyEvent(
244
+ makeEvent({
245
+ threadId: "t2",
246
+ paneId: "%5",
247
+ ts: 6000,
248
+ details: { lastActivityAt: 7000 },
249
+ }),
250
+ );
251
+ tracker.pruneSupersededByPane();
252
+ const agents = tracker.getAgents("main");
253
+ expect(agents).toHaveLength(1);
254
+ expect(agents[0]!.threadId).toBe("t1");
255
+ });
256
+ });
257
+
155
258
  describe("multi-thread support", () => {
156
259
  it("tracks multiple threads for the same agent", () => {
157
260
  tracker.applyEvent(makeEvent({ threadId: "t1", status: "running" }));
@@ -148,6 +148,56 @@ export class AgentTracker {
148
148
  }
149
149
  }
150
150
 
151
+ /**
152
+ * When multiple instances of the same agent share a paneId, keep only the most recent.
153
+ * The currently-live instance is pinned by the pane scanner, so this only removes superseded
154
+ * predecessors (e.g. a Claude Code session that was /exited and replaced in the same pane).
155
+ */
156
+ pruneSupersededByPane(): void {
157
+ for (const [session, sessionInstances] of this.instances) {
158
+ const groups = new Map<string, { key: string; ts: number }[]>();
159
+ for (const [key, event] of sessionInstances) {
160
+ if (!event.paneId) continue;
161
+ const groupKey = `${event.paneId}\0${event.agent}`;
162
+ const ts = event.details?.lastActivityAt ?? event.ts;
163
+ const list = groups.get(groupKey) ?? [];
164
+ list.push({ key, ts });
165
+ groups.set(groupKey, list);
166
+ }
167
+ for (const list of groups.values()) {
168
+ if (list.length < 2) continue;
169
+ list.sort((a, b) => b.ts - a.ts);
170
+ for (let i = 1; i < list.length; i++) {
171
+ const k = list[i]!.key;
172
+ if (this.isPinned(session, k)) continue;
173
+ sessionInstances.delete(k);
174
+ this.unseenInstances.delete(this.unseenKey(session, k));
175
+ }
176
+ }
177
+ if (sessionInstances.size === 0) {
178
+ this.instances.delete(session);
179
+ }
180
+ }
181
+ }
182
+
183
+ /** Auto-prune any instance whose last activity is older than timeoutMs, regardless of status. Skips pinned. */
184
+ pruneStale(timeoutMs: number): void {
185
+ const now = Date.now();
186
+ for (const [session, sessionInstances] of this.instances) {
187
+ for (const [key, event] of sessionInstances) {
188
+ if (this.isPinned(session, key)) continue;
189
+ const lastSeen = event.details?.lastActivityAt ?? event.ts;
190
+ if (now - lastSeen > timeoutMs) {
191
+ sessionInstances.delete(key);
192
+ this.unseenInstances.delete(this.unseenKey(session, key));
193
+ }
194
+ }
195
+ if (sessionInstances.size === 0) {
196
+ this.instances.delete(session);
197
+ }
198
+ }
199
+ }
200
+
151
201
  /** Auto-prune terminal instances older than timeout, but only if instance is not unseen or pinned */
152
202
  pruneTerminal(): void {
153
203
  const now = Date.now();
@@ -23,6 +23,7 @@ import {
23
23
  PID_FILE,
24
24
  SERVER_IDLE_TIMEOUT_MS,
25
25
  STUCK_RUNNING_TIMEOUT_MS,
26
+ STALE_AGENT_TIMEOUT_MS,
26
27
  } from "../shared";
27
28
  import type { ServerState, SessionData, ClientCommand, FocusUpdate, MetadataTone } from "../shared";
28
29
 
@@ -391,6 +392,8 @@ export function startServer(
391
392
  invalidateCurrentSessionCache();
392
393
  tracker.pruneStuck(STUCK_RUNNING_TIMEOUT_MS);
393
394
  tracker.pruneTerminal();
395
+ tracker.pruneStale(STALE_AGENT_TIMEOUT_MS);
396
+ tracker.pruneSupersededByPane();
394
397
  lastState = computeState();
395
398
  syncGitWatchers(lastState.sessions, broadcastState);
396
399
  const msg = JSON.stringify(lastState);
@@ -11,6 +11,7 @@ export const SERVER_HOST: string = process.env.TT_AGENTBOARD_HOST || DEFAULT_SER
11
11
  export const PID_FILE = "/tmp/agentboard.pid";
12
12
  export const SERVER_IDLE_TIMEOUT_MS = 30_000;
13
13
  export const STUCK_RUNNING_TIMEOUT_MS = 3 * 60 * 1000;
14
+ export const STALE_AGENT_TIMEOUT_MS = 12 * 60 * 60 * 1000;
14
15
  export const JOURNAL_IDLE_TIMEOUT_MS = 120_000;
15
16
 
16
17
  export interface SessionData {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tt",
3
3
  "description": "Core dev workflow commands and skills: interview-me, write-prd, prd-to-issues, tdd, improve-architecture, refine-text, task, parallel-slots, towles-tool.",
4
- "version": "0.0.129",
4
+ "version": "0.0.130",
5
5
  "author": {
6
6
  "name": "Chris Towles"
7
7
  }
@@ -18,7 +18,13 @@ function textBlock(text: string): ContentBlock {
18
18
  }
19
19
 
20
20
  function toolUseBlock(name: string, input: Record<string, unknown>): ContentBlock {
21
- return { type: "tool_use" as const, id: "tool-stub", name, input, caller: { type: "direct" } };
21
+ return {
22
+ type: "tool_use" as const,
23
+ id: "tool-stub",
24
+ name,
25
+ input,
26
+ caller: { type: "direct" },
27
+ } as unknown as ContentBlock;
22
28
  }
23
29
 
24
30
  function makeUsage(overrides: Partial<Usage> = {}): Usage {
@@ -32,7 +38,7 @@ function makeUsage(overrides: Partial<Usage> = {}): Usage {
32
38
  server_tool_use: null,
33
39
  service_tier: null,
34
40
  ...overrides,
35
- };
41
+ } as unknown as Usage;
36
42
  }
37
43
 
38
44
  function makeEntry(overrides: Partial<JournalEntry> = {}): JournalEntry {
@@ -16,7 +16,7 @@ function makeUsage(overrides: Partial<Usage> = {}): Usage {
16
16
  server_tool_use: null,
17
17
  service_tier: null,
18
18
  ...overrides,
19
- };
19
+ } as unknown as Usage;
20
20
  }
21
21
 
22
22
  describe("graph --days filtering", () => {