@tt-a1i/openpi 0.4.0 → 0.6.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 (141) hide show
  1. package/README.md +116 -46
  2. package/SETUP.md +29 -7
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-launch-card-v1.webp +0 -0
  5. package/bin/openpi.js +155 -0
  6. package/extensions/ai-providers/LICENSE.upstream +23 -0
  7. package/extensions/ai-providers/README.md +59 -0
  8. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  9. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  10. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  11. package/extensions/ai-providers/antigravity/models.ts +84 -0
  12. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  13. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  14. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  15. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  16. package/extensions/ai-providers/cursor/constants.ts +5 -0
  17. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  18. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  19. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  20. package/extensions/ai-providers/cursor/models.ts +45 -0
  21. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  22. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  23. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  24. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  25. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +38 -3
  31. package/extensions/background-terminals/src/domain.ts +2 -0
  32. package/extensions/background-terminals/src/manager.ts +484 -106
  33. package/extensions/background-terminals/src/output.ts +33 -0
  34. package/extensions/background-terminals/src/prompt.ts +13 -5
  35. package/extensions/background-terminals/src/result-delivery.ts +47 -24
  36. package/extensions/clear-context/index.ts +83 -0
  37. package/extensions/context-pivot/index.ts +16 -6
  38. package/extensions/cron/index.ts +68 -27
  39. package/extensions/cron/schedule.ts +12 -2
  40. package/extensions/file-mutation-display/render.ts +17 -257
  41. package/extensions/file-search/src/binaries.ts +57 -41
  42. package/extensions/git-read/index.ts +1 -3
  43. package/extensions/model-info/cache-diagnostics.ts +220 -0
  44. package/extensions/model-info/index.ts +65 -33
  45. package/extensions/model-info/session-metrics.ts +96 -0
  46. package/extensions/plan-mode/bash-policy.ts +54 -9
  47. package/extensions/plan-mode/index.ts +82 -6
  48. package/extensions/post-edit/index.ts +16 -6
  49. package/extensions/sessions/git-stats.ts +258 -72
  50. package/extensions/sessions/index.ts +153 -86
  51. package/extensions/sessions/preview-cache.ts +104 -0
  52. package/extensions/sessions/preview-loader.ts +856 -0
  53. package/extensions/sessions/sessions.ts +43 -4
  54. package/extensions/setup/index.ts +138 -130
  55. package/extensions/shared/activity-status.ts +30 -0
  56. package/extensions/shared/agent-session-page.ts +319 -0
  57. package/extensions/shared/agent-tool-renderer.ts +218 -0
  58. package/extensions/shared/agent-transcript.ts +524 -0
  59. package/extensions/shared/capability-intent.ts +1 -1
  60. package/extensions/shared/child-session.ts +457 -21
  61. package/extensions/shared/completion-inbox.ts +193 -0
  62. package/extensions/shared/result-delivery.ts +34 -0
  63. package/extensions/shared/setup-config.ts +83 -34
  64. package/extensions/shared/setup-episode-state.ts +1 -1
  65. package/extensions/shared/structured-output.ts +154 -0
  66. package/extensions/shared/terminal-text.ts +110 -23
  67. package/extensions/shared/text-projection.ts +72 -15
  68. package/extensions/shared/tool-activity.ts +382 -0
  69. package/extensions/shared/tool-surface.ts +29 -2
  70. package/extensions/shared/transcript-viewport.ts +46 -0
  71. package/extensions/shared/web-observer-registry.ts +390 -0
  72. package/extensions/shared/worktree.ts +11 -0
  73. package/extensions/subagents/index.ts +313 -62
  74. package/extensions/subagents/navigation.ts +34 -5
  75. package/extensions/subagents/src/backend.ts +12 -1
  76. package/extensions/subagents/src/backends/pi.ts +450 -70
  77. package/extensions/subagents/src/domain.ts +21 -1
  78. package/extensions/subagents/src/manager.ts +39 -2
  79. package/extensions/subagents/src/prompt.ts +49 -7
  80. package/extensions/subagents/src/result-artifact.ts +36 -0
  81. package/extensions/subagents/src/result-delivery.ts +39 -14
  82. package/extensions/subagents/src/runtime.ts +15 -1
  83. package/extensions/subagents/src/ui/takeover.ts +73 -257
  84. package/extensions/subagents/src/ui/transcript.ts +38 -535
  85. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  86. package/extensions/suggestions/src/ui.ts +10 -4
  87. package/extensions/tasks/index.ts +0 -3
  88. package/extensions/ui-customization/footer.ts +16 -45
  89. package/extensions/ui-customization/index.ts +0 -4
  90. package/extensions/user-input-fold/index.ts +42 -6
  91. package/extensions/web/index.ts +257 -0
  92. package/extensions/workflows/acceptance.ts +43 -19
  93. package/extensions/workflows/artifacts.ts +137 -47
  94. package/extensions/workflows/completion-projection.ts +459 -0
  95. package/extensions/workflows/coordinator.ts +8 -10
  96. package/extensions/workflows/dashboard.ts +175 -228
  97. package/extensions/workflows/handoff.ts +70 -16
  98. package/extensions/workflows/index.ts +501 -198
  99. package/extensions/workflows/journal.ts +148 -13
  100. package/extensions/workflows/model.ts +79 -5
  101. package/extensions/workflows/navigation.ts +32 -8
  102. package/extensions/workflows/progress-projection.ts +306 -0
  103. package/extensions/workflows/prompt.ts +70 -16
  104. package/extensions/workflows/replay-safety.ts +42 -21
  105. package/extensions/workflows/result-delivery.ts +214 -76
  106. package/extensions/workflows/retention.ts +599 -0
  107. package/extensions/workflows/runner.ts +389 -345
  108. package/extensions/workflows/sandbox-child.cjs +25 -3
  109. package/extensions/workflows/sandbox.ts +62 -8
  110. package/extensions/workflows/serialization.ts +325 -17
  111. package/extensions/workflows/tool-renderer.ts +22 -0
  112. package/extensions/workflows/transcript.ts +149 -0
  113. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  114. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  115. package/package.json +34 -14
  116. package/skills/subagents/REFERENCE.md +190 -0
  117. package/skills/subagents/SKILL.md +2 -1
  118. package/skills/workflows/REFERENCE.md +6 -4
  119. package/skills/workflows/SKILL.md +1 -1
  120. package/web/adapter/pi-adapter.ts +664 -0
  121. package/web/host/browser-launcher.ts +20 -0
  122. package/web/host/pi-coding-agent-entry.ts +162 -0
  123. package/web/host/static-assets.ts +4 -0
  124. package/web/host/terminal-status.ts +38 -0
  125. package/web/host/web-host.ts +1069 -0
  126. package/web/http-dispatcher.ts +125 -0
  127. package/web/protocol/types.ts +467 -0
  128. package/web/runtime/pi-runtime.ts +1206 -0
  129. package/web/runtime/types.ts +102 -0
  130. package/web/runtime/web-host-lease.ts +497 -0
  131. package/web/trace.ts +18 -0
  132. package/web/ui/app.js +1700 -0
  133. package/web/ui/index.html +142 -0
  134. package/web/ui/styles.css +680 -0
  135. package/web/vite.config.mjs +34 -0
  136. package/extensions/execution-convergence/active-evidence.ts +0 -129
  137. package/extensions/execution-convergence/index.ts +0 -442
  138. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  139. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  140. package/extensions/setup/intercom.ts +0 -603
  141. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -0,0 +1,220 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Usage } from "@earendil-works/pi-ai";
