@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 +1 -1
- package/packages/agentboard/apps/tui/src/components/SessionCard.tsx +15 -0
- package/packages/agentboard/packages/runtime/src/agents/tracker.test.ts +103 -0
- package/packages/agentboard/packages/runtime/src/agents/tracker.ts +50 -0
- package/packages/agentboard/packages/runtime/src/server/index.ts +3 -0
- package/packages/agentboard/packages/runtime/src/shared.ts +1 -0
- package/packages/core/.claude-plugin/plugin.json +1 -1
- package/src/commands/graph/analyzer.test.ts +8 -2
- package/src/commands/graph.test.ts +1 -1
package/package.json
CHANGED
|
@@ -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.
|
|
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 {
|
|
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 {
|