pi-squire 0.2.2 → 0.3.1
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 +65 -1
- package/README.md +116 -6
- package/package.json +1 -1
- package/src/agents.ts +18 -3
- package/src/child.ts +33 -2
- package/src/index.ts +130 -15
- package/src/limits.ts +242 -0
- package/src/results.ts +16 -0
- package/src/tmux.ts +10 -3
- package/src/tracking.ts +74 -12
- package/src/usage.ts +432 -0
package/src/limits.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Usage-limit marks: which providers and models are out of quota, kept in one
|
|
2
|
+
// JSON file in Pi's agent directory so every Pi session on the machine sees
|
|
3
|
+
// them. A delegate records a mark when its run ends on a usage-limit error;
|
|
4
|
+
// the parent refuses to launch on a marked model until the mark clears.
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
const MINUTE = 60_000;
|
|
11
|
+
const HOUR: number = 60 * MINUTE;
|
|
12
|
+
const FIRST_COOLDOWN: number = 5 * MINUTE;
|
|
13
|
+
const MAX_COOLDOWN: number = 6 * HOUR;
|
|
14
|
+
/** An expired mark stays this long, only so the next hit escalates. */
|
|
15
|
+
const KEEP_EXPIRED: number = 24 * HOUR;
|
|
16
|
+
const MAX_REASON = 300;
|
|
17
|
+
const MAX_DELEGATIONS = 10;
|
|
18
|
+
|
|
19
|
+
export type Mark = {
|
|
20
|
+
/** `provider` for a whole provider, `provider/model` for one model. */
|
|
21
|
+
scope: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
/** Epoch milliseconds. */
|
|
24
|
+
recordedAt: number;
|
|
25
|
+
/** Epoch milliseconds. */
|
|
26
|
+
clearsAt: number;
|
|
27
|
+
source: "reactive" | "proactive";
|
|
28
|
+
hits: number;
|
|
29
|
+
/** The last delegation ids that hit this mark. */
|
|
30
|
+
delegations: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The slice of Pi's model registry this module reads. */
|
|
34
|
+
export type Models = { find(provider: string, id: string): { cost?: { input?: number; output?: number } } | undefined };
|
|
35
|
+
|
|
36
|
+
export const limitsFile = (): string => join(getAgentDir(), "pi-squire-limits.json");
|
|
37
|
+
|
|
38
|
+
function isMark(value: unknown): value is Mark {
|
|
39
|
+
const mark = value as Partial<Mark> | null;
|
|
40
|
+
return (
|
|
41
|
+
typeof mark === "object" &&
|
|
42
|
+
mark !== null &&
|
|
43
|
+
typeof mark.scope === "string" &&
|
|
44
|
+
typeof mark.reason === "string" &&
|
|
45
|
+
Number.isFinite(mark.recordedAt) &&
|
|
46
|
+
Number.isFinite(mark.clearsAt) &&
|
|
47
|
+
(mark.source === "reactive" || mark.source === "proactive") &&
|
|
48
|
+
Number.isFinite(mark.hits) &&
|
|
49
|
+
Array.isArray(mark.delegations)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every mark still worth keeping, active or recently expired. Never throws. */
|
|
54
|
+
export function readMarks(now = Date.now()): Mark[] {
|
|
55
|
+
try {
|
|
56
|
+
const parsed: unknown = JSON.parse(readFileSync(limitsFile(), "utf8"));
|
|
57
|
+
return Array.isArray(parsed) ? parsed.filter(isMark).filter((mark) => mark.clearsAt + KEEP_EXPIRED > now) : [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeMarks(marks: Mark[]): void {
|
|
64
|
+
const file = limitsFile();
|
|
65
|
+
mkdirSync(getAgentDir(), { recursive: true });
|
|
66
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
67
|
+
writeFileSync(temp, JSON.stringify(marks, null, 2));
|
|
68
|
+
renameSync(temp, file);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether a model is free: an id ending in `-free`, or zero cost in the registry. */
|
|
72
|
+
export function isFree(models: Models, provider: string, id: string): boolean {
|
|
73
|
+
if (id.endsWith("-free")) return true;
|
|
74
|
+
const cost = models.find(provider, id)?.cost;
|
|
75
|
+
return cost !== undefined && cost.input === 0 && cost.output === 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The active mark that covers provider/id, if any. A provider mark never covers a free model. */
|
|
79
|
+
export function activeMark(models: Models, provider: string, id: string, now = Date.now()): Mark | undefined {
|
|
80
|
+
const active = readMarks(now).filter((mark) => mark.clearsAt > now);
|
|
81
|
+
return (
|
|
82
|
+
active.find((mark) => mark.scope === `${provider}/${id}`) ??
|
|
83
|
+
(isFree(models, provider, id) ? undefined : active.find((mark) => mark.scope === provider))
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const LIMIT_PATTERN =
|
|
88
|
+
/GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|insufficient_quota|quota exceeded|subscription_sharing_usage_limit_exceeded|usage_limit_reached|usage limit reached|rate_limit_error/i;
|
|
89
|
+
|
|
90
|
+
/** The reset time an error text states: "try again in N units", "retry after", or a `resets_at` field. */
|
|
91
|
+
function statedReset(message: string, now: number): number | undefined {
|
|
92
|
+
const relative = /(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*(second|minute|hour)s?/i.exec(message);
|
|
93
|
+
if (relative !== null) {
|
|
94
|
+
return (
|
|
95
|
+
now + Number(relative[1]) * { second: 1000, minute: MINUTE, hour: HOUR }[relative[2].toLowerCase() as "second"]
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const text = /resets_?at\W{1,4}([0-9][0-9T:.+\-Z]*)/i.exec(message)?.[1];
|
|
99
|
+
if (text === undefined) return undefined;
|
|
100
|
+
return /^\d+(\.\d+)?$/.test(text) ? Number(text) * (Number(text) > 1e12 ? 1 : 1000) : Date.parse(text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type Response = { status: number; headers: Record<string, string> };
|
|
104
|
+
|
|
105
|
+
const UNIT_MS: Record<string, number> = { ms: 1, s: 1000, m: MINUTE, h: HOUR };
|
|
106
|
+
|
|
107
|
+
/** A duration string such as `1s` or `6m0s`, in milliseconds. */
|
|
108
|
+
function duration(text: string): number | undefined {
|
|
109
|
+
const parts = [...text.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h)/g)];
|
|
110
|
+
return parts.length === 0 || parts.map((part) => part[0]).join("") !== text.trim()
|
|
111
|
+
? undefined
|
|
112
|
+
: parts.reduce((sum, part) => sum + Number(part[1]) * UNIT_MS[part[2]], 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The reset time a failed response's headers state, if any, in the order
|
|
117
|
+
* `retry-after`, `anthropic-ratelimit-*-reset`, `x-ratelimit-reset-*`. Only a
|
|
118
|
+
* non-2xx response counts, and only a time in the future.
|
|
119
|
+
*/
|
|
120
|
+
export function headerReset(response: Response | undefined, now = Date.now()): number | undefined {
|
|
121
|
+
if (response === undefined || (response.status >= 200 && response.status < 300)) return undefined;
|
|
122
|
+
const headers = Object.entries(response.headers).map(([name, value]) => [name.toLowerCase(), String(value)] as const);
|
|
123
|
+
const future = (times: (number | undefined)[]): number | undefined => {
|
|
124
|
+
const found = times.filter((time): time is number => time !== undefined && time > now);
|
|
125
|
+
return found.length === 0 ? undefined : Math.max(...found);
|
|
126
|
+
};
|
|
127
|
+
const retry = headers.find(([name]) => name === "retry-after")?.[1].trim();
|
|
128
|
+
let retryTime: number | undefined;
|
|
129
|
+
if (retry !== undefined) retryTime = /^\d+$/.test(retry) ? now + Number(retry) * 1000 : Date.parse(retry);
|
|
130
|
+
const retryAt = future([retryTime]);
|
|
131
|
+
if (retryAt !== undefined) return retryAt;
|
|
132
|
+
const anthropic = future(
|
|
133
|
+
headers.filter(([name]) => /^anthropic-ratelimit-.*-reset$/.test(name)).map(([, value]) => Date.parse(value)),
|
|
134
|
+
);
|
|
135
|
+
if (anthropic !== undefined) return anthropic;
|
|
136
|
+
return future(
|
|
137
|
+
headers
|
|
138
|
+
.filter(([name]) => name === "x-ratelimit-reset-requests" || name === "x-ratelimit-reset-tokens")
|
|
139
|
+
.map(([, value]) => {
|
|
140
|
+
const ms = duration(value);
|
|
141
|
+
return ms === undefined ? undefined : now + ms;
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether an error message (Pi's `<status>: <body>`) is a usage limit, and
|
|
148
|
+
* the reset time the text states, if any. Overloaded and 5xx errors never are.
|
|
149
|
+
*/
|
|
150
|
+
export function classify(message: string, now = Date.now()): { resetAt?: number } | undefined {
|
|
151
|
+
if (/overloaded/i.test(message) || /^\s*5\d\d\b/.test(message)) return undefined;
|
|
152
|
+
if (!LIMIT_PATTERN.test(message) && !/^\s*429\b/.test(message)) return undefined;
|
|
153
|
+
const resetAt = statedReset(message, now);
|
|
154
|
+
return resetAt !== undefined && resetAt > now ? { resetAt } : {};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Record a reactive mark for a delegate's usage-limit failure. The scope is
|
|
159
|
+
* the provider, or only the model for a free model or `FreeUsageLimitError`.
|
|
160
|
+
*/
|
|
161
|
+
export function recordLimit(
|
|
162
|
+
models: Models,
|
|
163
|
+
hit: { provider: string; model: string; message: string; delegation: string; response?: Response },
|
|
164
|
+
now = Date.now(),
|
|
165
|
+
): void {
|
|
166
|
+
const limit = classify(hit.message, now);
|
|
167
|
+
if (limit === undefined) return;
|
|
168
|
+
const single = isFree(models, hit.provider, hit.model) || /FreeUsageLimitError/i.test(hit.message);
|
|
169
|
+
const scope = single ? `${hit.provider}/${hit.model}` : hit.provider;
|
|
170
|
+
const marks = readMarks(now);
|
|
171
|
+
const old = marks.find((mark) => mark.scope === scope);
|
|
172
|
+
const hits = (old?.hits ?? 0) + 1;
|
|
173
|
+
const mark: Mark = {
|
|
174
|
+
scope,
|
|
175
|
+
reason: hit.message.slice(0, MAX_REASON),
|
|
176
|
+
recordedAt: now,
|
|
177
|
+
clearsAt:
|
|
178
|
+
headerReset(hit.response, now) ?? limit.resetAt ?? now + Math.min(FIRST_COOLDOWN * 2 ** (hits - 1), MAX_COOLDOWN),
|
|
179
|
+
source: "reactive",
|
|
180
|
+
hits,
|
|
181
|
+
delegations: [...(old?.delegations ?? []), hit.delegation].slice(-MAX_DELEGATIONS),
|
|
182
|
+
};
|
|
183
|
+
writeMarks([...marks.filter((item) => item !== old), mark]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Record a proactive mark: a quota reading says `provider` is used up. It
|
|
188
|
+
* clears at `clearsAt`, or after the escalating cooldown when none is known.
|
|
189
|
+
* A failed write is swallowed; the mark is returned either way.
|
|
190
|
+
*/
|
|
191
|
+
export function recordProactive(
|
|
192
|
+
provider: string,
|
|
193
|
+
reason: string,
|
|
194
|
+
clearsAt: number | undefined,
|
|
195
|
+
now = Date.now(),
|
|
196
|
+
): Mark {
|
|
197
|
+
const marks = readMarks(now);
|
|
198
|
+
const old = marks.find((mark) => mark.scope === provider);
|
|
199
|
+
const hits = (old?.hits ?? 0) + 1;
|
|
200
|
+
const mark: Mark = {
|
|
201
|
+
scope: provider,
|
|
202
|
+
reason: reason.slice(0, MAX_REASON),
|
|
203
|
+
recordedAt: now,
|
|
204
|
+
clearsAt: clearsAt ?? now + Math.min(FIRST_COOLDOWN * 2 ** (hits - 1), MAX_COOLDOWN),
|
|
205
|
+
source: "proactive",
|
|
206
|
+
hits,
|
|
207
|
+
delegations: old?.delegations ?? [],
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
writeMarks([...marks.filter((item) => item !== old), mark]);
|
|
211
|
+
} catch {
|
|
212
|
+
// The reading is real even if the file is not writable: the caller blocks on the returned mark.
|
|
213
|
+
}
|
|
214
|
+
return mark;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Remove marks for `target`: `all`, a provider (its mark and every model mark
|
|
219
|
+
* under it) or `provider/model` (that mark and the provider mark covering it).
|
|
220
|
+
* Removing also forgets the escalation history. Returns the scopes of the
|
|
221
|
+
* active marks removed; when none, the file is left alone.
|
|
222
|
+
*/
|
|
223
|
+
export function clearMarks(target: string, now = Date.now()): string[] {
|
|
224
|
+
const marks = readMarks(now);
|
|
225
|
+
const hit = (mark: Mark): boolean =>
|
|
226
|
+
target === "all" ||
|
|
227
|
+
mark.scope === target ||
|
|
228
|
+
(target.includes("/") ? mark.scope === target.slice(0, target.indexOf("/")) : mark.scope.startsWith(`${target}/`));
|
|
229
|
+
const cleared = marks.filter((mark) => hit(mark) && mark.clearsAt > now).map((mark) => mark.scope);
|
|
230
|
+
if (cleared.length > 0) writeMarks(marks.filter((mark) => !hit(mark)));
|
|
231
|
+
return cleared;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Remove the mark with exactly this scope, and with it the escalation history. False when it could not be written. */
|
|
235
|
+
export function removeMark(scope: string, now = Date.now()): boolean {
|
|
236
|
+
try {
|
|
237
|
+
writeMarks(readMarks(now).filter((mark) => mark.scope !== scope));
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
package/src/results.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { join } from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
|
+
import { readMarks } from "./limits.ts";
|
|
13
|
+
|
|
12
14
|
/** The session entry type both delegation records and results use. */
|
|
13
15
|
export const CUSTOM_TYPE = "delegate";
|
|
14
16
|
/** The footer entry that counts delegations whose result has not arrived. */
|
|
@@ -157,6 +159,18 @@ export function findSession(dir: string | undefined, id: string): string | undef
|
|
|
157
159
|
return name === undefined ? undefined : join(dir, name);
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
/** The line naming a usage-limit mark this delegation's failure recorded, if any. */
|
|
163
|
+
function limitLine(id: string): string | undefined {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const mark = readMarks(now).find((item) => item.delegations.includes(id));
|
|
166
|
+
if (mark === undefined) return undefined;
|
|
167
|
+
const scope = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
|
|
168
|
+
const until = new Date(mark.clearsAt).toISOString();
|
|
169
|
+
return `Usage limit: the delegate hit a usage limit on ${scope}; ${
|
|
170
|
+
mark.clearsAt > now ? `it is marked until ${until} and later delegations skip it.` : `it was marked until ${until}.`
|
|
171
|
+
}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
/** The one message the parent sees for a settled delegation. */
|
|
161
175
|
function resultText(delegation: Delegation, payload: Inbound, session: string | undefined): string {
|
|
162
176
|
const reply = payload.envelope;
|
|
@@ -176,9 +190,11 @@ function resultText(delegation: Delegation, payload: Inbound, session: string |
|
|
|
176
190
|
const cut = cap(reply.body, BODY_CAP);
|
|
177
191
|
const result =
|
|
178
192
|
cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
|
|
193
|
+
const limit = reply.status === "failed" ? limitLine(delegation.id) : undefined;
|
|
179
194
|
return [
|
|
180
195
|
`[delegate] Result from ${delegation.agent} (${delegation.model}), delegation ${delegation.id}, request ${delegation.requestId}.`,
|
|
181
196
|
`Status: ${reply.status}`,
|
|
197
|
+
...(limit === undefined ? [] : [limit]),
|
|
182
198
|
quote,
|
|
183
199
|
`Envelope: ${payload.path}`,
|
|
184
200
|
`Delegate session: ${session ?? delegation.id}`,
|
package/src/tmux.ts
CHANGED
|
@@ -25,10 +25,14 @@ export interface TmuxClient {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Run one tmux command and return its stdout; never a shell. */
|
|
28
|
-
function
|
|
28
|
+
function runTmux(args: string[], timeout: number): Promise<string> {
|
|
29
29
|
return new Promise((resolve, reject) => {
|
|
30
|
-
execFile("tmux", args, { encoding: "utf8" }, (error, stdout, stderr) => {
|
|
30
|
+
execFile("tmux", args, { encoding: "utf8", timeout }, (error, stdout, stderr) => {
|
|
31
31
|
if (error) {
|
|
32
|
+
if (error.killed) {
|
|
33
|
+
reject(new Error(`tmux ${args[0]} timed out after ${timeout / 1000} s`));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
32
36
|
const detail = typeof stderr === "string" && stderr.trim() !== "" ? stderr.trim() : error.message;
|
|
33
37
|
reject(new Error(detail));
|
|
34
38
|
return;
|
|
@@ -43,7 +47,10 @@ function run(args: string[]): Promise<string> {
|
|
|
43
47
|
* `TMUX_PANE`, not in whatever session a client happens to have attached, so
|
|
44
48
|
* it works with several clients on one server.
|
|
45
49
|
*/
|
|
46
|
-
export function createTmuxClient(): TmuxClient {
|
|
50
|
+
export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClient {
|
|
51
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
52
|
+
const run = (args: string[]) => runTmux(args, timeoutMs);
|
|
53
|
+
|
|
47
54
|
/** The id of the session this pane belongs to, when known. */
|
|
48
55
|
async function targetSession(): Promise<string | undefined> {
|
|
49
56
|
const pane = process.env.TMUX_PANE;
|
package/src/tracking.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// and the `delegation_status` and `delegation_close` tools. A delegation's
|
|
3
3
|
// state comes from `results.ts` (`deriveState`); this module only asks tmux
|
|
4
4
|
// about the window and words what it is told.
|
|
5
|
+
import { statSync } from "node:fs";
|
|
6
|
+
|
|
5
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
8
|
import { Type } from "typebox";
|
|
7
9
|
|
|
10
|
+
import { type Mark, readMarks } from "./limits.ts";
|
|
8
11
|
import {
|
|
9
12
|
type DelegationState,
|
|
10
13
|
deriveState,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
type WindowState,
|
|
16
19
|
} from "./results.ts";
|
|
17
20
|
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
21
|
+
import type { ReadingError } from "./usage.ts";
|
|
18
22
|
|
|
19
23
|
/** The hook a tick emits so the provider claims waiting replies before windows are checked. */
|
|
20
24
|
const SCAN = "message:scan";
|
|
@@ -97,21 +101,45 @@ function stateLine(state: DelegationState, record: Recorded): string {
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
/** One delegation as `delegation_status` reports it; its text and details both read from this. */
|
|
100
|
-
type View = {
|
|
104
|
+
type View = {
|
|
105
|
+
record: Recorded;
|
|
106
|
+
state: DelegationState;
|
|
107
|
+
name: string;
|
|
108
|
+
sessionPath: string | undefined;
|
|
109
|
+
/** When the session file was last written; set only for a running delegation with a file. */
|
|
110
|
+
lastActive: Date | undefined;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
|
|
114
|
+
function ago(ms: number): string {
|
|
115
|
+
const minutes = Math.floor(ms / 60_000);
|
|
116
|
+
if (minutes < 1) return "just now";
|
|
117
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
118
|
+
return `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
|
|
119
|
+
}
|
|
101
120
|
|
|
102
121
|
/** The view of a delegation: its state and the fields the text and details share. */
|
|
103
122
|
function view(record: Recorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): View {
|
|
104
123
|
const state = deriveState(record, windowState(windows, record));
|
|
105
124
|
const sessionPath = record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id);
|
|
106
|
-
|
|
125
|
+
let lastActive: Date | undefined;
|
|
126
|
+
if (state === "running" && sessionPath !== undefined) {
|
|
127
|
+
try {
|
|
128
|
+
lastActive = statSync(sessionPath).mtime;
|
|
129
|
+
} catch {
|
|
130
|
+
// a failed stat counts as no file
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { record, state, name: recordedName(record), sessionPath, lastActive };
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
/** The text block for one delegation. */
|
|
110
|
-
function viewText({ record, state, name, sessionPath }: View): string {
|
|
137
|
+
function viewText({ record, state, name, sessionPath, lastActive }: View): string {
|
|
138
|
+
const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
|
|
111
139
|
const lines = [
|
|
112
140
|
`${record.id} ${record.agent} (${record.model}, thinking ${record.thinking})`,
|
|
113
141
|
` name: ${name}`,
|
|
114
|
-
` state: ${stateLine(state, record)}`,
|
|
142
|
+
` state: ${stateLine(state, record)}${seen}`,
|
|
115
143
|
` window: ${record.windowName} (${record.windowId})`,
|
|
116
144
|
` auto-exit: ${autoExitLine(record)}`,
|
|
117
145
|
` session: ${sessionPath ?? `${record.id} (no session file yet)`}`,
|
|
@@ -121,7 +149,7 @@ function viewText({ record, state, name, sessionPath }: View): string {
|
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
/** The structured details for one delegation. */
|
|
124
|
-
function viewDetails({ record, name, sessionPath }: View): Record<string, unknown> {
|
|
152
|
+
function viewDetails({ record, name, sessionPath, lastActive }: View): Record<string, unknown> {
|
|
125
153
|
return {
|
|
126
154
|
id: record.id,
|
|
127
155
|
name,
|
|
@@ -136,9 +164,23 @@ function viewDetails({ record, name, sessionPath }: View): Record<string, unknow
|
|
|
136
164
|
status: record.result?.status,
|
|
137
165
|
envelopePath: record.result?.envelopePath,
|
|
138
166
|
sessionPath,
|
|
167
|
+
...(lastActive === undefined ? {} : { lastActiveAt: lastActive.toISOString() }),
|
|
139
168
|
};
|
|
140
169
|
}
|
|
141
170
|
|
|
171
|
+
/** The usage-limit marks in force now, as `delegation_status` reports them. */
|
|
172
|
+
function activeMarks(): { scope: string; source: Mark["source"]; reason: string; clearsAt: string }[] {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
return readMarks(now)
|
|
175
|
+
.filter((mark) => mark.clearsAt > now)
|
|
176
|
+
.map(({ scope, source, reason, clearsAt }) => ({
|
|
177
|
+
scope,
|
|
178
|
+
source,
|
|
179
|
+
reason: reason.length > 120 ? `${reason.slice(0, 119)}…` : reason,
|
|
180
|
+
clearsAt: new Date(clearsAt).toISOString(),
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
|
|
142
184
|
/** Every delegation, or the one named; throws when a named id is unknown. */
|
|
143
185
|
function selectRecords(results: Results, id: string | undefined): Recorded[] {
|
|
144
186
|
const all = results.list();
|
|
@@ -216,12 +258,12 @@ function createPoll(
|
|
|
216
258
|
}
|
|
217
259
|
|
|
218
260
|
/** The `delegation_status` tool. */
|
|
219
|
-
function statusTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
261
|
+
function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => ReadingError[]): ToolDef {
|
|
220
262
|
return {
|
|
221
263
|
name: "delegation_status",
|
|
222
264
|
label: "Delegation status",
|
|
223
265
|
description:
|
|
224
|
-
"The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Names the delegation id, agent, session name, model, thinking, window, delegate session path and result envelope path. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
|
|
266
|
+
"The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Names the delegation id, agent, session name, model, thinking, window, delegate session path and result envelope path. A running delegation also shows when its session file was last written; a long gap may mean it is stuck but does not prove it. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
|
|
225
267
|
parameters: Type.Object({
|
|
226
268
|
id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
|
|
227
269
|
}),
|
|
@@ -235,10 +277,25 @@ function statusTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
|
235
277
|
const records = selectRecords(results, params.id);
|
|
236
278
|
const windows = await listWindows(tmux);
|
|
237
279
|
const views = records.map((record) => view(record, windows, ctx));
|
|
238
|
-
const
|
|
239
|
-
|
|
280
|
+
const marks = activeMarks();
|
|
281
|
+
const errors = readingErrors();
|
|
282
|
+
const details = { delegations: views.map(viewDetails), marks, readingErrors: errors };
|
|
283
|
+
const block =
|
|
284
|
+
marks.length === 0 && errors.length === 0
|
|
285
|
+
? []
|
|
286
|
+
: [
|
|
287
|
+
[
|
|
288
|
+
"Usage-limit marks:",
|
|
289
|
+
...marks.map((m) => `- ${m.scope} (${m.source}) until ${m.clearsAt}: ${m.reason}`),
|
|
290
|
+
...errors.map((e) => `- could not read ${e.provider} quota at ${e.at}: ${e.reason}`),
|
|
291
|
+
].join("\n"),
|
|
292
|
+
];
|
|
240
293
|
const heading = `${views.length} delegation${views.length === 1 ? "" : "s"}:`;
|
|
241
|
-
|
|
294
|
+
const parts =
|
|
295
|
+
views.length === 0
|
|
296
|
+
? ["No delegations are recorded in this session.", ...block]
|
|
297
|
+
: [`${heading}\n\n${views.map(viewText).join("\n\n")}`, ...block];
|
|
298
|
+
return toolResult(parts.join("\n\n"), details);
|
|
242
299
|
},
|
|
243
300
|
};
|
|
244
301
|
}
|
|
@@ -292,11 +349,16 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
|
292
349
|
}
|
|
293
350
|
|
|
294
351
|
/** The poll and the two tools, over the extension API, the records and tmux. */
|
|
295
|
-
export function createTracking(
|
|
352
|
+
export function createTracking(
|
|
353
|
+
pi: ExtensionAPI,
|
|
354
|
+
results: Results,
|
|
355
|
+
tmux: TmuxClient,
|
|
356
|
+
readingErrors: () => ReadingError[] = () => [],
|
|
357
|
+
): Tracking {
|
|
296
358
|
const poll = createPoll(pi, results, tmux);
|
|
297
359
|
return {
|
|
298
360
|
registerTools(): void {
|
|
299
|
-
pi.registerTool(statusTool(results, tmux) as never);
|
|
361
|
+
pi.registerTool(statusTool(results, tmux, readingErrors) as never);
|
|
300
362
|
pi.registerTool(closeTool(results, tmux) as never);
|
|
301
363
|
},
|
|
302
364
|
startPolling: poll.start,
|