@shanepadgett/tau-agent 0.15.0 → 0.17.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.
Files changed (57) hide show
  1. package/docs/subagents.md +25 -6
  2. package/extensions/attention/README.md +1 -0
  3. package/extensions/attention/index.ts +38 -0
  4. package/extensions/context/README.md +13 -3
  5. package/extensions/context/definitions.ts +3 -15
  6. package/extensions/context/evidence.ts +516 -0
  7. package/extensions/context/index.ts +114 -116
  8. package/extensions/context/panel.ts +60 -0
  9. package/extensions/context/settings.ts +30 -1
  10. package/extensions/context/sync.ts +171 -686
  11. package/extensions/context/validation.ts +4 -1
  12. package/extensions/context/write-scope.ts +109 -0
  13. package/extensions/context-pruning/README.md +22 -0
  14. package/extensions/context-pruning/file-evidence.ts +265 -0
  15. package/extensions/context-pruning/index.ts +379 -0
  16. package/extensions/context-pruning/projection.ts +108 -0
  17. package/extensions/context-pruning/prune.ts +346 -0
  18. package/extensions/context-pruning/render.ts +179 -0
  19. package/extensions/context-pruning/settings.ts +41 -0
  20. package/extensions/explore/README.md +3 -1
  21. package/extensions/explore/autoread.ts +85 -22
  22. package/extensions/explore/full-file-knowledge.ts +234 -0
  23. package/extensions/explore/index.ts +2 -3
  24. package/extensions/explore/read-cache.ts +150 -86
  25. package/extensions/explore/read-snapshots.ts +17 -4
  26. package/extensions/explore/read.ts +57 -38
  27. package/extensions/footer/index.ts +62 -60
  28. package/extensions/patch/README.md +1 -1
  29. package/extensions/patch/index.ts +19 -5
  30. package/extensions/run-summary/index.ts +5 -5
  31. package/extensions/silent-command-runner/README.md +1 -1
  32. package/extensions/silent-command-runner/index.ts +45 -28
  33. package/extensions/soul/prompt.ts +3 -1
  34. package/extensions/subagent/README.md +21 -5
  35. package/extensions/subagent/agents/context-sync.md +90 -0
  36. package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
  37. package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
  38. package/extensions/subagent/agents/review.md +48 -0
  39. package/extensions/subagent/agents/web-research.md +6 -0
  40. package/extensions/subagent/agents.ts +15 -2
  41. package/extensions/subagent/cmux-dashboard.ts +454 -0
  42. package/extensions/subagent/index.ts +181 -234
  43. package/extensions/subagent/render.ts +1 -1
  44. package/extensions/subagent/resume.ts +78 -0
  45. package/extensions/subagent/run.ts +213 -118
  46. package/extensions/subagent/runtime.ts +856 -0
  47. package/extensions/subagent/session-resource.ts +169 -0
  48. package/extensions/tau-help/help.md +7 -3
  49. package/extensions/turn-budget/index.ts +8 -36
  50. package/package.json +2 -2
  51. package/schemas/tau.schema.json +48 -1
  52. package/shared/context-pruning-state.ts +364 -0
  53. package/shared/events.ts +18 -0
  54. package/shared/model-fallback/index.ts +21 -10
  55. package/shared/model-fallback/types.ts +5 -3
  56. package/shared/settings/load.ts +78 -1
  57. package/shared/tool-row-state.ts +21 -1
