tau-coding-agent 0.1.5 → 0.2.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 (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,5 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import { lock } from "proper-lockfile";
3
5
  import {
4
6
  getAgentDir,
5
7
  type ExtensionAPI,
@@ -7,7 +9,8 @@ import {
7
9
  } from "@earendil-works/pi-coding-agent";
8
10
 
9
11
  const STATUS_KEY = "openai-verbosity";
10
- const SUPPORTED_APIS = new Set([
12
+ const CHAT_COMPLETIONS_API = "openai-completions";
13
+ const RESPONSES_APIS = new Set([
11
14
  "openai-responses",
12
15
  "openai-codex-responses",
13
16
  "azure-openai-responses",
@@ -18,11 +21,11 @@ type VerbositySetting = Verbosity | "auto";
18
21
  type JsonObject = Record<string, unknown>;
19
22
  type ModelInfo = NonNullable<ExtensionContext["model"]>;
20
23
  type VerbosityConfig = {
21
- models: Record<string, Verbosity>;
24
+ models: Record<string, VerbositySetting>;
22
25
  };
23
26
 
24
27
  function emptyConfig(): VerbosityConfig {
25
- return { models: {} };
28
+ return { models: Object.create(null) };
26
29
  }
27
30
 
28
31
  function getConfigPath(): string {
@@ -61,12 +64,13 @@ function getVerbosityArgumentCompletions(
61
64
  function parseConfig(value: unknown): VerbosityConfig {
62
65
  if (!isObject(value) || !isObject(value.models)) return emptyConfig();
63
66
 
64
- const models: Record<string, Verbosity> = {};
67
+ const models: Record<string, VerbositySetting> = Object.create(null);
65
68
  for (const [key, rawVerbosity] of Object.entries(value.models)) {
66
69
  const normalizedKey = key.trim();
67
70
  if (!normalizedKey || typeof rawVerbosity !== "string") continue;
71
+ if (key !== normalizedKey && Object.hasOwn(value.models, normalizedKey)) continue;
68
72
 
69
- const verbosity = normalizeVerbosity(rawVerbosity);
73
+ const verbosity = parseVerbositySetting(rawVerbosity);
70
74
  if (!verbosity) continue;
71
75
 
72
76
  models[normalizedKey] = verbosity;
@@ -90,14 +94,82 @@ async function loadConfig(): Promise<VerbosityConfig> {
90
94
  }
91
95
  }
92
96
 
93
- async function saveConfig(config: VerbosityConfig): Promise<void> {
97
+ // Resolve the target before locking and renaming: aliases must share a lock, not lose their symlink.
98
+ async function resolveConfigPath(): Promise<string> {
94
99
  const configPath = getConfigPath();
95
100
  await mkdir(path.dirname(configPath), { recursive: true });
96
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
101
+ try {
102
+ return await realpath(configPath);
103
+ } catch (error) {
104
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
105
+ const entry = await lstat(configPath).catch((error: NodeJS.ErrnoException) => {
106
+ if (error.code !== "ENOENT") throw error;
107
+ return undefined;
108
+ });
109
+ if (entry) throw new Error("Cannot save through an unresolved configuration symlink");
110
+ return path.join(await realpath(path.dirname(configPath)), path.basename(configPath));
111
+ }
112
+ }
113
+
114
+ async function saveConfig(
115
+ model: Pick<ModelInfo, "provider" | "id">,
116
+ setting: VerbositySetting,
117
+ onSaved: (config: VerbosityConfig) => void,
118
+ ): Promise<void> {
119
+ const configPath = await resolveConfigPath();
120
+ let compromised: Error | undefined;
121
+ const release = await lock(configPath, {
122
+ realpath: false,
123
+ retries: { retries: 10, factor: 1, minTimeout: 100, maxTimeout: 100 },
124
+ // The library's default callback throws asynchronously and can terminate Pi.
125
+ onCompromised: (error) => {
126
+ compromised = error;
127
+ },
128
+ });
129
+ let temporary: string | undefined;
130
+ try {
131
+ const raw = await readFile(configPath, "utf8").catch((error: NodeJS.ErrnoException) => {
132
+ if (error.code !== "ENOENT") throw error;
133
+ return '{"models":{}}';
134
+ });
135
+ const current: unknown = JSON.parse(raw);
136
+ if (!isObject(current) || !isObject(current.models)) {
137
+ throw new Error("Configuration must be an object with a models object");
138
+ }
139
+ const next = { ...current, models: { ...current.models, [getExactModelKey(model)]: setting } };
140
+ const nextConfig = parseConfig(next);
141
+ if (compromised) throw compromised;
142
+ const candidate = `${configPath}.${randomUUID()}.tmp`;
143
+ const file = await open(candidate, "wx", 0o600);
144
+ temporary = candidate;
145
+ try {
146
+ await file.writeFile(`${JSON.stringify(next, null, 2)}\n`, "utf8");
147
+ } finally {
148
+ await file.close();
149
+ }
150
+ // This is a cooperative mtime lease, not fencing against arbitrary OS pauses or lock removal.
151
+ if (compromised) throw compromised;
152
+ await rename(temporary, configPath);
153
+ temporary = undefined;
154
+ // Publication succeeded even if releasing the lock later fails.
155
+ onSaved(nextConfig);
156
+ } finally {
157
+ try {
158
+ if (temporary) await unlink(temporary);
159
+ } finally {
160
+ await release().catch((error: unknown) => {
161
+ throw compromised ?? error;
162
+ });
163
+ }
164
+ }
97
165
  }
98
166
 
99
167
  function isSupportedModel(model: ExtensionContext["model"]): model is ModelInfo {
100
- return !!model && SUPPORTED_APIS.has(model.api);
168
+ return (
169
+ !!model &&
170
+ /^gpt-(?:5|6)(?:[.-]|$)/i.test(model.id) &&
171
+ (model.api === CHAT_COMPLETIONS_API || RESPONSES_APIS.has(model.api))
172
+ );
101
173
  }
102
174
 
103
175
  function getExactModelKey(model: Pick<ModelInfo, "provider" | "id">): string {
@@ -107,28 +179,9 @@ function getExactModelKey(model: Pick<ModelInfo, "provider" | "id">): string {
107
179
  function resolveVerbosity(
108
180
  config: VerbosityConfig,
109
181
  model: Pick<ModelInfo, "provider" | "id">,
110
- ): { key?: string; verbosity?: Verbosity } {
111
- const exactKey = getExactModelKey(model);
112
- const exactVerbosity = config.models[exactKey];
113
- if (exactVerbosity) return { key: exactKey, verbosity: exactVerbosity };
114
-
115
- const sharedVerbosity = config.models[model.id];
116
- if (sharedVerbosity) return { key: model.id, verbosity: sharedVerbosity };
117
-
118
- return {};
119
- }
120
-
121
- function setVerbosity(
122
- config: VerbosityConfig,
123
- key: string,
124
- verbosity: VerbositySetting,
125
- ): VerbosityConfig {
126
- const models = { ...config.models };
127
-
128
- if (verbosity === "auto") delete models[key];
129
- else models[key] = verbosity;
130
-
131
- return { models };
182
+ ): { verbosity?: Verbosity } {
183
+ const setting = config.models[getExactModelKey(model)] ?? config.models[model.id];
184
+ return { verbosity: setting === "auto" ? undefined : setting };
132
185
  }
133
186
 
134
187
  function updateStatus(ctx: ExtensionContext, config: VerbosityConfig): void {
@@ -144,6 +197,7 @@ function updateStatus(ctx: ExtensionContext, config: VerbosityConfig): void {
144
197
 
145
198
  export default function openaiVerbosityExtension(pi: ExtensionAPI): void {
146
199
  let config = emptyConfig();
200
+ const pending = new Set<Promise<void>>();
147
201
 
148
202
  pi.registerCommand("verbosity", {
149
203
  description: "Set OpenAI response verbosity for the current model",
@@ -162,27 +216,31 @@ export default function openaiVerbosityExtension(pi: ExtensionAPI): void {
162
216
  }
163
217
 
164
218
  if (!isSupportedModel(model)) {
165
- ctx.ui.notify(
166
- "Current model does not support verbosity control. Supported APIs: openai-responses, openai-codex-responses, azure-openai-responses.",
167
- "warning",
168
- );
219
+ ctx.ui.notify("Current model does not support OpenAI verbosity control.", "warning");
169
220
  updateStatus(ctx, config);
170
221
  return;
171
222
  }
172
223
 
173
- const resolved = resolveVerbosity(config, model);
174
- const configKey = resolved.key ?? getExactModelKey(model);
175
- const nextConfig = setVerbosity(config, configKey, verbosity);
176
-
224
+ let saved = false;
225
+ const operation = saveConfig(model, verbosity, (nextConfig) => {
226
+ config = nextConfig;
227
+ saved = true;
228
+ });
229
+ pending.add(operation);
177
230
  try {
178
- await saveConfig(nextConfig);
231
+ await operation;
179
232
  } catch (error) {
180
233
  const message = error instanceof Error ? error.message : String(error);
181
- ctx.ui.notify(`Failed to save ${getConfigPath()}: ${message}`, "error");
234
+ if (saved) updateStatus(ctx, config);
235
+ ctx.ui.notify(
236
+ `${saved ? "Saved, but cleanup failed for" : "Failed to save"} ${getConfigPath()}: ${message}`,
237
+ saved ? "warning" : "error",
238
+ );
182
239
  return;
240
+ } finally {
241
+ pending.delete(operation);
183
242
  }
184
243
 
185
- config = nextConfig;
186
244
  updateStatus(ctx, config);
187
245
 
188
246
  if (verbosity === "auto") {
@@ -194,6 +252,10 @@ export default function openaiVerbosityExtension(pi: ExtensionAPI): void {
194
252
  },
195
253
  });
196
254
 
255
+ pi.on("session_shutdown", async () => {
256
+ await Promise.allSettled(pending);
257
+ });
258
+
197
259
  pi.on("session_start", async (_event, ctx) => {
198
260
  config = await loadConfig();
199
261
  updateStatus(ctx, config);
@@ -213,6 +275,10 @@ export default function openaiVerbosityExtension(pi: ExtensionAPI): void {
213
275
  const payload = event.payload;
214
276
  if (!isObject(payload)) return;
215
277
 
278
+ if (model.api === CHAT_COMPLETIONS_API) {
279
+ return { ...payload, verbosity };
280
+ }
281
+
216
282
  const text = isObject(payload.text) ? payload.text : {};
217
283
  return {
218
284
  ...payload,
@@ -19,6 +19,7 @@ import {
19
19
  type AgentEndMessage,
20
20
  type AgentEndMessages,
21
21
  type FixPassAgentTracker,
22
+ type ReviewRuntime,
22
23
  } from "./runtime.js";
23
24
  import type { ParsedRequest, ReviewMessageDetails } from "./schema.js";
24
25
 
@@ -60,16 +61,18 @@ export async function prepareFixReviewDetails(
60
61
  pi: ExtensionAPI,
61
62
  ctx: ExtensionCommandContext,
62
63
  request: ParsedRequest,
64
+ runtime: ReviewRuntime,
63
65
  forceFreshReview = false,
64
66
  ): Promise<ReviewMessageDetails | null> {
67
+ if (runtime.closed) return null;
65
68
  let reviewDetails = forceFreshReview ? null : getLastMessageReviewDetails(ctx);
66
69
 
67
70
  if (reviewDetails && reviewMatchesFixRequest(reviewDetails, request)) {
68
71
  const currentFingerprint = await computeCurrentFingerprint(
69
- pi,
70
- ctx.cwd,
72
+ { cwd: ctx.cwd },
71
73
  reviewDetails.scope.mode === "working-tree" || reviewDetails.scope.mode === "folder",
72
74
  );
75
+ if (runtime.closed) return null;
73
76
  const hasStalePayload = reviewDetails.staleness?.status === "stale";
74
77
  const isStaleNow = !fingerprintsEqual(reviewDetails.fingerprint, currentFingerprint);
75
78
  if (hasStalePayload || isStaleNow) {
@@ -80,7 +83,8 @@ export async function prepareFixReviewDetails(
80
83
  notify(ctx, REVIEW_STALE_REUSE_WARNING, "warning");
81
84
  }
82
85
  } else {
83
- const reviewResult = await runReviewPipeline(pi, ctx, request, "fix");
86
+ const reviewResult = await runReviewPipeline(pi, ctx, request, "fix", runtime);
87
+ if (runtime.closed) return null;
84
88
  if (!reviewResult.ok) {
85
89
  if (reviewResult.error === REVIEW_CANCELLED_ERROR) {
86
90
  notify(ctx, REVIEW_CANCELLED_ERROR, "error");
@@ -209,9 +213,10 @@ export async function runFixLoop(
209
213
  request: ParsedRequest,
210
214
  agentTracker: FixPassAgentTracker,
211
215
  reviewMessageQueue: ReviewMessageQueue,
216
+ runtime: ReviewRuntime,
212
217
  ): Promise<void> {
213
- for (;;) {
214
- const reviewDetails = await prepareFixReviewDetails(pi, ctx, request, true);
218
+ while (!runtime.closed) {
219
+ const reviewDetails = await prepareFixReviewDetails(pi, ctx, request, runtime, true);
215
220
  if (!reviewDetails) return;
216
221
 
217
222
  const beforeFixFingerprint = reviewDetails.fingerprint;
@@ -223,12 +228,16 @@ export async function runFixLoop(
223
228
  agentTracker,
224
229
  reviewMessageQueue,
225
230
  );
231
+ if (runtime.closed) return;
226
232
  if (wasLastAssistantAborted(fixMessages)) {
227
233
  notify(ctx, "Fix loop stopped: fix pass was aborted.", "warning");
228
234
  return;
229
235
  }
230
236
 
231
- const afterFixFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, true);
237
+ const afterFixFingerprint = await computeCurrentFingerprint(
238
+ { cwd: ctx.cwd },
239
+ reviewDetails.scope.mode === "working-tree" || reviewDetails.scope.mode === "folder",
240
+ );
232
241
  if (fingerprintsEqual(beforeFixFingerprint, afterFixFingerprint)) {
233
242
  notify(ctx, "Fix loop stopped: fix pass made no repository changes.", "warning");
234
243
  return;