3
+
4
+ export const CACHE_DIAGNOSTICS_CHANNEL = "model-info:cache-diagnostics";
5
+
6
+ export const CACHE_WARM_MINIMUM_TOKENS = 2_048;
7
+
8
+ export type CacheSemantics =
9
+ | "explicit-prefix"
10
+ | "implicit-best-effort"
11
+ | "unknown";
12
+
13
+ export type CacheObservationKind =
14
+ | "first-turn"
15
+ | "cold"
16
+ | "warm"
17
+ | "partial-hit"
18
+ | "miss-after-warm-prefix"
19
+ | "unknown";
20
+
21
+ export type CacheCorrelation =
22
+ | "model-change"
23
+ | "thinking-change"
24
+ | "tool-surface-change"
25
+ | "system-prompt-change"
26
+ | "compaction"
27
+ | "branch-change";
28
+
29
+ export interface CacheTurnIdentity {
30
+ provider: string;
31
+ modelId: string;
32
+ thinking: string;
33
+ toolSurfaceFingerprint: string;
34
+ systemPromptFingerprint: string;
35
+ }
36
+
37
+ export interface CacheTurnObservation {
38
+ turnIndex: number;
39
+ provider: string;
40
+ semantics: CacheSemantics;
41
+ kind: CacheObservationKind;
42
+ usage: {
43
+ input: number;
44
+ cacheRead: number;
45
+ cacheWrite: number;
46
+ promptTokens: number;
47
+ };
48
+ previousCacheRead: number | null;
49
+ reprocessedTokens: number | null;
50
+ correlations: CacheCorrelation[];
51
+ evidence: "observation";
52
+ verifiedCause: null;
53
+ explanation: string;
54
+ }
55
+
56
+ type TurnSample = {
57
+ identity: CacheTurnIdentity;
58
+ usage: CacheTurnObservation["usage"];
59
+ };
60
+
61
+ const IMPLICIT_CACHE_PROVIDERS = new Set([
62
+ "azure-openai-responses",
63
+ "google",
64
+ "google-antigravity",
65
+ "google-gemini-cli",
66
+ "openai",
67
+ "openai-codex",
68
+ "openai-responses",
69
+ ]);
70
+
71
+ export function cacheSemanticsForProvider(provider: string): CacheSemantics {
72
+ const normalized = provider.trim().toLowerCase();
73
+ if (normalized === "anthropic") return "explicit-prefix";
74
+ if (IMPLICIT_CACHE_PROVIDERS.has(normalized)) return "implicit-best-effort";
75
+ return "unknown";
76
+ }
77
+
78
+ export function fingerprintCacheSurface(value: unknown) {
79
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
80
+ }
81
+
82
+ function finiteUsage(value: number) {
83
+ return Number.isFinite(value) && value > 0 ? value : 0;
84
+ }
85
+
86
+ function promptUsage(usage: Usage): CacheTurnObservation["usage"] {
87
+ const input = finiteUsage(usage.input);
88
+ const cacheRead = finiteUsage(usage.cacheRead);
89
+ const cacheWrite = finiteUsage(usage.cacheWrite);
90
+ return {
91
+ input,
92
+ cacheRead,
93
+ cacheWrite,
94
+ promptTokens: input + cacheRead + cacheWrite,
95
+ };
96
+ }
97
+
98
+ function identityCorrelations(
99
+ previous: CacheTurnIdentity,
100
+ current: CacheTurnIdentity,
101
+ ) {
102
+ const correlations: CacheCorrelation[] = [];
103
+ if (
104
+ previous.provider !== current.provider ||
105
+ previous.modelId !== current.modelId
106
+ ) {
107
+ correlations.push("model-change");
108
+ }
109
+ if (previous.thinking !== current.thinking) {
110
+ correlations.push("thinking-change");
111
+ }
112
+ if (previous.toolSurfaceFingerprint !== current.toolSurfaceFingerprint) {
113
+ correlations.push("tool-surface-change");
114
+ }
115
+ if (previous.systemPromptFingerprint !== current.systemPromptFingerprint) {
116
+ correlations.push("system-prompt-change");
117
+ }
118
+ return correlations;
119
+ }
120
+
121
+ function classify(
122
+ semantics: CacheSemantics,
123
+ current: CacheTurnObservation["usage"],
124
+ previous: TurnSample | undefined,
125
+ ): Pick<CacheTurnObservation, "kind" | "reprocessedTokens" | "explanation"> {
126
+ if (!previous) {
127
+ return {
128
+ kind: "first-turn",
129
+ reprocessedTokens: null,
130
+ explanation:
131
+ "No prior turn exists, so cache continuity cannot be inferred.",
132
+ };
133
+ }
134
+
135
+ const previousWarm = previous.usage.cacheRead >= CACHE_WARM_MINIMUM_TOKENS;
136
+ if (current.cacheRead > 0) {
137
+ return {
138
+ kind:
139
+ current.cacheRead < previous.usage.cacheRead ? "partial-hit" : "warm",
140
+ reprocessedTokens: null,
141
+ explanation:
142
+ current.cacheRead < previous.usage.cacheRead
143
+ ? "The provider reported a smaller cache read than on the prior turn."
144
+ : "The provider reported cached prompt tokens on this turn.",
145
+ };
146
+ }
147
+ if (!previousWarm) {
148
+ return {
149
+ kind: "cold",
150
+ reprocessedTokens: null,
151
+ explanation:
152
+ "The prior turn had no sufficiently warm prefix, so this cold turn is not an invalidation signal.",
153
+ };
154
+ }
155
+ if (semantics !== "explicit-prefix") {
156
+ return {
157
+ kind: "unknown",
158
+ reprocessedTokens: null,
159
+ explanation:
160
+ semantics === "implicit-best-effort"
161
+ ? "A warm-to-cold transition was observed, but this provider exposes only best-effort cache usage."
162
+ : "A warm-to-cold transition was observed, but the provider cache contract is unknown.",
163
+ };
164
+ }
165
+ return {
166
+ kind: "miss-after-warm-prefix",
167
+ reprocessedTokens: current.input,
168
+ explanation:
169
+ "An explicit-prefix provider reported a warm prior turn and zero cache read now; local boundaries are correlations, not verified causes.",
170
+ };
171
+ }
172
+
173
+ export function createCacheDiagnosticsTracker() {
174
+ let previous: TurnSample | undefined;
175
+ let pendingCorrelations = new Set<CacheCorrelation>();
176
+
177
+ const reset = () => {
178
+ previous = undefined;
179
+ pendingCorrelations = new Set();
180
+ };
181
+
182
+ const mark = (correlation: CacheCorrelation) => {
183
+ pendingCorrelations.add(correlation);
184
+ };
185
+
186
+ const observe = (options: {
187
+ turnIndex: number;
188
+ identity: CacheTurnIdentity;
189
+ usage: Usage;
190
+ }) => {
191
+ const usage = promptUsage(options.usage);
192
+ const semantics = cacheSemanticsForProvider(options.identity.provider);
193
+ const classification = classify(semantics, usage, previous);
194
+ const correlations = previous
195
+ ? identityCorrelations(previous.identity, options.identity)
196
+ : [];
197
+ for (const pending of pendingCorrelations) correlations.push(pending);
198
+ const uniqueCorrelations = [...new Set(correlations)];
199
+
200
+ const observation: CacheTurnObservation = {
201
+ turnIndex: options.turnIndex,
202
+ provider: options.identity.provider,
203
+ semantics,
204
+ kind: classification.kind,
205
+ usage,
206
+ previousCacheRead: previous?.usage.cacheRead ?? null,
207
+ reprocessedTokens: classification.reprocessedTokens,
208
+ correlations: uniqueCorrelations,
209
+ evidence: "observation",
210
+ verifiedCause: null,
211
+ explanation: classification.explanation,
212
+ };
213
+
214
+ previous = { identity: options.identity, usage };
215
+ pendingCorrelations.clear();
216
+ return observation;
217
+ };
218
+
219
+ return { mark, observe, reset };
220
+ }
@@ -7,37 +7,17 @@ import {
7
7
  MODEL_INFO_CHANNEL,
8
8
  REFRESH_CHANNEL,
9
9
  } from "../shared/dashboard-state.ts";