@@ -0,0 +1,364 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+
3
+ const CONTEXT_PRUNE_TOOL = "context_prune";
4
+ const AUTOREAD_MESSAGE_TYPE = "tau.autoread";
5
+
6
+ export interface ContextPruneRefreshedFileV1 {
7
+ path: string;
8
+ rowId: string;
9
+ servedHash: string;
10
+ }
11
+
12
+ export interface ContextPruneDeferredFileV1 {
13
+ path: string;
14
+ reason: string;
15
+ relevantWhen: string;
16
+ }
17
+
18
+ export interface ContextPruneDetailsV1 {
19
+ v: 1;
20
+ status: "applied" | "skipped";
21
+ anchorToolCallId: string;
22
+ newlyPrunedToolCallIds: string[];
23
+ newlyPrunedAutoreadRowIds: string[];
24
+ retainedToolCallIds: string[];
25
+ retainedAutoreadRowIds: string[];
26
+ refreshedFiles: ContextPruneRefreshedFileV1[];
27
+ deferredFiles: ContextPruneDeferredFileV1[];
28
+ tokensBefore: number;
29
+ tokensAfter: number;
30
+ tokensReclaimed: number;
31
+ }
32
+
33
+ export interface ActiveContextPruningState {
34
+ latestAnchorToolCallId: string | undefined;
35
+ prunedToolCallIds: ReadonlySet<string>;
36
+ prunedAutoreadRowIds: ReadonlySet<string>;
37
+ deferredFiles: readonly ContextPruneDeferredFileV1[];
38
+ }
39
+
40
+ const RUNTIME_ENABLED_KEY = Symbol.for("@earendil-works/tau-agent/context-pruning-enabled");
41
+ const runtimeState = globalThis as typeof globalThis & { [key: symbol]: unknown };
42
+
43
+ export function setContextPruningEnabled(enabled: boolean): void {
44
+ runtimeState[RUNTIME_ENABLED_KEY] = enabled;
45
+ }
46
+
47
+ function isContextPruningEnabled(): boolean {
48
+ return runtimeState[RUNTIME_ENABLED_KEY] === true;
49
+ }
50
+
51
+ export function parseContextPruneDetailsV1(value: unknown): ContextPruneDetailsV1 | undefined {
52
+ if (
53
+ !hasExactKeys(value, [
54
+ "v",
55
+ "status",
56
+ "anchorToolCallId",
57
+ "newlyPrunedToolCallIds",
58
+ "newlyPrunedAutoreadRowIds",
59
+ "retainedToolCallIds",
60
+ "retainedAutoreadRowIds",
61
+ "refreshedFiles",
62
+ "deferredFiles",
63
+ "tokensBefore",
64
+ "tokensAfter",
65
+ "tokensReclaimed",
66
+ ])
67
+ )
68
+ return undefined;
69
+ if (value.v !== 1 || (value.status !== "applied" && value.status !== "skipped")) return undefined;
70
+ if (!isNonEmptyString(value.anchorToolCallId)) return undefined;
71
+
72
+ const newlyPrunedToolCallIds = parseUniqueStrings(value.newlyPrunedToolCallIds);
73
+ const newlyPrunedAutoreadRowIds = parseUniqueStrings(value.newlyPrunedAutoreadRowIds);
74
+ const retainedToolCallIds = parseUniqueStrings(value.retainedToolCallIds);
75
+ const retainedAutoreadRowIds = parseUniqueStrings(value.retainedAutoreadRowIds);
76
+ const refreshedFiles = parseRefreshedFiles(value.refreshedFiles);
77
+ const deferredFiles = parseDeferredFiles(value.deferredFiles);
78
+ if (
79
+ !newlyPrunedToolCallIds ||
80
+ !newlyPrunedAutoreadRowIds ||
81
+ !retainedToolCallIds ||
82
+ !retainedAutoreadRowIds ||
83
+ !refreshedFiles ||
84
+ !deferredFiles
85
+ )
86
+ return undefined;
87
+ if (
88
+ !isNonNegativeFinite(value.tokensBefore) ||
89
+ !isNonNegativeFinite(value.tokensAfter) ||
90
+ !isFiniteNumber(value.tokensReclaimed) ||
91
+ value.tokensBefore - value.tokensAfter !== value.tokensReclaimed
92
+ )
93
+ return undefined;
94
+ if (hasOverlap(newlyPrunedToolCallIds, retainedToolCallIds)) return undefined;
95
+ if (hasOverlap(newlyPrunedAutoreadRowIds, retainedAutoreadRowIds)) return undefined;
96
+ if (value.status === "skipped" && (newlyPrunedToolCallIds.length > 0 || newlyPrunedAutoreadRowIds.length > 0))
97
+ return undefined;
98
+
99
+ return {
100
+ v: 1,
101
+ status: value.status,
102
+ anchorToolCallId: value.anchorToolCallId,
103
+ newlyPrunedToolCallIds,
104
+ newlyPrunedAutoreadRowIds,
105
+ retainedToolCallIds,
106
+ retainedAutoreadRowIds,
107
+ refreshedFiles,
108
+ deferredFiles,
109
+ tokensBefore: value.tokensBefore,
110
+ tokensAfter: value.tokensAfter,
111
+ tokensReclaimed: value.tokensReclaimed,
112
+ };
113
+ }
114
+
115
+ export function replayContextPruningState(
116
+ branch: readonly SessionEntry[],
117
+ enabled = isContextPruningEnabled(),
118
+ ): ActiveContextPruningState {
119
+ const prunedToolCallIds = new Set<string>();
120
+ const prunedAutoreadRowIds = new Set<string>();
121
+ const state: ActiveContextPruningState = {
122
+ latestAnchorToolCallId: undefined,
123
+ prunedToolCallIds,
124
+ prunedAutoreadRowIds,
125
+ deferredFiles: [],
126
+ };
127
+ if (!enabled) return state;
128
+
129
+ const toolCalls = new Map<string, { count: number; name: string; index: number }>();
130
+ const toolResults = new Map<string, { count: number; name: string; index: number }>();
131
+ const autoreads = new Map<
132
+ string,
133
+ { count: number; path: string; servedHash: string; source: string; batchId: string; index: number }
134
+ >();
135
+ for (let index = 0; index < branch.length; index += 1) {
136
+ const entry = branch[index];
137
+ if (entry.type === "message") {
138
+ const message = entry.message;
139
+ if (message.role === "assistant") {
140
+ for (const block of message.content) {
141
+ if (block.type !== "toolCall") continue;
142
+ const current = toolCalls.get(block.id);
143
+ toolCalls.set(block.id, { count: (current?.count ?? 0) + 1, name: block.name, index });
144
+ }
145
+ } else if (message.role === "toolResult") {
146
+ const current = toolResults.get(message.toolCallId);
147
+ toolResults.set(message.toolCallId, {
148
+ count: (current?.count ?? 0) + 1,
149
+ name: message.toolName,
150
+ index,
151
+ });
152
+ }
153
+ continue;
154
+ }
155
+ if (entry.type !== "custom_message" || entry.customType !== AUTOREAD_MESSAGE_TYPE) continue;
156
+ const autoread = parseAutoreadReference(entry.details);
157
+ if (!autoread) continue;
158
+ const current = autoreads.get(autoread.rowId);
159
+ autoreads.set(autoread.rowId, {
160
+ count: (current?.count ?? 0) + 1,
161
+ path: autoread.path,
162
+ servedHash: autoread.servedHash,
163
+ source: autoread.source,
164
+ batchId: autoread.batchId,
165
+ index,
166
+ });
167
+ }
168
+
169
+ for (let resultIndex = 0; resultIndex < branch.length; resultIndex += 1) {
170
+ const entry = branch[resultIndex];
171
+ if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
172
+ const result = entry.message;
173
+ if (result.toolName !== CONTEXT_PRUNE_TOOL) continue;
174
+ const details = parseContextPruneDetailsV1(result.details);
175
+ if (!details || details.status !== "applied" || details.anchorToolCallId !== result.toolCallId) continue;
176
+ if (!isCompleteExchange(details.anchorToolCallId, CONTEXT_PRUNE_TOOL, toolCalls, toolResults)) continue;
177
+ const anchorCallIndex = toolCalls.get(details.anchorToolCallId)?.index;
178
+ if (anchorCallIndex === undefined || anchorCallIndex >= resultIndex) continue;
179
+ if (details.newlyPrunedToolCallIds.includes(details.anchorToolCallId)) continue;
180
+ if (details.retainedToolCallIds.includes(details.anchorToolCallId)) continue;
181
+ if (
182
+ !allCompleteExchanges(details.newlyPrunedToolCallIds, toolCalls, toolResults) ||
183
+ !allCompleteExchanges(details.retainedToolCallIds, toolCalls, toolResults) ||
184
+ !allAutoreads(details.newlyPrunedAutoreadRowIds, autoreads) ||
185
+ !allAutoreads(details.retainedAutoreadRowIds, autoreads)
186
+ )
187
+ continue;
188
+ if (
189
+ details.newlyPrunedToolCallIds.some((id) => !exchangePrecedes(id, anchorCallIndex, toolCalls, toolResults)) ||
190
+ details.retainedToolCallIds.some((id) => !exchangePrecedes(id, anchorCallIndex, toolCalls, toolResults)) ||
191
+ details.newlyPrunedAutoreadRowIds.some((id) => {
192
+ const row = autoreads.get(id);
193
+ return row === undefined || row.index >= anchorCallIndex;
194
+ })
195
+ )
196
+ continue;
197
+ if (
198
+ details.newlyPrunedToolCallIds.some((id) => prunedToolCallIds.has(id)) ||
199
+ details.retainedToolCallIds.some((id) => prunedToolCallIds.has(id)) ||
200
+ details.newlyPrunedAutoreadRowIds.some((id) => prunedAutoreadRowIds.has(id)) ||
201
+ details.retainedAutoreadRowIds.some((id) => prunedAutoreadRowIds.has(id))
202
+ )
203
+ continue;
204
+ if (
205
+ details.refreshedFiles.some((file) => {
206
+ const row = autoreads.get(file.rowId);
207
+ return (
208
+ row?.count !== 1 ||
209
+ row.path !== file.path ||
210
+ row.servedHash !== file.servedHash ||
211
+ row.source !== "context-pruning" ||
212
+ row.batchId !== details.anchorToolCallId ||
213
+ !details.retainedAutoreadRowIds.includes(file.rowId)
214
+ );
215
+ })
216
+ )
217
+ continue;
218
+ const refreshedRowIds = new Set(details.refreshedFiles.map((file) => file.rowId));
219
+ if (
220
+ details.retainedAutoreadRowIds.some((id) => {
221
+ const row = autoreads.get(id);
222
+ return row === undefined || (row.index >= anchorCallIndex && !refreshedRowIds.has(id));
223
+ })
224
+ )
225
+ continue;
226
+
227
+ for (const id of details.newlyPrunedToolCallIds) prunedToolCallIds.add(id);
228
+ for (const id of details.newlyPrunedAutoreadRowIds) prunedAutoreadRowIds.add(id);
229
+ state.latestAnchorToolCallId = details.anchorToolCallId;
230
+ state.deferredFiles = details.deferredFiles;
231
+ }
232
+ return state;
233
+ }
234
+
235
+ function parseRefreshedFiles(value: unknown): ContextPruneRefreshedFileV1[] | undefined {
236
+ if (!Array.isArray(value)) return undefined;
237
+ const files: ContextPruneRefreshedFileV1[] = [];
238
+ const rowIds = new Set<string>();
239
+ for (const item of value) {
240
+ if (!hasExactKeys(item, ["path", "rowId", "servedHash"])) return undefined;
241
+ if (!isNonEmptyString(item.path) || !isNonEmptyString(item.rowId) || !isNonEmptyString(item.servedHash))
242
+ return undefined;
243
+ if (rowIds.has(item.rowId)) return undefined;
244
+ rowIds.add(item.rowId);
245
+ files.push({ path: item.path, rowId: item.rowId, servedHash: item.servedHash });
246
+ }
247
+ return files;
248
+ }
249
+
250
+ function parseDeferredFiles(value: unknown): ContextPruneDeferredFileV1[] | undefined {
251
+ if (!Array.isArray(value)) return undefined;
252
+ const files: ContextPruneDeferredFileV1[] = [];
253
+ for (const item of value) {
254
+ if (!hasExactKeys(item, ["path", "reason", "relevantWhen"])) return undefined;
255
+ if (!isNonEmptyString(item.path) || !isNonEmptyString(item.reason) || !isNonEmptyString(item.relevantWhen))
256
+ return undefined;
257
+ files.push({ path: item.path, reason: item.reason, relevantWhen: item.relevantWhen });
258
+ }
259
+ return files;
260
+ }
261
+
262
+ function parseUniqueStrings(value: unknown): string[] | undefined {
263
+ if (!Array.isArray(value)) return undefined;
264
+ const strings: string[] = [];
265
+ const seen = new Set<string>();
266
+ for (const item of value) {
267
+ if (!isNonEmptyString(item) || seen.has(item)) return undefined;
268
+ seen.add(item);
269
+ strings.push(item);
270
+ }
271
+ return strings;
272
+ }
273
+
274
+ function parseAutoreadReference(
275
+ value: unknown,
276
+ ): { rowId: string; path: string; servedHash: string; source: string; batchId: string } | undefined {
277
+ if (!isRecord(value) || value.status !== "read") return undefined;
278
+ if (
279
+ !isNonEmptyString(value.rowId) ||
280
+ !isNonEmptyString(value.path) ||
281
+ !isNonEmptyString(value.cwd) ||
282
+ !isNonEmptyString(value.source) ||
283
+ !isNonEmptyString(value.batchId)
284
+ )
285
+ return undefined;
286
+ if (!isRecord(value.readCache) || !isNonEmptyString(value.readCache.servedHash)) return undefined;
287
+ return {
288
+ rowId: value.rowId,
289
+ path: value.path,
290
+ servedHash: value.readCache.servedHash,
291
+ source: value.source,
292
+ batchId: value.batchId,
293
+ };
294
+ }
295
+
296
+ function allCompleteExchanges(
297
+ ids: readonly string[],
298
+ calls: ReadonlyMap<string, { count: number; name: string; index: number }>,
299
+ results: ReadonlyMap<string, { count: number; name: string; index: number }>,
300
+ ): boolean {
301
+ return ids.every((id) => {
302
+ const call = calls.get(id);
303
+ return call !== undefined && isCompleteExchange(id, call.name, calls, results);
304
+ });
305
+ }
306
+
307
+ function isCompleteExchange(
308
+ id: string,
309
+ name: string,
310
+ calls: ReadonlyMap<string, { count: number; name: string; index: number }>,
311
+ results: ReadonlyMap<string, { count: number; name: string; index: number }>,
312
+ ): boolean {
313
+ const call = calls.get(id);
314
+ const result = results.get(id);
315
+ return (
316
+ call?.count === 1 &&
317
+ result?.count === 1 &&
318
+ call.name === name &&
319
+ result.name === name &&
320
+ call.index < result.index
321
+ );
322
+ }
323
+
324
+ function exchangePrecedes(
325
+ id: string,
326
+ anchorCallIndex: number,
327
+ calls: ReadonlyMap<string, { index: number }>,
328
+ results: ReadonlyMap<string, { index: number }>,
329
+ ): boolean {
330
+ const call = calls.get(id);
331
+ const result = results.get(id);
332
+ return call !== undefined && result !== undefined && call.index < result.index && result.index < anchorCallIndex;
333
+ }
334
+
335
+ function allAutoreads(ids: readonly string[], autoreads: ReadonlyMap<string, { count: number }>): boolean {
336
+ return ids.every((id) => autoreads.get(id)?.count === 1);
337
+ }
338
+
339
+ function hasOverlap(left: readonly string[], right: readonly string[]): boolean {
340
+ const rightSet = new Set(right);
341
+ return left.some((item) => rightSet.has(item));
342
+ }
343
+
344
+ function hasExactKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
345
+ if (!isRecord(value)) return false;
346
+ const actual = Object.keys(value);
347
+ return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
348
+ }
349
+
350
+ function isRecord(value: unknown): value is Record<string, unknown> {
351
+ return typeof value === "object" && value !== null && !Array.isArray(value);
352
+ }
353
+
354
+ function isNonEmptyString(value: unknown): value is string {
355
+ return typeof value === "string" && value.length > 0;
356
+ }
357
+
358
+ function isNonNegativeFinite(value: unknown): value is number {
359
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
360
+ }
361
+
362
+ function isFiniteNumber(value: unknown): value is number {
363
+ return typeof value === "number" && Number.isFinite(value);
364
+ }
package/shared/events.ts CHANGED
@@ -7,6 +7,13 @@ export type TauAgentEvents = {
7
7
  body?: string;
8
8
  source?: string;
9
9
  };
