pi-squire 0.2.2 → 0.3.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/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 run(args: string[]): Promise<string> {
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 = { record: Recorded; state: DelegationState; name: string; sessionPath: string | undefined };
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
- return { record, state, name: recordedName(record), sessionPath };
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 details = { delegations: views.map(viewDetails) };
239
- if (views.length === 0) return toolResult("No delegations are recorded in this session.", details);
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
- return toolResult(`${heading}\n\n${views.map(viewText).join("\n\n")}`, details);
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(pi: ExtensionAPI, results: Results, tmux: TmuxClient): Tracking {
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,