@tt-a1i/openpi 0.4.0 → 0.5.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 (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -20,7 +20,15 @@ import { execFile } from "node:child_process";
20
20
  import { tmpdir } from "node:os";
21
21
  import { dirname, join } from "node:path";
22
22
  import { promisify } from "node:util";
23
- import { Crypto, Data, Effect, Encoding, FileSystem, Stream } from "effect";
23
+ import {
24
+ Crypto,
25
+ Data,
26
+ Effect,
27
+ Encoding,
28
+ FileSystem,
29
+ Layer,
30
+ Stream,
31
+ } from "effect";
24
32
  import { FetchHttpClient, HttpClient } from "effect/unstable/http";
25
33
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
26
34
 
@@ -161,6 +169,12 @@ export class InstallError extends Data.TaggedError("InstallError")<{
161
169
  readonly cause?: unknown;
162
170
  }> {}
163
171
 
172
+ export class BinaryDownloadError extends Data.TaggedError(
173
+ "BinaryDownloadError",
174
+ )<{
175
+ readonly message: string;
176
+ }> {}
177
+
164
178
  export interface BinaryEnv {
165
179
  /** True when the executable runs and supports the flags this tool requires. */
166
180
  readonly probe: (command: string, tool: ToolName) => Effect.Effect<boolean>;
@@ -237,9 +251,9 @@ export function readBoundedResponse<E, R>(
237
251
  return Effect.gen(function* () {
238
252
  const declaredLength = Number(response.headers["content-length"]);
239
253
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
240
- return yield* Effect.fail(
241
- new Error(`download exceeds the ${maxBytes}-byte size limit`),
242
- );
254
+ return yield* new BinaryDownloadError({
255
+ message: `download exceeds the ${maxBytes}-byte size limit`,
256
+ });
243
257
  }
244
258
 
245
259
  const result = yield* Stream.runFoldEffect(
@@ -249,7 +263,9 @@ export function readBoundedResponse<E, R>(
249
263
  const totalBytes = accumulator.totalBytes + chunk.byteLength;
250
264
  if (totalBytes > maxBytes) {
251
265
  return Effect.fail(
252
- new Error(`download exceeds the ${maxBytes}-byte size limit`),
266
+ new BinaryDownloadError({
267
+ message: `download exceeds the ${maxBytes}-byte size limit`,
268
+ }),
253
269
  );
254
270
  }
255
271
  return Effect.sync(() => {
@@ -272,9 +288,9 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
272
288
 
273
289
  for (let redirects = 0; redirects <= MAX_DOWNLOAD_REDIRECTS; redirects++) {
274
290
  if (url.protocol !== "https:") {
275
- return yield* Effect.fail(
276
- new Error(`refusing non-HTTPS download URL: ${url.href}`),
277
- );
291
+ return yield* new BinaryDownloadError({
292
+ message: `refusing non-HTTPS download URL: ${url.href}`,
293
+ });
278
294
  }
279
295
 
280
296
  const result = yield* Effect.scoped(
@@ -283,31 +299,27 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
283
299
  if ([301, 302, 303, 307, 308].includes(response.status)) {
284
300
  const location = response.headers.location;
285
301
  if (!location) {
286
- return yield* Effect.fail(
287
- new Error(`redirect from ${url.href} had no location header`),
288
- );
302
+ return yield* new BinaryDownloadError({
303
+ message: `redirect from ${url.href} had no location header`,
304
+ });
289
305
  }
290
306
  if (redirects === MAX_DOWNLOAD_REDIRECTS) {
291
- return yield* Effect.fail(
292
- new Error(
293
- `download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
294
- ),
295
- );
307
+ return yield* new BinaryDownloadError({
308
+ message: `download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
309
+ });
296
310
  }
297
311
  if (!URL.canParse(location, url)) {
298
- return yield* Effect.fail(
299
- new Error(
300
- `download returned an invalid redirect URL: ${location}`,
301
- ),
302
- );
312
+ return yield* new BinaryDownloadError({
313
+ message: `download returned an invalid redirect URL: ${location}`,
314
+ });
303
315
  }
304
316
  return { _tag: "Redirect" as const, url: new URL(location, url) };
305
317
  }
306
318
 
307
319
  if (response.status < 200 || response.status >= 300) {
308
- return yield* Effect.fail(
309
- new Error(`download failed with HTTP ${response.status}`),
310
- );
320
+ return yield* new BinaryDownloadError({
321
+ message: `download failed with HTTP ${response.status}`,
322
+ });
311
323
  }
312
324
  return {
313
325
  _tag: "Complete" as const,
@@ -320,7 +332,9 @@ function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
320
332
  url = result.url;
321
333
  }
322
334
 
323
- return yield* Effect.fail(new Error("download redirect handling failed"));
335
+ return yield* new BinaryDownloadError({
336
+ message: "download redirect handling failed",
337
+ });
324
338
  });
325
339
  }
326
340
 
@@ -344,9 +358,9 @@ export const liveBinaryEnv: BinaryEnv = {
344
358
  install: (asset, destination) => {
345
359
  const install = Effect.gen(function* () {
346
360
  if (!URL.canParse(asset.url)) {
347
- return yield* Effect.fail(
348
- new Error(`invalid download URL: ${asset.url}`),
349
- );
361
+ return yield* new BinaryDownloadError({
362
+ message: `invalid download URL: ${asset.url}`,
363
+ });
350
364
  }
351
365
 
352
366
  const url = new URL(asset.url);
@@ -360,11 +374,9 @@ export const liveBinaryEnv: BinaryEnv = {
360
374
  const digestBytes = yield* crypto.digest("SHA-256", bytes);
361
375
  const digest = Encoding.encodeHex(digestBytes);
362
376
  if (digest !== asset.sha256) {
363
- return yield* Effect.fail(
364
- new Error(
365
- `SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
366
- ),
367
- );
377
+ return yield* new BinaryDownloadError({
378
+ message: `SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
379
+ });
368
380
  }
369
381
 
370
382
  const workDir = yield* fs.makeTempDirectoryScoped({
@@ -384,9 +396,9 @@ export const liveBinaryEnv: BinaryEnv = {
384
396
  }),
385
397
  ).pipe(Effect.timeout(60_000));
386
398
  if (tarExitCode !== ChildProcessSpawner.ExitCode(0)) {
387
- return yield* Effect.fail(
388
- new Error(`tar failed with exit code ${tarExitCode}`),
389
- );
399
+ return yield* new BinaryDownloadError({
400
+ message: `tar failed with exit code ${tarExitCode}`,
401
+ });
390
402
  }
391
403
 
392
404
  const extracted = join(workDir, asset.archiveDir, asset.binaryName);
@@ -401,13 +413,17 @@ export const liveBinaryEnv: BinaryEnv = {
401
413
  yield* fs.rename(stagedDestination, destination);
402
414
  });
403
415
 
404
- return install.pipe(
405
- Effect.scoped,
406
- Effect.provide(NodeServices.layer),
407
- Effect.provide(NodeHttpClient.layerFetch),
408
- Effect.provideService(FetchHttpClient.RequestInit, {
416
+ const InstallLayer = Layer.mergeAll(
417
+ NodeServices.layer,
418
+ NodeHttpClient.layerFetch,
419
+ Layer.succeed(FetchHttpClient.RequestInit, {
409
420
  redirect: "manual",
410
421
  }),
422
+ );
423
+
424
+ return install.pipe(
425
+ Effect.scoped,
426
+ Effect.provide(InstallLayer),
411
427
  Effect.mapError(
412
428
  (cause) =>
413
429
  new InstallError({
@@ -13,15 +13,13 @@
13
13
 
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
- import { StringEnum } from "@earendil-works/pi-ai";
17
16
  import type {
18
17
  AgentToolResult,
19
18
  ExtensionAPI,
20
- ExtensionContext,
21
19
  } from "@earendil-works/pi-coding-agent";
22
20
  import { Text } from "@earendil-works/pi-tui";
23
21
  import { Cause, Effect, Exit } from "effect";
24
- import { type Static, Type } from "typebox";
22
+ import { Type } from "typebox";
25
23
  import { formatCapturedOutput } from "../file-search/src/output.ts";
26
24
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
27
25
  import {
@@ -7,37 +7,11 @@ import {
7
7
  MODEL_INFO_CHANNEL,
8
8
  REFRESH_CHANNEL,
9
9
  } from "../shared/dashboard-state.ts";
10
+ import { createSessionMetricsTracker } from "./session-metrics.ts";
10
11
 
11
12
  const CHARS_PER_ESTIMATED_TOKEN = 4;
12
13
  const LIVE_UPDATE_INTERVAL_MS = 200;
13
14
 
14
- function getSessionMetrics(ctx: ExtensionContext) {
15
- let cost = 0;
16
- let cacheRead = 0;
17
- let promptTokens = 0;
18
-
19
- for (const entry of ctx.sessionManager.getBranch()) {
20
- let usage;
21
- if (entry.type === "message") {
22
- const message = entry.message;
23
- if (message.role === "assistant" || message.role === "toolResult") {
24
- usage = message.usage;
25
- }
26
- } else if (entry.type === "compaction" || entry.type === "branch_summary") {
27
- usage = entry.usage;
28
- }
29
- if (!usage) continue;
30
- cost += usage.cost.total;
31
- cacheRead += usage.cacheRead;
32
- promptTokens += usage.input + usage.cacheRead + usage.cacheWrite;
33
- }
34
-
35
- return {
36
- cost,
37
- cachePercent: promptTokens > 0 ? (cacheRead / promptTokens) * 100 : null,
38
- };
39
- }
40
-
41
15
  function estimateContentTokens(characters: number) {
42
16
  return Math.ceil(characters / CHARS_PER_ESTIMATED_TOKEN);
43
17
  }
@@ -54,6 +28,7 @@ export default function modelInfo(pi: ExtensionAPI) {
54
28
  let runContentStreamMs = 0;
55
29
  let lastLiveUpdate = 0;
56
30
  let currentContext: ExtensionContext | undefined;
31
+ const sessionMetrics = createSessionMetricsTracker();
57
32
 
58
33
  const publish = () => pi.events.emit(MODEL_INFO_CHANNEL, { ...state });
59
34
 
@@ -61,7 +36,6 @@ export default function modelInfo(pi: ExtensionAPI) {
61
36
  currentContext = ctx;
62
37
  const model = ctx.model;
63
38
  const usage = ctx.getContextUsage();
64
- const metrics = getSessionMetrics(ctx);
65
39
 
66
40
  state = {
67
41
  ...state,
@@ -72,12 +46,14 @@ export default function modelInfo(pi: ExtensionAPI) {
72
46
  contextTokens: usage?.tokens ?? null,
73
47
  contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
74
48
  contextPercent: usage?.percent ?? null,
75
- cachePercent: metrics.cachePercent,
76
- cost: metrics.cost,
77
49
  };
78
50
  publish();
79
51
  }
80
52
 
53
+ function syncSessionMetrics(ctx: ExtensionContext) {
54
+ state = { ...state, ...sessionMetrics.sync(ctx.sessionManager) };
55
+ }
56
+
81
57
  function resetMessageTracking() {
82
58
  contentStreamStart = null;
83
59
  lastContentDeltaAt = null;
@@ -97,6 +73,8 @@ export default function modelInfo(pi: ExtensionAPI) {
97
73
  runContentTokens = 0;
98
74
  runContentStreamMs = 0;
99
75
  state = { ...state, tokensPerSecond: null, generating: false };
76
+ sessionMetrics.reset();
77
+ syncSessionMetrics(ctx);
100
78
  refresh(ctx);
101
79
  });
102
80
 
@@ -213,14 +191,23 @@ export default function modelInfo(pi: ExtensionAPI) {
213
191
  refresh(ctx);
214
192
  });
215
193
 
216
- pi.on("turn_end", (_event, ctx) => refresh(ctx));
194
+ pi.on("turn_end", (_event, ctx) => {
195
+ syncSessionMetrics(ctx);
196
+ refresh(ctx);
197
+ });
217
198
 
218
199
  // Compaction and branch moves rewrite history, so the cached percentage is
219
200
  // stale the moment they land. Pi reports unknown occupancy until the next
220
201
  // assistant reply, which is the honest state to show.
221
- pi.on("session_compact", (_event, ctx) => refresh(ctx));
202
+ pi.on("session_compact", (_event, ctx) => {
203
+ syncSessionMetrics(ctx);
204
+ refresh(ctx);
205
+ });
222
206
 
223
- pi.on("session_tree", (_event, ctx) => refresh(ctx));
207
+ pi.on("session_tree", (_event, ctx) => {
208
+ syncSessionMetrics(ctx);
209
+ refresh(ctx);
210
+ });
224
211
 
225
212
  pi.on("agent_settled", (_event, ctx) => {
226
213
  state = { ...state, generating: false };
@@ -230,5 +217,6 @@ export default function modelInfo(pi: ExtensionAPI) {
230
217
  pi.on("session_shutdown", () => {
231
218
  stopRefreshListener();
232
219
  currentContext = undefined;
220
+ sessionMetrics.reset();
233
221
  });
234
222
  }
@@ -0,0 +1,96 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { Usage } from "@earendil-works/pi-ai";
3
+
4
+ type SessionMetricsManager = {
5
+ getSessionId(): string;
6
+ getLeafId(): string | null;
7
+ getEntry(id: string): SessionEntry | undefined;
8
+ };
9
+
10
+ type PrefixMetrics = Readonly<{
11
+ cost: number;
12
+ cacheRead: number;
13
+ promptTokens: number;
14
+ }>;
15
+
16
+ const EMPTY_PREFIX: PrefixMetrics = Object.freeze({
17
+ cost: 0,
18
+ cacheRead: 0,
19
+ promptTokens: 0,
20
+ });
21
+
22
+ function getUsage(entry: SessionEntry): Usage | undefined {
23
+ if (entry.type === "message") {
24
+ return entry.message.role === "assistant" ||
25
+ entry.message.role === "toolResult"
26
+ ? entry.message.usage
27
+ : undefined;
28
+ }
29
+ return entry.type === "compaction" || entry.type === "branch_summary"
30
+ ? entry.usage
31
+ : undefined;
32
+ }
33
+
34
+ function addUsage(prefix: PrefixMetrics, entry: SessionEntry): PrefixMetrics {
35
+ const usage = getUsage(entry);
36
+ if (!usage) return prefix;
37
+ return Object.freeze({
38
+ cost: prefix.cost + usage.cost.total,
39
+ cacheRead: prefix.cacheRead + usage.cacheRead,
40
+ promptTokens:
41
+ prefix.promptTokens + usage.input + usage.cacheRead + usage.cacheWrite,
42
+ });
43
+ }
44
+
45
+ function toMetrics(prefix: PrefixMetrics) {
46
+ return {
47
+ cost: prefix.cost,
48
+ cachePercent:
49
+ prefix.promptTokens > 0
50
+ ? (prefix.cacheRead / prefix.promptTokens) * 100
51
+ : null,
52
+ };
53
+ }
54
+
55
+ export function createSessionMetricsTracker() {
56
+ let sessionId: string | undefined;
57
+ let prefixes = new Map<string, PrefixMetrics>();
58
+
59
+ const reset = () => {
60
+ sessionId = undefined;
61
+ prefixes = new Map();
62
+ };
63
+
64
+ const sync = (manager: SessionMetricsManager) => {
65
+ const nextSessionId = manager.getSessionId();
66
+ if (sessionId !== nextSessionId) reset();
67
+ sessionId = nextSessionId;
68
+
69
+ const leafId = manager.getLeafId();
70
+ if (!leafId) return toMetrics(EMPTY_PREFIX);
71
+
72
+ const cachedLeaf = prefixes.get(leafId);
73
+ if (cachedLeaf) return toMetrics(cachedLeaf);
74
+
75
+ const suffix: SessionEntry[] = [];
76
+ let currentId: string | null = leafId;
77
+ while (currentId && !prefixes.has(currentId)) {
78
+ const entry = manager.getEntry(currentId);
79
+ if (!entry) break;
80
+ suffix.push(entry);
81
+ currentId = entry.parentId;
82
+ }
83
+
84
+ let prefix = currentId
85
+ ? (prefixes.get(currentId) ?? EMPTY_PREFIX)
86
+ : EMPTY_PREFIX;
87
+ for (const entry of suffix.reverse()) {
88
+ prefix = addUsage(prefix, entry);
89
+ prefixes.set(entry.id, prefix);
90
+ }
91
+
92
+ return toMetrics(prefixes.get(leafId) ?? EMPTY_PREFIX);
93
+ };
94
+
95
+ return { sync, reset };
96
+ }
@@ -11,11 +11,13 @@
11
11
  *
12
12
  * THREAT MODEL. The adversary is a model that would start changing things
13
13
  * before the user approved a plan — including one steered by injected text it
14
- * read while investigating. It is NOT a hostile repository: `git diff` honors
15
- * `diff.external` from the repo's own config, so a checkout whose `.git/config`
16
- * is attacker-controlled can run a program through a command this module
17
- * allows. Nothing here can prevent that, and nothing above it tries to: pi
18
- * already runs the project's own tooling under the user's trust decision.
14
+ * read while investigating. Repository-configured diff drivers are also in
15
+ * scope for commands that render patches: raw `git diff`, `git show`,
16
+ * `git whatchanged`, and diff-generating `git log` forms are refused here. Plan
17
+ * Mode exposes the structured git-read tools instead; they force
18
+ * `--no-ext-diff --no-textconv --no-color` in argv. This is not a claim that
19
+ * every hostile Git configuration is inert: the remaining Git investigation
20
+ * commands still run under Pi's existing project trust boundary.
19
21
  *
20
22
  * EVERY LIST BELOW IS AN ALLOWLIST, deliberately. An earlier version scanned
21
23
  * for known-dangerous flags instead, and review found four separate escapes in
@@ -50,9 +52,7 @@ const UNQUOTED_GLOB_CHARACTERS = new Set(["*", "?", "[", "]"]);
50
52
  */
51
53
  const GIT_SUBCOMMANDS = new Set([
52
54
  "log",
53
- "diff",
54
55
  "status",
55
- "show",
56
56
  "blame",
57
57
  "shortlog",
58
58
  "describe",
@@ -63,10 +63,11 @@ const GIT_SUBCOMMANDS = new Set([
63
63
  "cat-file",
64
64
  "merge-base",
65
65
  "name-rev",
66
- "whatchanged",
67
66
  "grep",
68
67
  ]);
69
68
 
69
+ const GIT_DIFF_SUBCOMMANDS = new Set(["diff", "show", "whatchanged"]);
70
+
70
71
  /**
71
72
  * Flags accepted after a git subcommand. The admission rule for this list is
72
73
  * narrow: a flag qualifies only if it shapes OUTPUT or SELECTS commits, and
@@ -159,6 +160,27 @@ const GIT_FLAGS = new Set([
159
160
  "--count",
160
161
  ]);
161
162
 
163
+ /**
164
+ * Options that make `git log` enter the diff machinery. Git enables textconv
165
+ * by default for porcelain log/diff output, so these must use the structured
166
+ * git tools even though plain history browsing remains safe and useful.
167
+ */
168
+ const GIT_LOG_DIFF_FLAGS = new Set([
169
+ "-p",
170
+ "--patch",
171
+ "--stat",
172
+ "--shortstat",
173
+ "--numstat",
174
+ "--summary",
175
+ "--raw",
176
+ "--name-only",
177
+ "--name-status",
178
+ "--word-diff",
179
+ "-U",
180
+ "--unified",
181
+ "-L",
182
+ ]);
183
+
162
184
  /** gh subcommands paired with the verbs under each that only read. */
163
185
  const GH_SUBCOMMANDS = new Map<string, ReadonlySet<string>>([
164
186
  ["pr", new Set(["view", "list", "diff", "checks", "status"])],
@@ -377,6 +399,16 @@ function flagName(word: string) {
377
399
  return eq === -1 ? word : word.slice(0, eq);
378
400
  }
379
401
 
402
+ function firstMatchedFlag(
403
+ words: readonly string[],
404
+ flags: ReadonlySet<string>,
405
+ ) {
406
+ for (const word of words) {
407
+ if (word === "--") break;
408
+ if (flags.has(flagName(word))) return word;
409
+ }
410
+ }
411
+
380
412
  /**
381
413
  * Check the argument tail. Words after `--` are pathspecs by definition and
382
414
  * need no check; before it, a word either is an allowlisted flag or is not a
@@ -439,11 +471,24 @@ export function planBashDecision(command: unknown): BashPlanDecision {
439
471
  */
440
472
  if (program === "git") {
441
473
  const [subcommand, ...args] = rest;
474
+ if (subcommand && GIT_DIFF_SUBCOMMANDS.has(subcommand)) {
475
+ return refuse(
476
+ `plan mode does not run raw "git ${subcommand}" because repository diff drivers may execute external programs — use git_diff or git_show instead (and git_log to find commits)`,
477
+ );
478
+ }
442
479
  if (!subcommand || !GIT_SUBCOMMANDS.has(subcommand)) {
443
480
  return refuse(
444
- `plan mode allows only a read-only git subcommand immediately after "git" (log, diff, status, show, blame, …), not "${subcommand ?? "(none)"}"`,
481
+ `plan mode allows only a read-only git subcommand immediately after "git" (log, status, blame, …), not "${subcommand ?? "(none)"}"`,
445
482
  );
446
483
  }
484
+ if (subcommand === "log") {
485
+ const diffFlag = firstMatchedFlag(args, GIT_LOG_DIFF_FLAGS);
486
+ if (diffFlag) {
487
+ return refuse(
488
+ `plan mode does not run raw "git log ${diffFlag}" because diff-generating log options may execute repository diff drivers — use git_log to find commits, then git_show or git_diff to inspect changes`,
489
+ );
490
+ }
491
+ }
447
492
  return scanArguments(args, GIT_FLAGS, "git");
448
493
  }
449
494
 
@@ -37,6 +37,7 @@ import {
37
37
  type PlanModeState,
38
38
  } from "../shared/plan-mode-state.ts";
39
39
  import {
40
+ loadOpenPiCapabilities,
40
41
  OPENPI_TOOL_SURFACE,
41
42
  patchOwnedTools,
42
43
  } from "../shared/tool-surface.ts";
@@ -173,6 +174,9 @@ export const PLAN_SAFE_TOOLS = new Set([
173
174
  "ls",
174
175
  "fd",
175
176
  "rg",
177
+ "git_show",
178
+ "git_diff",
179
+ "git_log",
176
180
  // Web research without write actions.
177
181
  "web_search",
178
182
  "source_check",
@@ -208,7 +212,7 @@ export const PLAN_SAFE_TOOLS = new Set([
208
212
  ]);
209
213
 
210
214
  export const BLOCK_REASON =
211
- "Plan mode is active: no changes yet. Keep investigating with read-only tools (read, fd, rg, web search, read-only bash like git log/diff/status, and subagent_spawn for parallel exploration — planning children get read-only tools). When the plan is decision-complete, call plan_ready alone with the complete Markdown plan; the user then chooses the next action with `/plan`, or cancels with `/plan off`.";
215
+ "Plan mode is active: no changes yet. Keep investigating with read-only tools (read, fd, rg, git_log/git_diff/git_show, web search, read-only bash like git log/status, and subagent_spawn for parallel exploration — planning children get read-only tools). Raw git diff/show and diff-generating git log forms are blocked; use the structured Git tools instead. When the plan is decision-complete, call plan_ready alone with the complete Markdown plan; the user then chooses the next action with `/plan`, or cancels with `/plan off`.";
212
216
 
213
217
  export function planToolCallDecision(
214
218
  toolName: string,
@@ -256,6 +260,7 @@ export default function planMode(pi: ExtensionAPI) {
256
260
  hasUI: boolean;
257
261
  ui: { setStatus: (key: string, value?: string) => void };
258
262
  }) => {
263
+ if (planning) loadOpenPiCapabilities(pi, ["search"]);
259
264
  syncPlanTool();
260
265
  pi.events.emit(PLAN_MODE_CHANNEL, { planning } satisfies PlanModeState);
261
266
  if (!ctx.hasUI) return;
@@ -519,7 +524,7 @@ export default function planMode(pi: ExtensionAPI) {
519
524
  {
520
525
  customType: "plan-mode-armed",
521
526
  content:
522
- `Plan mode is on: investigate and propose a plan, but do not change anything yet. Edits and writes are blocked until the user explicitly chooses an implementation action. For files use the read, ls, grep and fd tools; bash is limited to read-only git and gh history commands such as \`git log\`, \`git diff\`, \`git status\`, \`git show\`, \`git blame\` and \`gh pr view\` — one plain command, no pipes or redirects. You can still delegate with \`subagent_spawn\` to explore several parts of the codebase at once: children spawned while planning receive read-only tools, so use them for investigation rather than for work to be done later.${objective ? `\n\nPlan for: ${objective}` : ""}` +
527
+ `Plan mode is on: investigate and propose a plan, but do not change anything yet. Edits and writes are blocked until the user explicitly chooses an implementation action. For files use the read, ls, grep, fd and rg tools. Use \`git_log\`, \`git_diff\` and \`git_show\` for history and changes; they disable repository external diff and textconv programs. Bash is limited to read-only git and gh commands such as \`git log\`, \`git status\`, \`git blame\` and \`gh pr view\` — raw Git commands that render diffs are blocked, and every Bash call must be one plain command with no pipes or redirects. You can still delegate with \`subagent_spawn\` to explore several parts of the codebase at once: children spawned while planning receive read-only tools, so use them for investigation rather than for work to be done later.${objective ? `\n\nPlan for: ${objective}` : ""}` +
523
528
  "\n\nUse read-only tools to ground the plan. When it is decision-complete, call `plan_ready` alone with the complete Markdown plan. That records the plan but does not start implementation; the user chooses the next action with `/plan`.",
524
529
  display: true,
525
530
  details: {},
@@ -21,6 +21,14 @@ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
21
21
 
22
22
  /** Tools whose success means a file on disk changed. */
23
23
  const MUTATING_TOOLS = new Set(["write", "edit"]);
24
+ const NOTICE_COMMAND_MAX_CHARS = 160;
25
+ const NOTICE_DETAIL_MAX_CHARS = 320;
26
+
27
+ function boundedNoticeText(value: string, maxChars: number) {
28
+ const chars = [...sanitizeTerminalText(value).trim()];
29
+ if (chars.length <= maxChars) return chars.join("");
30
+ return `${chars.slice(0, maxChars - 1).join("")}…`;
31
+ }
24
32
 
25
33
  export default function postEdit(
26
34
  pi: ExtensionAPI,
@@ -75,18 +83,20 @@ export default function postEdit(
75
83
  })
76
84
  .then((result) => {
77
85
  if (result.code === 0) return;
78
- const detail = sanitizeTerminalText(
86
+ const commandPreview = boundedNoticeText(ran, NOTICE_COMMAND_MAX_CHARS);
87
+ const detail = boundedNoticeText(
79
88
  result.stderr || result.stdout || "",
80
- )
81
- .trim()
82
- .slice(0, 500);
89
+ NOTICE_DETAIL_MAX_CHARS,
90
+ );
83
91
  warn(
84
- `post-edit command failed (exit ${result.code}): ${sanitizeTerminalText(ran)}${detail ? `\n${detail}` : ""}`,
92
+ `post-edit command failed (exit ${result.code}): ${commandPreview}${detail ? `\n${detail}` : ""}`,
85
93
  );
86
94
  })
87
95
  .catch((error: unknown) => {
88
96
  const detail = error instanceof Error ? error.message : String(error);
89
- warn(`post-edit command could not run: ${detail}`);
97
+ warn(
98
+ `post-edit command could not run: ${boundedNoticeText(detail, NOTICE_DETAIL_MAX_CHARS)}`,
99
+ );
90
100
  })
91
101
  .finally(() => {
92
102
  if (active?.controller === controller) active = undefined;