10
+ "tau:attention.hold.acquire": {
11
+ id: string;
12
+ };
13
+ "tau:attention.hold.release": {
14
+ id: string;
15
+ disposition: "notify" | "discard";
16
+ };
10
17
  "tau:file-mutation.applied": {
11
18
  source: "patch";
12
19
  toolCallId: string;
@@ -37,6 +44,17 @@ export type TauAgentEvents = {
37
44
  rowId: string;
38
45
  state?: ToolRowVisualState;
39
46
  };
47
+ /** @internal Complete-state replay for Tau-owned tool-row renderers. */
48
+ "tau:tool-row-state.snapshot.requested": {
49
+ requester: string;
50
+ };
51
+ /** @internal Complete-state replay for Tau-owned tool-row renderers. */
52
+ "tau:tool-row-state.snapshot": {
53
+ states: ReadonlyArray<{
54
+ rowId: string;
55
+ state: ToolRowVisualState;
56
+ }>;
57
+ };
40
58
  };
41
59
 
42
60
  export interface TauFooterItem {
@@ -1,6 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Api, AssistantMessage, Message, Model, ThinkingLevel, Tool } from "@earendil-works/pi-ai";
3
- import { completeSimple } from "@earendil-works/pi-ai/compat";
4
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
4
  import { loadTauExtensionSettings, updateTauExtensionSettings } from "../settings/load.ts";
