@towles/tool 0.0.128 → 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.128",
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
- "description": "Core dev workflow commands: interview-me, write-prd, prd-to-issues, tdd, improve-architecture, refine-text, task.",
4
- "version": "0.0.109",
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.130",
5
5
  "author": {
6
6
  "name": "Chris Towles"
7
7
  }
@@ -10,6 +10,13 @@ Core workflow automation commands for Claude Code.
10
10
  | `/tt:improve` | Explore codebase and suggest improvements |
11
11
  | `/tt:refine` | Fix grammar/spelling in files |
12
12
 
13
+ ## Skills
14
+
15
+ | Skill | Description |
16
+ | ------------------- | --------------------------------------------------------------------------------- |
17
+ | `tt:towles-tool` | `tt` CLI reference: git/gh helpers, journaling, dependency checks. |
18
+ | `tt:parallel-slots` | Fan out parallel Claude Code agents across slot clones of any repo, via `gh` CLI. |
19
+
13
20
  ## Installation
14
21
 
15
22
  ```bash
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: parallel-slots
3
+ description: Use when the user wants to dispatch parallel Claude Code agents across slot clones of a repo, asks to "fan out", "run N in parallel", "use the slots", or wants to coordinate multiple isolated working copies of the same repo. Explains the slot directory layout, when to fan out vs. stay in primary, and the `gh`-driven workflow that ties slots together.
4
+ user_invocable: true
5
+ ---
6
+
7
+ # Parallel slots
8
+
9
+ The slot pattern lets you run independent Claude Code sessions on the same repo without stepping on each other. Mirrors Boris Cherny's "5 terminal tabs, each a separate git checkout" workflow.
10
+
11
+ ## Layout
12
+
13
+ ```
14
+ ~/code/<scope>/<repo>-repos/
15
+ <repo>-primary/ # interactive work
16
+ <repo>-slot-1/ # parallel agent slot
17
+ <repo>-slot-2/
18
+ <repo>-slot-3/
19
+ <repo>-slot-4/
20
+ <repo>-slot-5/
21
+ ```
22
+
23
+ Each slot is a full clone of the same GitHub remote, not a worktree. They check out branches independently. Use the `gh` CLI for all GitHub-side operations (issue → branch, PR create, PR merge, status). If the repo ships a tmux sidebar (e.g. AgentBoard in towles-tool), it watches every slot and surfaces completion via the stop-hook sweep.
24
+
25
+ ## When to fan out
26
+
27
+ Fan out (use slots) when:
28
+
29
+ - Three or more independent tasks would benefit from running simultaneously (e.g. one PR, one bug, one refactor).
30
+ - A task is risky and you want a clean, throwaway slot that won't pollute primary's working tree.
31
+ - You're iterating on the agent harness itself and want to leave primary stable.
32
+
33
+ Stay in primary when:
34
+
35
+ - The work is sequential or all the changes need to land in the same commit.
36
+ - You're reading/exploring; spinning a slot just adds overhead.
37
+
38
+ ## Dispatch flow
39
+
40
+ 1. Pick a free slot (any slot whose sidebar pane is idle).
41
+ 2. `cd` into it and confirm the working tree is clean.
42
+ 3. Branch off from a GitHub issue: `gh issue develop <issue-number> --checkout` — creates a remote branch tied to the issue and switches the slot to it. If there's no issue, name the branch and use `gh pr checkout <pr>` later if you need to hop onto a colleague's PR.
43
+ 4. Hand the task to Claude in that slot — either via the repo's sidebar TUI or by running `tt auto-claude` with a prompt.
44
+ 5. Watch the sidebar pane for completion. The stop-hook prints results back to it.
45
+
46
+ ## Coordination rules
47
+
48
+ - Never run two agents on the _same_ branch in two slots — push/pull races destroy work.
49
+ - Branch names should be unique per slot for the duration of the run.
50
+ - If a slot's working tree is dirty when you arrive, treat it as in-progress work — investigate before resetting.
51
+ - Pre-commit hooks (format + lint + typecheck) run in every slot, so `--no-verify` is forbidden.
52
+
53
+ ## Verifying a slot's output
54
+
55
+ Before merging from a slot, run that repo's verify command (`/verify` in towles-tool) inside it. Don't trust the slot's own self-report; the agent that wrote the change is not the right reviewer.
56
+
57
+ ## Shipping from a slot
58
+
59
+ Open the PR with `gh pr create` (use `--fill` to seed title/body from commits, or pass `--title`/`--body` explicitly). Merge with `gh pr merge --rebase --admin` — the standard merge style.
60
+
61
+ ## Cleanup
62
+
63
+ After a slot's branch is merged: confirm with `gh pr status` that the slot's PR is merged, then prune the local branch. Use `compound-engineering:ce-clean-gone-branches` to bulk-prune across multiple slots in one pass.
64
+
65
+ ## Anti-patterns
66
+
67
+ - Spinning all 5 slots on the same task "for redundancy". You'll spend the time merging conflicts.
68
+ - Treating slots as long-lived workspaces. They are scratch checkouts — keep them transient.
69
+ - Editing files in primary while a slot has them open. Stay in one or the other for any given file.
@@ -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", () => {