@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0
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/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
package/src/ui/fleet-list.ts
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
15
|
import type { AgentManager } from "../agent-manager.js";
|
|
16
|
+
import { toSingleLine } from "../text-safety.js";
|
|
16
17
|
import type { AgentRecord } from "../types.js";
|
|
17
|
-
import { getLifetimeTotal } from "../usage.js";
|
|
18
|
-
import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
|
|
18
|
+
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
19
|
+
import { type AgentActivity, formatCost, getDisplayName, type Theme } from "./agent-widget.js";
|
|
19
20
|
import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
|
|
20
21
|
|
|
21
22
|
/** Widget key for the below-editor fleet list. */
|
|
@@ -93,6 +94,12 @@ export class FleetList {
|
|
|
93
94
|
constructor(
|
|
94
95
|
private manager: AgentManager,
|
|
95
96
|
private agentActivity: Map<string, AgentActivity>,
|
|
97
|
+
/**
|
|
98
|
+
* Read live at render time. Whether each row shows an estimated cost after
|
|
99
|
+
* its token count. Defaults to off — the extension supplies the user's
|
|
100
|
+
* `showCost` setting.
|
|
101
|
+
*/
|
|
102
|
+
private showCost: () => boolean = () => false,
|
|
96
103
|
) {}
|
|
97
104
|
|
|
98
105
|
// ---- Lifecycle ----
|
|
@@ -188,7 +195,7 @@ export class FleetList {
|
|
|
188
195
|
private agentRecords(): AgentRecord[] {
|
|
189
196
|
const now = Date.now();
|
|
190
197
|
return this.manager.listAgents()
|
|
191
|
-
.filter(a => a.session && (
|
|
198
|
+
.filter(a => !a.parentAgentId && a.session && (
|
|
192
199
|
a.status === "running" || a.status === "queued"
|
|
193
200
|
|| a.id === this.viewingAgentId
|
|
194
201
|
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
|
|
@@ -288,7 +295,7 @@ export class FleetList {
|
|
|
288
295
|
const record = entry.record;
|
|
289
296
|
if (!this.ui) return;
|
|
290
297
|
if (!record.session) {
|
|
291
|
-
this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
|
|
298
|
+
this.ui.notify(`Agent is ${toSingleLine(record.status)} — no session available.`, "info");
|
|
292
299
|
return;
|
|
293
300
|
}
|
|
294
301
|
const session = record.session;
|
|
@@ -306,10 +313,11 @@ export class FleetList {
|
|
|
306
313
|
theme,
|
|
307
314
|
done,
|
|
308
315
|
() => {
|
|
309
|
-
if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
|
|
316
|
+
if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${toSingleLine(record.description)}".`, "info");
|
|
310
317
|
},
|
|
311
318
|
keybindings,
|
|
312
319
|
(message: string) => this.manager.steer(record.id, message),
|
|
320
|
+
this.showCost(),
|
|
313
321
|
);
|
|
314
322
|
},
|
|
315
323
|
{
|
|
@@ -371,10 +379,13 @@ export class FleetList {
|
|
|
371
379
|
}
|
|
372
380
|
|
|
373
381
|
private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
|
|
374
|
-
const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", getDisplayName(record.type))} ${record.description}`;
|
|
375
|
-
const
|
|
382
|
+
const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", toSingleLine(getDisplayName(record.type)))} ${toSingleLine(record.description)}`;
|
|
383
|
+
const usage = this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage;
|
|
384
|
+
const tokens = getLifetimeTotal(usage);
|
|
376
385
|
const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished
|
|
377
|
-
const
|
|
386
|
+
const cost = this.showCost() ? formatCost(getLifetimeCost(usage)) : "";
|
|
387
|
+
const stats = `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}${cost ? ` · ${cost}` : ""}`;
|
|
388
|
+
const right = theme.fg("dim", stats);
|
|
378
389
|
return rightAlign(left, right, width);
|
|
379
390
|
}
|
|
380
391
|
}
|
package/src/ui/schedule-menu.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { SubagentScheduler } from "../schedule.js";
|
|
13
|
+
import { safeTruncate, stripControlChars, toSingleLine } from "../text-safety.js";
|
|
13
14
|
import type { ScheduledSubagent } from "../types.js";
|
|
14
15
|
|
|
15
16
|
/** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
|
|
@@ -40,29 +41,44 @@ function statusIcon(j: ScheduledSubagent): string {
|
|
|
40
41
|
/** Compact selectable row — name, schedule, agent type, next/last run, count. */
|
|
41
42
|
function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
|
|
42
43
|
const next = scheduler.getNextRun(j.id);
|
|
44
|
+
// runCount is typed number but comes from unvalidated JSON; coerce before the
|
|
45
|
+
// single-line sanitizer, which returns "" for non-string input.
|
|
46
|
+
const runCount = Number(j.runCount);
|
|
43
47
|
return [
|
|
44
48
|
statusIcon(j),
|
|
45
|
-
j.name
|
|
46
|
-
j.schedule
|
|
47
|
-
`[${j.subagent_type}]`,
|
|
49
|
+
safeTruncate(toSingleLine(j.name), 18).padEnd(18),
|
|
50
|
+
safeTruncate(toSingleLine(j.schedule), 14).padEnd(14),
|
|
51
|
+
`[${toSingleLine(j.subagent_type)}]`,
|
|
48
52
|
`next ${relTime(next)}`,
|
|
49
53
|
`last ${relTime(j.lastRun)}`,
|
|
50
|
-
`runs ${
|
|
54
|
+
`runs ${toSingleLine(String(Number.isFinite(runCount) ? runCount : 0))}`,
|
|
51
55
|
].join(" ");
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** Multi-line details block for the cancel confirm. */
|
|
55
59
|
function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
|
|
56
60
|
const next = scheduler.getNextRun(j.id) ?? "—";
|
|
61
|
+
// The prompt is the only multi-line field and goes last, under its own
|
|
62
|
+
// label, so nothing it contains can forge a metadata line above it. The
|
|
63
|
+
// store is not validated per field, so a corrupted entry can hand it a
|
|
64
|
+
// non-string; coerce before the sanitizer touches it.
|
|
65
|
+
const rawPrompt = typeof j.prompt === "string" ? j.prompt : "";
|
|
66
|
+
const prompt = stripControlChars(rawPrompt);
|
|
67
|
+
const promptPreview = prompt.length > 200 ? `${safeTruncate(prompt, 200)}…` : prompt;
|
|
68
|
+
// runCount is typed number but comes from unvalidated JSON; coerce before the
|
|
69
|
+
// single-line sanitizer, which returns "" for non-string input.
|
|
70
|
+
const runCount = Number(j.runCount);
|
|
57
71
|
return [
|
|
58
|
-
`name: ${j.name}`,
|
|
59
|
-
`schedule: ${j.schedule} (${j.scheduleType})`,
|
|
60
|
-
`agent: ${j.subagent_type}`,
|
|
61
|
-
`
|
|
62
|
-
`
|
|
63
|
-
`
|
|
64
|
-
`
|
|
65
|
-
|
|
72
|
+
`name: ${toSingleLine(j.name)}`,
|
|
73
|
+
`schedule: ${toSingleLine(j.schedule)} (${toSingleLine(j.scheduleType)})`,
|
|
74
|
+
`agent: ${toSingleLine(j.subagent_type)}`,
|
|
75
|
+
`created: ${toSingleLine(j.createdAt)}`,
|
|
76
|
+
`last run: ${toSingleLine(j.lastRun ?? "—")} (${toSingleLine(j.lastStatus ?? "—")})`,
|
|
77
|
+
`next run: ${toSingleLine(next)}`,
|
|
78
|
+
`runs: ${toSingleLine(String(Number.isFinite(runCount) ? runCount : 0))}`,
|
|
79
|
+
"",
|
|
80
|
+
"prompt:",
|
|
81
|
+
promptPreview,
|
|
66
82
|
].join("\n");
|
|
67
83
|
}
|
|
68
84
|
|
|
@@ -85,20 +101,48 @@ export async function showSchedulesMenu(
|
|
|
85
101
|
return;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
|
|
104
|
+
// Labels are the user-facing rows, so two jobs whose names collapse to the
|
|
105
|
+
// same text would otherwise be indistinguishable. Make each generated label
|
|
106
|
+
// unique and resolve the selected label back to its job, so confirm/cancel
|
|
107
|
+
// act on the row the user picked.
|
|
108
|
+
const jobByLabel = new Map<string, ScheduledSubagent>();
|
|
109
|
+
const nextSuffix = new Map<string, number>();
|
|
110
|
+
const labels = jobs.map(j => {
|
|
111
|
+
const base = formatJob(j, scheduler);
|
|
112
|
+
let label = base;
|
|
113
|
+
if (jobByLabel.has(label)) {
|
|
114
|
+
// A repeated base (rows can truncate to the same text) is load-bearing:
|
|
115
|
+
// the suffix keeps the labels distinct. Resume from the last used suffix
|
|
116
|
+
// so a fully-conflicting set costs one step per row.
|
|
117
|
+
let n = nextSuffix.get(base) ?? 2;
|
|
118
|
+
// The rescan is unreachable today (every base ends with its `runs N`
|
|
119
|
+
// tail), but keep it: a duplicate label would let the user cancel the
|
|
120
|
+
// wrong job if the row shape changes.
|
|
121
|
+
while (jobByLabel.has(`${base} (${n})`)) n++;
|
|
122
|
+
label = `${base} (${n})`;
|
|
123
|
+
nextSuffix.set(base, n + 1);
|
|
124
|
+
}
|
|
125
|
+
jobByLabel.set(label, j);
|
|
126
|
+
return label;
|
|
127
|
+
});
|
|
89
128
|
const choice = await ctx.ui.select(
|
|
90
129
|
`Scheduled jobs (${jobs.length}) — select to cancel`,
|
|
91
130
|
labels,
|
|
92
131
|
);
|
|
93
132
|
if (!choice) return;
|
|
94
133
|
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
const job = jobs[idx];
|
|
134
|
+
const job = jobByLabel.get(choice);
|
|
135
|
+
if (!job) return;
|
|
98
136
|
|
|
99
|
-
const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
|
|
137
|
+
const ok = await ctx.ui.confirm(`Cancel "${toSingleLine(job.name)}"?`, formatDetails(job, scheduler));
|
|
100
138
|
if (!ok) return;
|
|
101
139
|
|
|
102
|
-
scheduler.removeJob(job.id);
|
|
103
|
-
|
|
140
|
+
const removed = scheduler.removeJob(job.id);
|
|
141
|
+
if (removed) {
|
|
142
|
+
ctx.ui.notify(`Cancelled "${toSingleLine(job.name)}".`, "info");
|
|
143
|
+
} else {
|
|
144
|
+
// The record vanished between the list read and the removal — do not
|
|
145
|
+
// claim a cancellation that did not happen.
|
|
146
|
+
ctx.ui.notify(`Could not cancel "${toSingleLine(job.name)}" — it is no longer a scheduled job.`, "warning");
|
|
147
|
+
}
|
|
104
148
|
}
|
package/src/usage.ts
CHANGED
|
@@ -1,30 +1,123 @@
|
|
|
1
1
|
/** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
|
|
2
2
|
|
|
3
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Lifetime usage components, accumulated via `message_end` events. Survives
|
|
5
7
|
* compaction (which replaces session.state.messages and would reset any
|
|
6
|
-
* stats-derived sum). cacheRead is excluded
|
|
7
|
-
* the cumulative cached prefix re-read on that one
|
|
8
|
-
* turns counts the prefix N times. See issue #38.
|
|
8
|
+
* stats-derived sum). cacheRead is excluded from `getLifetimeTotal` because
|
|
9
|
+
* each turn's cacheRead is the cumulative cached prefix re-read on that one
|
|
10
|
+
* call — summing across turns counts the prefix N times. See issue #38.
|
|
11
|
+
*
|
|
12
|
+
* That exclusion is about this *display* total, not about what was billed: the
|
|
13
|
+
* prefix really is re-read and re-charged on every call. So `cacheRead` is
|
|
14
|
+
* accumulated here anyway, kept out of `getLifetimeTotal` and used only where
|
|
15
|
+
* billing is the question — reporting to the parent session, whose own messages
|
|
16
|
+
* pi counts the same way (`addUsageToTotals`). Reporting 0 there would make a
|
|
17
|
+
* subagent's rows count differently from every other row in one total.
|
|
18
|
+
*
|
|
19
|
+
* `cost` is a plain sum for the same reason: it is what pi charged for that one
|
|
20
|
+
* message (`usage.cost.total`, priced from the model's rates), not a cumulative
|
|
21
|
+
* figure. Both are optional because a model with no pricing data reports no
|
|
22
|
+
* cost, and because every accumulator predates them; absent reads as 0.
|
|
9
23
|
*/
|
|
10
|
-
export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
|
|
24
|
+
export type LifetimeUsage = { input: number; output: number; cacheWrite: number; cacheRead?: number; cost?: number };
|
|
11
25
|
|
|
12
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Sum of lifetime *token* components for DISPLAY, or 0 if undefined.
|
|
28
|
+
* Deliberately excludes `cacheRead` (see above) and `cost` — that is money, not
|
|
29
|
+
* tokens, and lives on the same object only because it accumulates on the same
|
|
30
|
+
* events.
|
|
31
|
+
*/
|
|
13
32
|
export function getLifetimeTotal(u?: LifetimeUsage): number {
|
|
14
33
|
return u ? u.input + u.output + u.cacheWrite : 0;
|
|
15
34
|
}
|
|
16
35
|
|
|
36
|
+
/** Accumulated cost in USD, or 0 when unpriced/undefined. */
|
|
37
|
+
export function getLifetimeCost(u?: LifetimeUsage): number {
|
|
38
|
+
return u?.cost ?? 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
17
41
|
/** Add a usage delta into a target accumulator (mutates target). */
|
|
18
42
|
export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
|
|
19
43
|
into.input += delta.input;
|
|
20
44
|
into.output += delta.output;
|
|
21
45
|
into.cacheWrite += delta.cacheWrite;
|
|
46
|
+
if (delta.cacheRead) into.cacheRead = (into.cacheRead ?? 0) + delta.cacheRead;
|
|
47
|
+
if (delta.cost) into.cost = (into.cost ?? 0) + delta.cost;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render an accumulator as a pi `Usage`, or undefined when nothing was spent —
|
|
52
|
+
* callers attach nothing rather than a zero, so a consumer can tell "spent
|
|
53
|
+
* nothing" from "never ran".
|
|
54
|
+
*
|
|
55
|
+
* `cacheRead` IS included, unlike in `getLifetimeTotal`: pi sums it across a
|
|
56
|
+
* session's own assistant messages, and the prefix genuinely is re-read and
|
|
57
|
+
* re-billed on every call. Only `total` is populated on the cost breakdown; pi
|
|
58
|
+
* reads nothing else from it, and the per-kind split is not tracked.
|
|
59
|
+
*
|
|
60
|
+
* This is pi's own shape on purpose: every extension-facing payload that carries
|
|
61
|
+
* spend takes a whole `Usage` (`AgentToolResult.usage`, `ToolResultEventResult`,
|
|
62
|
+
* `AssistantMessage`, …), never a flattened cost — pi flattens only in computed
|
|
63
|
+
* read APIs it expects you to render, like `SessionStats`. Handing over pi's own
|
|
64
|
+
* type means `usage.cost.total` is where a listener already looks, and whatever
|
|
65
|
+
* pi adds to `Usage` later arrives without a change here.
|
|
66
|
+
*/
|
|
67
|
+
export function toReportedUsage(u: LifetimeUsage): Usage | undefined {
|
|
68
|
+
const { input, output, cacheWrite, cacheRead = 0, cost = 0 } = u;
|
|
69
|
+
if (input === 0 && output === 0 && cacheWrite === 0 && cacheRead === 0 && cost === 0) return undefined;
|
|
70
|
+
return {
|
|
71
|
+
input,
|
|
72
|
+
output,
|
|
73
|
+
cacheRead,
|
|
74
|
+
cacheWrite,
|
|
75
|
+
totalTokens: input + output + cacheRead + cacheWrite,
|
|
76
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Subagent spend that the parent session has not been told about yet.
|
|
82
|
+
*
|
|
83
|
+
* Subagents run in their own pi sessions, so none of what they spend appears in
|
|
84
|
+
* the parent's `getSessionStats()`. Pi does aggregate `toolResult.usage` into
|
|
85
|
+
* those stats, though — so the way back into the parent's footer and `/cost` is
|
|
86
|
+
* to hang the spend on a tool result. Background agents finish between tool
|
|
87
|
+
* calls with nothing to hang it on, hence a pool: every assistant message lands
|
|
88
|
+
* here as it happens, and the next tool result this extension returns carries
|
|
89
|
+
* whatever has accumulated.
|
|
90
|
+
*
|
|
91
|
+
* Drain empties it, so each message is reported exactly once no matter how many
|
|
92
|
+
* results are returned or how many agents were running.
|
|
93
|
+
*/
|
|
94
|
+
export class PendingUsagePool {
|
|
95
|
+
private pending: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
|
|
96
|
+
private dirty = false;
|
|
97
|
+
|
|
98
|
+
add(delta: LifetimeUsage): void {
|
|
99
|
+
addUsage(this.pending, delta);
|
|
100
|
+
this.dirty = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Take everything accumulated so far as a pi `Usage`, resetting the pool.
|
|
105
|
+
* Returns undefined when nothing is pending, so callers can leave the tool
|
|
106
|
+
* result untouched rather than attaching a zero.
|
|
107
|
+
*/
|
|
108
|
+
drain(): Usage | undefined {
|
|
109
|
+
if (!this.dirty) return undefined;
|
|
110
|
+
const drained = toReportedUsage(this.pending);
|
|
111
|
+
this.pending = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
|
|
112
|
+
this.dirty = false;
|
|
113
|
+
return drained;
|
|
114
|
+
}
|
|
22
115
|
}
|
|
23
116
|
|
|
24
117
|
/** Minimal shape we read from upstream `getSessionStats()`. */
|
|
25
118
|
export type SessionStatsLike = {
|
|
26
119
|
tokens: { input: number; output: number; cacheWrite: number };
|
|
27
|
-
contextUsage?: { percent: number | null };
|
|
120
|
+
contextUsage?: { percent: number | null; contextWindow?: number | null };
|
|
28
121
|
};
|
|
29
122
|
export type SessionLike = { getSessionStats(): SessionStatsLike };
|
|
30
123
|
|
|
@@ -55,6 +148,33 @@ export function getSessionTokens(session: SessionLike | undefined): number {
|
|
|
55
148
|
*/
|
|
56
149
|
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
|
|
57
150
|
if (!session) return null;
|
|
58
|
-
try {
|
|
59
|
-
|
|
151
|
+
try {
|
|
152
|
+
const percent = session.getSessionStats().contextUsage?.percent;
|
|
153
|
+
// A non-finite/negative percent would render `Context: NaN%` (or a nonsense
|
|
154
|
+
// negative) in the tool text and skew the widget/viewer thresholds.
|
|
155
|
+
return typeof percent === "number" && Number.isFinite(percent) && percent >= 0 ? percent : null;
|
|
156
|
+
} catch { return null; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Format a context-window size for the report: "1.0M" at ≥1M, else "NNNk". */
|
|
160
|
+
function formatContextWindow(contextWindow: number): string {
|
|
161
|
+
return contextWindow >= 1_000_000 ? "1.0M" : `${(contextWindow / 1000).toFixed(0)}k`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Context-window utilization as `<percent>% of <window>` (e.g. "61.0% of 200k"),
|
|
166
|
+
* or null when unavailable (no model contextWindow, post-compaction, a
|
|
167
|
+
* disposed/throwing session, a non-finite/negative percent, or a non-finite
|
|
168
|
+
* window ≤ 0). A real 0.0% renders — the percent gate is not 0.
|
|
169
|
+
*/
|
|
170
|
+
export function formatSessionContext(session: SessionLike | undefined): string | null {
|
|
171
|
+
if (!session) return null;
|
|
172
|
+
try {
|
|
173
|
+
const usage = session.getSessionStats().contextUsage;
|
|
174
|
+
const percent = usage?.percent;
|
|
175
|
+
const contextWindow = usage?.contextWindow;
|
|
176
|
+
if (percent == null || !Number.isFinite(percent) || percent < 0) return null;
|
|
177
|
+
if (contextWindow == null || !Number.isFinite(contextWindow) || contextWindow <= 0) return null;
|
|
178
|
+
return `${percent.toFixed(1)}% of ${formatContextWindow(contextWindow)}`;
|
|
179
|
+
} catch { return null; }
|
|
60
180
|
}
|
package/src/worktree.ts
CHANGED
|
@@ -28,6 +28,25 @@ export interface WorktreeInfo {
|
|
|
28
28
|
workPath: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Project-wide switch for worktree isolation (`worktreeIsolation` in
|
|
33
|
+
* subagents.json). Default `true` — unchanged behaviour.
|
|
34
|
+
*
|
|
35
|
+
* The `"off"` isolation value gives a model a legal way to decline a worktree,
|
|
36
|
+
* but it still depends on the model choosing it. This is the deterministic half
|
|
37
|
+
* of the same fix: on a large repo where every worktree costs real time and
|
|
38
|
+
* disk, turning it off means no caller can create one, whatever it passes.
|
|
39
|
+
*/
|
|
40
|
+
let worktreeIsolationEnabled = true;
|
|
41
|
+
|
|
42
|
+
export function setWorktreeIsolationEnabled(enabled: boolean): void {
|
|
43
|
+
worktreeIsolationEnabled = enabled;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isWorktreeIsolationEnabled(): boolean {
|
|
47
|
+
return worktreeIsolationEnabled;
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
export interface WorktreeCleanupResult {
|
|
32
51
|
/** Whether changes were found in the worktree. */
|
|
33
52
|
hasChanges: boolean;
|
package/src/group-join.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* group-join.ts — Manages grouped background agent completion notifications.
|
|
3
|
-
*
|
|
4
|
-
* Instead of each agent individually nudging the main agent on completion,
|
|
5
|
-
* agents in a group are held until all complete (or a timeout fires),
|
|
6
|
-
* then a single consolidated notification is sent.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { AgentRecord } from "./types.js";
|
|
10
|
-
|
|
11
|
-
export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
|
|
12
|
-
|
|
13
|
-
interface AgentGroup {
|
|
14
|
-
groupId: string;
|
|
15
|
-
agentIds: Set<string>;
|
|
16
|
-
completedRecords: Map<string, AgentRecord>;
|
|
17
|
-
timeoutHandle?: ReturnType<typeof setTimeout>;
|
|
18
|
-
delivered: boolean;
|
|
19
|
-
/** Shorter timeout for stragglers after a partial delivery. */
|
|
20
|
-
isStraggler: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Default timeout: 30s after first completion in a group. */
|
|
24
|
-
const DEFAULT_TIMEOUT = 30_000;
|
|
25
|
-
/** Straggler re-batch timeout: 15s. */
|
|
26
|
-
const STRAGGLER_TIMEOUT = 15_000;
|
|
27
|
-
|
|
28
|
-
export class GroupJoinManager {
|
|
29
|
-
private groups = new Map<string, AgentGroup>();
|
|
30
|
-
private agentToGroup = new Map<string, string>();
|
|
31
|
-
|
|
32
|
-
constructor(
|
|
33
|
-
private deliverCb: DeliveryCallback,
|
|
34
|
-
private groupTimeout = DEFAULT_TIMEOUT,
|
|
35
|
-
) {}
|
|
36
|
-
|
|
37
|
-
/** Register a group of agent IDs that should be joined. */
|
|
38
|
-
registerGroup(groupId: string, agentIds: string[]): void {
|
|
39
|
-
const group: AgentGroup = {
|
|
40
|
-
groupId,
|
|
41
|
-
agentIds: new Set(agentIds),
|
|
42
|
-
completedRecords: new Map(),
|
|
43
|
-
delivered: false,
|
|
44
|
-
isStraggler: false,
|
|
45
|
-
};
|
|
46
|
-
this.groups.set(groupId, group);
|
|
47
|
-
for (const id of agentIds) {
|
|
48
|
-
this.agentToGroup.set(id, groupId);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Called when an agent completes.
|
|
54
|
-
* Returns:
|
|
55
|
-
* - 'pass' — agent is not grouped, caller should send individual nudge
|
|
56
|
-
* - 'held' — result held, waiting for group completion
|
|
57
|
-
* - 'delivered' — this completion triggered the group notification
|
|
58
|
-
*/
|
|
59
|
-
onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
|
|
60
|
-
const groupId = this.agentToGroup.get(record.id);
|
|
61
|
-
if (!groupId) return 'pass';
|
|
62
|
-
|
|
63
|
-
const group = this.groups.get(groupId);
|
|
64
|
-
if (!group || group.delivered) return 'pass';
|
|
65
|
-
|
|
66
|
-
group.completedRecords.set(record.id, record);
|
|
67
|
-
|
|
68
|
-
// All done — deliver immediately
|
|
69
|
-
if (group.completedRecords.size >= group.agentIds.size) {
|
|
70
|
-
this.deliver(group, false);
|
|
71
|
-
return 'delivered';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// First completion in this batch — start timeout
|
|
75
|
-
if (!group.timeoutHandle) {
|
|
76
|
-
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
|
|
77
|
-
group.timeoutHandle = setTimeout(() => {
|
|
78
|
-
this.onTimeout(group);
|
|
79
|
-
}, timeout);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return 'held';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private onTimeout(group: AgentGroup): void {
|
|
86
|
-
if (group.delivered) return;
|
|
87
|
-
group.timeoutHandle = undefined;
|
|
88
|
-
|
|
89
|
-
// Partial delivery — some agents still running
|
|
90
|
-
const remaining = new Set<string>();
|
|
91
|
-
for (const id of group.agentIds) {
|
|
92
|
-
if (!group.completedRecords.has(id)) remaining.add(id);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Clean up agentToGroup for delivered agents (they won't complete again)
|
|
96
|
-
for (const id of group.completedRecords.keys()) {
|
|
97
|
-
this.agentToGroup.delete(id);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Deliver what we have
|
|
101
|
-
this.deliverCb([...group.completedRecords.values()], true);
|
|
102
|
-
|
|
103
|
-
// Set up straggler group for remaining agents
|
|
104
|
-
group.completedRecords.clear();
|
|
105
|
-
group.agentIds = remaining;
|
|
106
|
-
group.isStraggler = true;
|
|
107
|
-
// Timeout will be started when the next straggler completes
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private deliver(group: AgentGroup, partial: boolean): void {
|
|
111
|
-
if (group.timeoutHandle) {
|
|
112
|
-
clearTimeout(group.timeoutHandle);
|
|
113
|
-
group.timeoutHandle = undefined;
|
|
114
|
-
}
|
|
115
|
-
group.delivered = true;
|
|
116
|
-
this.deliverCb([...group.completedRecords.values()], partial);
|
|
117
|
-
this.cleanupGroup(group.groupId);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private cleanupGroup(groupId: string): void {
|
|
121
|
-
const group = this.groups.get(groupId);
|
|
122
|
-
if (!group) return;
|
|
123
|
-
for (const id of group.agentIds) {
|
|
124
|
-
this.agentToGroup.delete(id);
|
|
125
|
-
}
|
|
126
|
-
this.groups.delete(groupId);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Check if an agent is in a group. */
|
|
130
|
-
isGrouped(agentId: string): boolean {
|
|
131
|
-
return this.agentToGroup.has(agentId);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
dispose(): void {
|
|
135
|
-
for (const group of this.groups.values()) {
|
|
136
|
-
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
|
|
137
|
-
}
|
|
138
|
-
this.groups.clear();
|
|
139
|
-
this.agentToGroup.clear();
|
|
140
|
-
}
|
|
141
|
-
}
|