6
5
  import { errorText, truncAt } from "../text.ts";
@@ -38,11 +37,20 @@ export async function resolveCandidates(
38
37
  const key = `${model.provider}/${model.id}`;
39
38
  if (seen.has(key)) return;
40
39
 
40
+ const provider = ctx.modelRegistry.getProvider(model.provider);
41
+ if (!provider) return;
41
42
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
42
- if (!auth.ok || !auth.apiKey) return;
43
+ if (!auth.ok) return;
43
44
 
44
45
  seen.add(key);
45
- candidates.push({ model, apiKey: auth.apiKey, headers: auth.headers, reasoning });
46
+ candidates.push({
47
+ model,
48
+ provider,
49
+ apiKey: auth.apiKey,
50
+ headers: auth.headers,
51
+ env: auth.env,
52
+ reasoning,
53
+ });
46
54
  };
47
55
 
48
56
  for (const preferred of preferredModels) {
@@ -211,13 +219,16 @@ function completeCandidate(
211
219
  sessionId: string,
212
220
  tools?: Tool[],
213
221
  ): Promise<AssistantMessage> {
214
- return completeSimple(candidate.model, tools ? { messages: [...messages], tools } : { messages: [...messages] }, {
215
- apiKey: candidate.apiKey,
216
- headers: candidate.headers,
217
- signal: ctx.signal,
218
- reasoning: candidate.reasoning,
219
- sessionId,
220
- });
222
+ return candidate.provider
223
+ .streamSimple(candidate.model, tools ? { messages: [...messages], tools } : { messages: [...messages] }, {
224
+ apiKey: candidate.apiKey,
225
+ headers: candidate.headers,
226
+ env: candidate.env,
227
+ signal: ctx.signal,
228
+ reasoning: candidate.reasoning,
229
+ sessionId,
230
+ })
231
+ .result();
221
232
  }
222
233
 
223
234
  function responseText(response: AssistantMessage): string {
@@ -1,8 +1,10 @@
1
- import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
1
+ import type { Api, Model, Provider, ProviderEnv, ProviderHeaders, ThinkingLevel } from "@earendil-works/pi-ai";
2
2
 
3
3
  export interface ModelCandidate {
4
4
  model: Model<Api>;
5
- apiKey: string;
6
- headers: Record<string, string> | undefined;
5
+ provider: Provider;
6
+ apiKey: string | undefined;
7
+ headers: ProviderHeaders | undefined;
8
+ env: ProviderEnv | undefined;
7
9
  reasoning: ThinkingLevel | undefined;
8
10
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { IsObject, type TSchema } from "typebox";
2
3
  import { Value } from "typebox/value";
3
4
  import type { JsonObject, TauExtensionSettingsSpec } from "./define.ts";
4
5
  import { asObject, readJsonStatus, writeJsonObject } from "./json.ts";
@@ -22,7 +23,9 @@ export async function loadTauExtensionSettings<TDefaults extends JsonObject>(
22
23
  if (projectStatus?.exists && projectStatus.ok)
23
24
  merged = mergeSettings(merged, extensionSection(projectStatus.value, spec.key));
24
25
 
25
- return Value.Check(spec.schema, merged) ? merged : spec.defaults;
26
+ if (Value.Check(spec.schema, merged)) return merged;
27
+ const repaired = replaceInvalidProperties(spec.schema, merged, spec.defaults);
28
+ return repaired !== NO_DEFAULT && Value.Check(spec.schema, repaired) ? (repaired as TDefaults) : spec.defaults;
26
29
  }
27
30
 
28
31
  export async function updateTauExtensionSettings<TDefaults extends JsonObject>(
@@ -57,3 +60,77 @@ async function updateTauSettings(
57
60
  function extensionSection(root: JsonObject, key: string): JsonObject | undefined {
58
61
  return asObject(asObject(root.extensions)?.[key]);
59
62
  }
63
+
64
+ const NO_DEFAULT = Symbol("no-default");
65
+
66
+ function replaceInvalidProperties(
67
+ schema: TSchema,
68
+ value: unknown,
69
+ documentedDefault: unknown | typeof NO_DEFAULT,
70
+ ): unknown | typeof NO_DEFAULT {
71
+ if (Value.Check(schema, value)) return value;
72
+
73
+ const valueObject = asObject(value);
74
+ if (IsObject(schema) && valueObject) {
75
+ const defaultObject = documentedDefault === NO_DEFAULT ? undefined : asObject(documentedDefault);
76
+ const repaired: JsonObject = {};
77
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
78
+ for (const [key, propertySchema] of Object.entries(schema.properties)) {
79
+ if (!Object.hasOwn(valueObject, key)) {
80
+ if (!required.has(key)) continue;
81
+ const requiredDefault = propertyDefault(propertySchema, defaultObject, key);
82
+ if (requiredDefault === NO_DEFAULT) return validatedDefault(schema, documentedDefault);
83
+ repaired[key] = Value.Clone(requiredDefault);
84
+ continue;
85
+ }
86
+
87
+ const propertyValue = replaceInvalidProperties(
88
+ propertySchema,
89
+ valueObject[key],
90
+ propertyDefault(propertySchema, defaultObject, key),
91
+ );
92
+ if (propertyValue !== NO_DEFAULT) repaired[key] = propertyValue;
93
+ else if (required.has(key)) return validatedDefault(schema, documentedDefault);
94
+ }
95
+
96
+ const schemaObject = asObject(schema);
97
+ const additionalProperties = schemaObject?.additionalProperties;
98
+ if (additionalProperties !== false) {
99
+ for (const [key, additionalValue] of Object.entries(valueObject)) {
100
+ if (Object.hasOwn(schema.properties, key)) continue;
101
+ if (additionalProperties === undefined || additionalProperties === true) repaired[key] = additionalValue;
102
+ else {
103
+ const repairedAdditional = replaceInvalidProperties(
104
+ additionalProperties as TSchema,
105
+ additionalValue,
106
+ NO_DEFAULT,
107
+ );
108
+ if (repairedAdditional !== NO_DEFAULT) repaired[key] = repairedAdditional;
109
+ }
110
+ }
111
+ }
112
+
113
+ if (Value.Check(schema, repaired)) return repaired;
114
+ }
115
+
116
+ return validatedDefault(schema, documentedDefault);
117
+ }
118
+
119
+ function propertyDefault(
120
+ schema: TSchema,
121
+ defaultObject: JsonObject | undefined,
122
+ key: string,
123
+ ): unknown | typeof NO_DEFAULT {
124
+ if (defaultObject && Object.hasOwn(defaultObject, key)) return defaultObject[key];
125
+ const schemaObject = asObject(schema);
126
+ return schemaObject && Object.hasOwn(schemaObject, "default") ? schemaObject.default : NO_DEFAULT;
127
+ }
128
+
129
+ function validatedDefault(
130
+ schema: TSchema,
131
+ documentedDefault: unknown | typeof NO_DEFAULT,
132
+ ): unknown | typeof NO_DEFAULT {
133
+ return documentedDefault !== NO_DEFAULT && Value.Check(schema, documentedDefault)
134
+ ? Value.Clone(documentedDefault)
135
+ : NO_DEFAULT;
136
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
- import { onTauEvent } from "./events.js";
2
+ import { emitTauEvent, onTauEvent } from "./events.js";
3
3
 
4
4
  export type ToolRowVisualState = "pruned";
5
5
 
@@ -17,11 +17,30 @@ export interface ToolRowStateStore {
17
17
  export function createToolRowStateStore(pi: EventAPI, owner: string): ToolRowStateStore {
18
18
  const states = new Map<string, ToolRowVisualState>();
19
19
  const invalidators = new Map<string, () => void>();
20
+
21
+ function requestSnapshot(): void {
22
+ emitTauEvent(pi, "tau:tool-row-state.snapshot.requested", { requester: owner });
23
+ }
24
+
20
25
  onTauEvent(pi, owner, "tau:tool-row-state.set", ({ rowId, state }) => {
21
26
  if (state === undefined) states.delete(rowId);
22
27
  else states.set(rowId, state);
23
28
  invalidators.get(rowId)?.();
24
29
  });
30
+ onTauEvent(pi, owner, "tau:tool-row-state.snapshot", ({ states: snapshot }) => {
31
+ const nextStates = new Map(snapshot.map(({ rowId, state }) => [rowId, state] as const));
32
+ const changedRows = [...new Set([...states.keys(), ...nextStates.keys()])].filter(
33
+ (rowId) => states.get(rowId) !== nextStates.get(rowId),
34
+ );
35
+ states.clear();
36
+ for (const [rowId, state] of nextStates) states.set(rowId, state);
37
+ for (const rowId of changedRows) invalidators.get(rowId)?.();
38
+ });
39
+ pi.on("session_start", () => {
40
+ states.clear();
41
+ invalidators.clear();
42
+ requestSnapshot();
43
+ });
25
44
 
26
45
  return {
27
46
  get(rowId) {
@@ -33,6 +52,7 @@ export function createToolRowStateStore(pi: EventAPI, owner: string): ToolRowSta
33
52
  clear() {
34
53
  states.clear();
35
54
  invalidators.clear();
55
+ requestSnapshot();
36
56
  },
37
57
  };
38
58
  }