10
+ import { createSessionMetricsTracker } from "./session-metrics.ts";
11
+ import {
12
+ CACHE_DIAGNOSTICS_CHANNEL,
13
+ createCacheDiagnosticsTracker,
14
+ fingerprintCacheSurface,
15
+ type CacheTurnIdentity,
16
+ } from "./cache-diagnostics.ts";
10
17
 
11
18
  const CHARS_PER_ESTIMATED_TOKEN = 4;
12
19
  const LIVE_UPDATE_INTERVAL_MS = 200;
13
20
 
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
21
  function estimateContentTokens(characters: number) {
42
22
  return Math.ceil(characters / CHARS_PER_ESTIMATED_TOKEN);
43
23
  }
@@ -54,6 +34,9 @@ export default function modelInfo(pi: ExtensionAPI) {
54
34
  let runContentStreamMs = 0;
55
35
  let lastLiveUpdate = 0;
56
36
  let currentContext: ExtensionContext | undefined;
37
+ const sessionMetrics = createSessionMetricsTracker();
38
+ const cacheDiagnostics = createCacheDiagnosticsTracker();
39
+ let cacheIdentity: CacheTurnIdentity | undefined;
57
40
 
58
41
  const publish = () => pi.events.emit(MODEL_INFO_CHANNEL, { ...state });
59
42
 
@@ -61,7 +44,6 @@ export default function modelInfo(pi: ExtensionAPI) {
61
44
  currentContext = ctx;
62
45
  const model = ctx.model;
63
46
  const usage = ctx.getContextUsage();
64
- const metrics = getSessionMetrics(ctx);
65
47
 
66
48
  state = {
67
49
  ...state,
@@ -72,12 +54,14 @@ export default function modelInfo(pi: ExtensionAPI) {
72
54
  contextTokens: usage?.tokens ?? null,
73
55
  contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
74
56
  contextPercent: usage?.percent ?? null,
75
- cachePercent: metrics.cachePercent,
76
- cost: metrics.cost,
77
57
  };
78
58
  publish();
79
59
  }
80
60
 
61
+ function syncSessionMetrics(ctx: ExtensionContext) {
62
+ state = { ...state, ...sessionMetrics.sync(ctx.sessionManager) };
63
+ }
64
+
81
65
  function resetMessageTracking() {
82
66
  contentStreamStart = null;
83
67
  lastContentDeltaAt = null;
@@ -97,10 +81,15 @@ export default function modelInfo(pi: ExtensionAPI) {
97
81
  runContentTokens = 0;
98
82
  runContentStreamMs = 0;
99
83
  state = { ...state, tokensPerSecond: null, generating: false };
84
+ sessionMetrics.reset();
85
+ cacheDiagnostics.reset();
86
+ cacheIdentity = undefined;
87
+ syncSessionMetrics(ctx);
100
88
  refresh(ctx);
101
89
  });
102
90
 
103
91
  pi.on("model_select", (event, ctx) => {
92
+ cacheDiagnostics.mark("model-change");
104
93
  state = {
105
94
  ...state,
106
95
  provider: event.model.provider,
@@ -113,6 +102,7 @@ export default function modelInfo(pi: ExtensionAPI) {
113
102
  });
114
103
 
115
104
  pi.on("thinking_level_select", (event) => {
105
+ cacheDiagnostics.mark("thinking-change");
116
106
  state = { ...state, thinking: event.level };
117
107
  publish();
118
108
  });
@@ -125,6 +115,17 @@ export default function modelInfo(pi: ExtensionAPI) {
125
115
  refresh(ctx);
126
116
  });
127
117
 
118
+ pi.on("before_agent_start", (event, ctx) => {
119
+ const selectedTools = event.systemPromptOptions.selectedTools ?? [];
120
+ cacheIdentity = {
121
+ provider: ctx.model?.provider ?? "",
122
+ modelId: ctx.model?.id ?? "no-model",
123
+ thinking: ctx.model?.reasoning ? pi.getThinkingLevel() : "off",
124
+ toolSurfaceFingerprint: fingerprintCacheSurface(selectedTools),
125
+ systemPromptFingerprint: fingerprintCacheSurface(event.systemPrompt),
126
+ };
127
+ });
128
+
128
129
  pi.on("message_start", (event) => {
129
130
  if (event.message.role === "assistant") resetMessageTracking();
130
131
  });
@@ -213,14 +214,42 @@ export default function modelInfo(pi: ExtensionAPI) {
213
214
  refresh(ctx);
214
215
  });
215
216
 
216
- pi.on("turn_end", (_event, ctx) => refresh(ctx));
217
+ pi.on("turn_end", (event, ctx) => {
218
+ syncSessionMetrics(ctx);
219
+ refresh(ctx);
220
+ // Failed/cancelled responses may carry placeholder zero usage. They do
221
+ // not establish a cache observation or replace the last valid baseline.
222
+ if (
223
+ event.message?.role === "assistant" &&
224
+ event.message.stopReason !== "error" &&
225
+ event.message.stopReason !== "aborted" &&
226
+ cacheIdentity
227
+ ) {
228
+ pi.events.emit(
229
+ CACHE_DIAGNOSTICS_CHANNEL,
230
+ cacheDiagnostics.observe({
231
+ turnIndex: event.turnIndex,
232
+ identity: cacheIdentity,
233
+ usage: event.message.usage,
234
+ }),
235
+ );
236
+ }
237
+ });
217
238
 
218
239
  // Compaction and branch moves rewrite history, so the cached percentage is
219
240
  // stale the moment they land. Pi reports unknown occupancy until the next
220
241
  // assistant reply, which is the honest state to show.
221
- pi.on("session_compact", (_event, ctx) => refresh(ctx));
242
+ pi.on("session_compact", (_event, ctx) => {
243
+ cacheDiagnostics.mark("compaction");
244
+ syncSessionMetrics(ctx);
245
+ refresh(ctx);
246
+ });
222
247
 
223
- pi.on("session_tree", (_event, ctx) => refresh(ctx));
248
+ pi.on("session_tree", (_event, ctx) => {
249
+ cacheDiagnostics.mark("branch-change");
250
+ syncSessionMetrics(ctx);
251
+ refresh(ctx);
252
+ });
224
253
 
225
254
  pi.on("agent_settled", (_event, ctx) => {
226
255
  state = { ...state, generating: false };
@@ -230,5 +259,8 @@ export default function modelInfo(pi: ExtensionAPI) {
230
259
  pi.on("session_shutdown", () => {
231
260
  stopRefreshListener();
232
261
  currentContext = undefined;
262
+ sessionMetrics.reset();
263
+ cacheDiagnostics.reset();
264
+ cacheIdentity = undefined;
233
265
  });
234
266
  }
@@ -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