@tt-a1i/openpi 0.3.1 → 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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -0,0 +1,113 @@
1
+ import {
2
+ formatSize,
3
+ truncateHead,
4
+ truncateTail,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ const HEAD_SHARE = 0.75;
8
+ const OMISSION_MARKER = "[... middle omitted ...]";
9
+ // Three framing blanks, the omission marker, and the footer.
10
+ const STATIC_PROJECTION_OVERHEAD_LINES = 5;
11
+
12
+ function utf8Prefix(content: string, maxBytes: number) {
13
+ const bytes = Buffer.from(content, "utf8");
14
+ if (bytes.length <= maxBytes) return content;
15
+ let end = Math.max(0, maxBytes);
16
+ while (end > 0) {
17
+ const value = bytes.subarray(0, end).toString("utf8");
18
+ if (!value.endsWith("�")) return value;
19
+ end -= 1;
20
+ }
21
+ return "";
22
+ }
23
+
24
+ function boundedHead(content: string, maxBytes: number, maxLines: number) {
25
+ const result = truncateHead(content, { maxBytes, maxLines });
26
+ if (result.content || !result.firstLineExceedsLimit) return result.content;
27
+ return utf8Prefix(content, maxBytes);
28
+ }
29
+
30
+ function compactProjection(
31
+ content: string,
32
+ maxBytes: number,
33
+ maxLines: number,
34
+ ) {
35
+ const marker =
36
+ maxBytes >= OMISSION_MARKER.length
37
+ ? OMISSION_MARKER
38
+ : ".".repeat(Math.min(3, maxBytes));
39
+ if (!marker || maxLines <= 0) return "";
40
+ if (maxLines === 1) return marker;
41
+
42
+ const bodyLineBudget = maxLines - 1;
43
+ const keepTail = bodyLineBudget >= 2;
44
+ const separatorBytes = keepTail ? 2 : 1;
45
+ const bodyByteBudget = maxBytes - marker.length - separatorBytes;
46
+ if (bodyByteBudget <= 0 || (keepTail && bodyByteBudget < 2)) return marker;
47
+
48
+ const headLines = keepTail
49
+ ? Math.max(1, Math.floor(bodyLineBudget * HEAD_SHARE))
50
+ : bodyLineBudget;
51
+ const tailLines = keepTail ? Math.max(1, bodyLineBudget - headLines) : 0;
52
+ const headBytes = keepTail
53
+ ? Math.max(1, Math.floor(bodyByteBudget * HEAD_SHARE))
54
+ : bodyByteBudget;
55
+ const tailBytes = keepTail ? Math.max(1, bodyByteBudget - headBytes) : 0;
56
+ const head = boundedHead(content, headBytes, headLines);
57
+ const tail = keepTail
58
+ ? truncateTail(content, {
59
+ maxBytes: tailBytes,
60
+ maxLines: tailLines,
61
+ }).content
62
+ : "";
63
+ return [head, marker, tail].filter(Boolean).join("\n");
64
+ }
65
+
66
+ /** Keep decision context at the start and verdict/evidence at the end. */
67
+ export function projectText(
68
+ content: string,
69
+ options: { maxBytes: number; maxLines: number; recovery: string },
70
+ ) {
71
+ if (options.maxBytes <= 0 || options.maxLines <= 0) return "";
72
+
73
+ const probe = truncateHead(content, {
74
+ maxBytes: options.maxBytes,
75
+ maxLines: options.maxLines,
76
+ });
77
+ if (!probe.truncated) return content;
78
+
79
+ const recoveryNewlines = options.recovery.split("\n").length - 1;
80
+ const bodyLineBudget =
81
+ options.maxLines - STATIC_PROJECTION_OVERHEAD_LINES - recoveryNewlines;
82
+ if (bodyLineBudget < 2) {
83
+ return compactProjection(content, options.maxBytes, options.maxLines);
84
+ }
85
+ const headLines = Math.max(1, Math.floor(bodyLineBudget * HEAD_SHARE));
86
+ const tailLines = Math.max(1, bodyLineBudget - headLines);
87
+
88
+ let bodyBudget = options.maxBytes;
89
+ let projected = "";
90
+ for (let attempt = 0; attempt < 8; attempt++) {
91
+ const headBytes = Math.max(1, Math.floor(bodyBudget * HEAD_SHARE));
92
+ const tailBytes = Math.max(1, bodyBudget - headBytes);
93
+ const head = boundedHead(content, headBytes, headLines);
94
+ const tail = truncateTail(content, {
95
+ maxBytes: tailBytes,
96
+ maxLines: tailLines,
97
+ }).content;
98
+ const shown =
99
+ Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
100
+ const footer = `[Projection bounded: showing ${formatSize(shown)} of ${formatSize(probe.totalBytes)} across the head and tail. ${options.recovery}]`;
101
+ projected = `${head}\n\n${OMISSION_MARKER}\n\n${tail}\n\n${footer}`;
102
+ const overflow = Buffer.byteLength(projected, "utf8") - options.maxBytes;
103
+ if (overflow <= 0 || bodyBudget <= overflow + 2) break;
104
+ bodyBudget -= overflow;
105
+ }
106
+ const projectedProbe = truncateHead(projected, {
107
+ maxBytes: options.maxBytes,
108
+ maxLines: options.maxLines,
109
+ });
110
+ return projectedProbe.truncated
111
+ ? compactProjection(content, options.maxBytes, options.maxLines)
112
+ : projected;
113
+ }
@@ -0,0 +1,382 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth } from "@earendil-works/pi-tui";
5
+ import { spinnerFrame } from "./spinner.ts";
6
+ import { sanitizeTerminalText } from "./terminal-text.ts";
7
+
8
+ export type ToolActivityStatus = "pending" | "success" | "error";
9
+
10
+ export interface ToolActivity {
11
+ readonly name: string;
12
+ readonly args?: unknown;
13
+ readonly argsFallback?: string;
14
+ readonly output?: string;
15
+ readonly details?: unknown;
16
+ readonly status: ToolActivityStatus;
17
+ readonly cwd?: string;
18
+ readonly startedAt?: number;
19
+ readonly endedAt?: number;
20
+ }
21
+
22
+ interface ActivityRow {
23
+ readonly verb: string;
24
+ readonly target: string;
25
+ readonly detail?: string;
26
+ }
27
+
28
+ function record(value: unknown): Record<string, unknown> {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value)
30
+ ? (value as Record<string, unknown>)
31
+ : {};
32
+ }
33
+
34
+ function string(value: unknown) {
35
+ return typeof value === "string" ? sanitizeTerminalText(value) : "";
36
+ }
37
+
38
+ function number(value: unknown) {
39
+ return typeof value === "number" ? value : undefined;
40
+ }
41
+
42
+ function compact(value: string) {
43
+ return sanitizeTerminalText(value).replace(/\r?\n/g, " ↵ ").trim();
44
+ }
45
+
46
+ export function parseToolArgsPreview(preview?: string) {
47
+ const clean = sanitizeTerminalText(preview ?? "").trim();
48
+ const fallback = compact(clean);
49
+ if (!fallback) return { args: undefined, fallback: undefined };
50
+ try {
51
+ const args: unknown = JSON.parse(clean);
52
+ return { args, fallback };
53
+ } catch {
54
+ return { args: undefined, fallback };
55
+ }
56
+ }
57
+
58
+ function displayPath(value: string, cwd?: string) {
59
+ if (!isAbsolute(value) || !cwd) return value;
60
+ const local = relative(cwd, value);
61
+ if (local === "") return ".";
62
+ return local.startsWith("..") || isAbsolute(local) ? value : local;
63
+ }
64
+
65
+ function resultCount(output: string) {
66
+ return output
67
+ .split(/\r?\n/)
68
+ .filter((line) => line.trim().length > 0 && !line.trim().startsWith("["))
69
+ .length;
70
+ }
71
+
72
+ function grepMatchCount(output: string) {
73
+ const text = output.trim();
74
+ if (!text || text === "No matches found") return 0;
75
+ return text.split(/\r?\n/).filter((line) => /^.+:\d+:/.test(line)).length;
76
+ }
77
+
78
+ function itemCount(output: string, emptyMessage: string) {
79
+ const text = output.trim();
80
+ return !text || text === emptyMessage ? 0 : resultCount(output);
81
+ }
82
+
83
+ function plural(count: number, singular: string) {
84
+ const pluralForm =
85
+ singular === "match"
86
+ ? "matches"
87
+ : singular === "entry"
88
+ ? "entries"
89
+ : `${singular}s`;
90
+ return `${count} ${count === 1 ? singular : pluralForm}`;
91
+ }
92
+
93
+ function editStats(details: unknown) {
94
+ const diff = string(record(details).diff);
95
+ if (!diff) return undefined;
96
+ let additions = 0;
97
+ let removals = 0;
98
+ for (const line of diff.split(/\r?\n/)) {
99
+ if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
100
+ if (line.startsWith("-") && !line.startsWith("---")) removals += 1;
101
+ }
102
+ return { additions, removals };
103
+ }
104
+
105
+ function range(args: Record<string, unknown>) {
106
+ const offset = number(args.offset);
107
+ const limit = number(args.limit);
108
+ if (offset === undefined && limit === undefined) return "";
109
+ const start = offset ?? 1;
110
+ return limit === undefined ? `:${start}-` : `:${start}-${start + limit - 1}`;
111
+ }
112
+
113
+ function canonicalName(name: string) {
114
+ const lower = sanitizeTerminalText(name).toLowerCase();
115
+ if (lower === "rg") return "grep";
116
+ if (lower === "fd") return "find";
117
+ return lower;
118
+ }
119
+
120
+ function activityRow(activity: ToolActivity): ActivityRow {
121
+ const name = canonicalName(activity.name);
122
+ const args = record(activity.args);
123
+ const fallback = compact(activity.argsFallback ?? "");
124
+ const output = sanitizeTerminalText(activity.output ?? "");
125
+ const path = displayPath(string(args.path) || ".", activity.cwd);
126
+ switch (name) {
127
+ case "read":
128
+ return { verb: "Read", target: `${path}${range(args)}` };
129
+ case "bash":
130
+ return {
131
+ verb: "Ran",
132
+ target:
133
+ string(args.command).replace(/\s+/g, " ").trim() || fallback || name,
134
+ };
135
+ case "write": {
136
+ const content = string(args.content);
137
+ const lines =
138
+ content.length === 0
139
+ ? 0
140
+ : content.replace(/\r?\n$/, "").split(/\r?\n/).length;
141
+ return {
142
+ verb: "Wrote",
143
+ target: string(args.path) ? path : fallback,
144
+ ...(content ? { detail: plural(lines, "line") } : {}),
145
+ };
146
+ }
147
+ case "edit":
148
+ return { verb: "Edited", target: string(args.path) ? path : fallback };
149
+ case "grep": {
150
+ const pattern = string(args.pattern) || fallback;
151
+ return {
152
+ verb: "Searched",
153
+ target: pattern,
154
+ ...(output
155
+ ? { detail: `in ${path} ${plural(grepMatchCount(output), "match")}` }
156
+ : string(args.path)
157
+ ? { detail: `in ${path}` }
158
+ : {}),
159
+ };
160
+ }
161
+ case "find": {
162
+ const pattern = string(args.pattern) || fallback;
163
+ return {
164
+ verb: "Searched",
165
+ target: pattern,
166
+ ...(output
167
+ ? {
168
+ detail: `in ${path} ${plural(itemCount(output, "No files found matching pattern"), "result")}`,
169
+ }
170
+ : string(args.path)
171
+ ? { detail: `in ${path}` }
172
+ : {}),
173
+ };
174
+ }
175
+ case "ls":
176
+ return {
177
+ verb: "Listed",
178
+ target: string(args.path) ? path : fallback || ".",
179
+ ...(output
180
+ ? { detail: plural(itemCount(output, "(empty directory)"), "entry") }
181
+ : {}),
182
+ };
183
+ default:
184
+ return {
185
+ verb: sanitizeTerminalText(activity.name),
186
+ target: fallback || name,
187
+ };
188
+ }
189
+ }
190
+
191
+ function pendingVerb(name: string) {
192
+ switch (canonicalName(name)) {
193
+ case "read":
194
+ return "Reading";
195
+ case "bash":
196
+ return "Running";
197
+ case "write":
198
+ return "Writing";
199
+ case "edit":
200
+ return "Editing";
201
+ case "grep":
202
+ case "find":
203
+ return "Searching";
204
+ case "ls":
205
+ return "Listing";
206
+ default:
207
+ return "Running";
208
+ }
209
+ }
210
+
211
+ function activityIcon(name: string) {
212
+ switch (canonicalName(name)) {
213
+ case "read":
214
+ return "\ueaa4";
215
+ case "bash":
216
+ return "\uea85";
217
+ case "write":
218
+ case "edit":
219
+ return "\uea73";
220
+ case "grep":
221
+ case "find":
222
+ return "\uea6d";
223
+ case "ls":
224
+ return "\uea83";
225
+ default:
226
+ return "✓";
227
+ }
228
+ }
229
+
230
+ function errorSummary(output?: string) {
231
+ const lines = sanitizeTerminalText(output ?? "")
232
+ .split(/\r?\n/)
233
+ .map((line) => stripVTControlCharacters(line).trim())
234
+ .filter(Boolean);
235
+ return (
236
+ [...lines]
237
+ .reverse()
238
+ .find((line) =>
239
+ /(?:command (?:exited|timed out|aborted)|error|denied|failed)/i.test(
240
+ line,
241
+ ),
242
+ ) ?? lines[0]
243
+ );
244
+ }
245
+
246
+ function elapsed(activity: ToolActivity, now: number) {
247
+ if (activity.startedAt === undefined) return undefined;
248
+ const seconds = Math.floor(
249
+ ((activity.endedAt ?? now) - activity.startedAt) / 1000,
250
+ );
251
+ return seconds > 0 ? `${seconds}s` : undefined;
252
+ }
253
+
254
+ export function toolActivityText(
255
+ activity: ToolActivity,
256
+ theme: Theme,
257
+ now = Date.now(),
258
+ ) {
259
+ const row = activityRow(activity);
260
+ const duration = elapsed(activity, now);
261
+ const verbText = (
262
+ activity.status === "pending"
263
+ ? pendingVerb(activity.name)
264
+ : activity.status === "error"
265
+ ? "Failed"
266
+ : row.verb
267
+ ).padEnd(8);
268
+ const verb = theme.fg(
269
+ activity.status === "error"
270
+ ? "error"
271
+ : activity.status === "success"
272
+ ? "muted"
273
+ : "toolTitle",
274
+ verbText,
275
+ );
276
+
277
+ if (activity.status === "pending") {
278
+ const detail = duration ? ` · ${duration}` : "";
279
+ return `${theme.fg("warning", spinnerFrame(now))} ${verb} ${row.target}${theme.fg("dim", detail)}`;
280
+ }
281
+ if (activity.status === "error") {
282
+ const summary = errorSummary(activity.output);
283
+ const detail = [duration, summary].filter(Boolean).join(" · ");
284
+ return `${theme.fg("error", "✕")} ${verb} ${row.target}${detail ? theme.fg("dim", ` · ${detail}`) : ""}`;
285
+ }
286
+
287
+ const parts: string[] = [];
288
+ if (canonicalName(activity.name) === "edit") {
289
+ const stats = editStats(activity.details);
290
+ if (stats) {
291
+ parts.push(
292
+ `${theme.fg("success", `+${stats.additions}`)} ${theme.fg("error", `-${stats.removals}`)}`,
293
+ );
294
+ }
295
+ } else if (row.detail) {
296
+ parts.push(theme.fg("dim", row.detail));
297
+ }
298
+ if (duration) parts.push(theme.fg("dim", duration));
299
+ const detail = parts.join(theme.fg("dim", " · "));
300
+ return `${theme.fg("dim", activityIcon(activity.name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`;
301
+ }
302
+
303
+ export function renderToolActivityLine(
304
+ activity: ToolActivity,
305
+ theme: Theme,
306
+ width: number,
307
+ now = Date.now(),
308
+ ) {
309
+ const ellipsis = activity.status === "success" ? theme.fg("muted", "…") : "…";
310
+ return truncateToWidth(
311
+ toolActivityText(activity, theme, now),
312
+ width,
313
+ ellipsis,
314
+ );
315
+ }
316
+
317
+ /**
318
+ * Match the horizontal shell used by Pi's collapsed tool component. Keeping
319
+ * this here lets persisted child transcripts and live parent tools share the
320
+ * exact same operator-facing row without pretending bounded previews contain
321
+ * the full native tool result.
322
+ */
323
+ export function renderPaddedToolActivityLine(
324
+ activity: ToolActivity,
325
+ theme: Theme,
326
+ width: number,
327
+ now = Date.now(),
328
+ ) {
329
+ const padding = " ";
330
+ const contentWidth = width - padding.length * 2;
331
+ if (contentWidth <= 0) return "";
332
+ return `${padding}${renderToolActivityLine(activity, theme, contentWidth, now)}${padding}`;
333
+ }
334
+
335
+ /** Historical Direct helper retained without owning a second formatter. */
336
+ export function summarizeToolArgs(
337
+ name: string,
338
+ argsPreview?: string,
339
+ cwd?: string,
340
+ ) {
341
+ if (!argsPreview) return undefined;
342
+ const fallback = compact(argsPreview);
343
+ if (!fallback || fallback === "{}") return undefined;
344
+
345
+ let args: Record<string, unknown> | undefined;
346
+ try {
347
+ const parsed: unknown = JSON.parse(argsPreview);
348
+ if (
349
+ parsed !== null &&
350
+ typeof parsed === "object" &&
351
+ !Array.isArray(parsed)
352
+ ) {
353
+ args = parsed as Record<string, unknown>;
354
+ }
355
+ } catch {
356
+ return fallback;
357
+ }
358
+ if (!args) return fallback;
359
+
360
+ const field = (key: string) => {
361
+ const value = args[key];
362
+ return typeof value === "string" && value.length > 0
363
+ ? sanitizeTerminalText(value).replace(/\r?\n/g, " ↵ ")
364
+ : undefined;
365
+ };
366
+ const path = () => {
367
+ const value = field("path");
368
+ return value ? displayPath(value, cwd) : undefined;
369
+ };
370
+ const tool = canonicalName(name);
371
+ if (tool === "bash") return field("command") ?? fallback;
372
+ if (tool === "read" || tool === "write" || tool === "edit") {
373
+ return path() ?? fallback;
374
+ }
375
+ if (tool === "grep" || tool === "find") {
376
+ const pattern = field("pattern");
377
+ const searchPath = path();
378
+ if (pattern && searchPath) return `${pattern} · ${searchPath}`;
379
+ return pattern ?? searchPath ?? fallback;
380
+ }
381
+ return fallback;
382
+ }
@@ -17,19 +17,24 @@ export const OPENPI_TOOL_SURFACE = {
17
17
  entry: ["fd", "rg"],
18
18
  deferred: [],
19
19
  },
20
+ gitRead: {
21
+ entry: ["git_show", "git_diff", "git_log"],
22
+ deferred: [],
23
+ },
20
24
  subagents: {
21
- entry: ["subagent_spawn"],
22
- deferred: [
25
+ entry: [
26
+ "subagent_spawn",
23
27
  "subagent_wait",
24
28
  "subagent_cancel",
25
29
  "subagent_send",
26
30
  "subagent_check",
27
31
  "subagent_list",
28
32
  ],
33
+ deferred: [],
29
34
  },
30
35
  workflows: {
31
- entry: ["workflow"],
32
- deferred: ["workflow_stop", "workflow_status"],
36
+ entry: ["workflow", "workflow_stop", "workflow_status"],
37
+ deferred: [],
33
38
  },
34
39
  background: {
35
40
  entry: ["bg_start"],
@@ -65,8 +70,9 @@ export type OpenPiToolOwner = keyof typeof OPENPI_TOOL_SURFACE;
65
70
 
66
71
  export const OPENPI_CAPABILITY_GROUPS = {
67
72
  search: {
68
- owners: ["fileSearch"],
69
- summary: "Fast structured file and content search with fd and rg.",
73
+ owners: ["fileSearch", "gitRead"],
74
+ summary:
75
+ "Fast structured file and content search (fd, rg) plus read-only git inspection (git_show, git_diff, git_log).",
70
76
  },
71
77
  delegate: {
72
78
  owners: ["subagents"],
@@ -129,13 +135,14 @@ interface ToolSurfaceState {
129
135
  subscribed: boolean;
130
136
  }
131
137
 
132
- const OWNER_SOURCE_PATHS = {
138
+ export const OPENPI_OWNER_SOURCE_PATHS = {
133
139
  capabilities: fileURLToPath(
134
140
  new URL("../capabilities/index.ts", import.meta.url),
135
141
  ),
136
142
  fileSearch: fileURLToPath(
137
143
  new URL("../file-search/index.ts", import.meta.url),
138
144
  ),
145
+ gitRead: fileURLToPath(new URL("../git-read/index.ts", import.meta.url)),
139
146
  subagents: fileURLToPath(new URL("../subagents/index.ts", import.meta.url)),
140
147
  workflows: fileURLToPath(new URL("../workflows/index.ts", import.meta.url)),
141
148
  background: fileURLToPath(
@@ -261,7 +268,7 @@ function availableOwnedToolNames(
261
268
  }
262
269
 
263
270
  const expectedSource =
264
- state.sourceByOwner.get(owner) ?? OWNER_SOURCE_PATHS[owner];
271
+ state.sourceByOwner.get(owner) ?? OPENPI_OWNER_SOURCE_PATHS[owner];
265
272
  return new Set(
266
273
  reportedTools
267
274
  .filter(
@@ -272,6 +279,33 @@ function availableOwnedToolNames(
272
279
  );
273
280
  }
274
281
 
282
+ /** Verify that one active tool is the definition owned by this OpenPI source. */
283
+ export function isOwnedToolActive(
284
+ pi: ActiveToolSurface,
285
+ owner: OpenPiToolOwner,
286
+ name: string,
287
+ ) {
288
+ if (!ownedToolNames(owner).includes(name)) {
289
+ throw new Error(`${owner} does not own tool ${JSON.stringify(name)}.`);
290
+ }
291
+ return (
292
+ pi.getActiveTools().includes(name) &&
293
+ availableOwnedToolNames(pi, stateFor(pi), owner).has(name)
294
+ );
295
+ }
296
+
297
+ /** Verify that one registered tool is available from this OpenPI source. */
298
+ export function isOwnedToolAvailable(
299
+ pi: ActiveToolSurface,
300
+ owner: OpenPiToolOwner,
301
+ name: string,
302
+ ) {
303
+ if (!ownedToolNames(owner).includes(name)) {
304
+ throw new Error(`${owner} does not own tool ${JSON.stringify(name)}.`);
305
+ }
306
+ return availableOwnedToolNames(pi, stateFor(pi), owner).has(name);
307
+ }
308
+
275
309
  function projectedOwnerTools(
276
310
  pi: ActiveToolSurface,
277
311
  state: ToolSurfaceState,
@@ -0,0 +1,46 @@
1
+ function maxScrollTop(rowCount: number, viewportSize: number) {
2
+ return Math.max(0, rowCount - Math.max(1, viewportSize));
3
+ }
4
+
5
+ function clamp(value: number, maximum: number) {
6
+ return Math.max(0, Math.min(value, maximum));
7
+ }
8
+
9
+ /**
10
+ * Pure transcript viewport state with the same follow-to-end contract as Pi's
11
+ * ScrollView. Operators may pause following without new rows moving their
12
+ * absolute reading anchor; reaching the end explicitly resumes following.
13
+ */
14
+ export class TranscriptViewport {
15
+ scrollTop = 0;
16
+ followingEnd = true;
17
+
18
+ reconcile(rowCount: number, viewportSize: number) {
19
+ const maximum = maxScrollTop(rowCount, viewportSize);
20
+ this.scrollTop = this.followingEnd
21
+ ? maximum
22
+ : clamp(this.scrollTop, maximum);
23
+ }
24
+
25
+ scrollBy(delta: number, rowCount: number, viewportSize: number) {
26
+ this.reconcile(rowCount, viewportSize);
27
+ const maximum = maxScrollTop(rowCount, viewportSize);
28
+ this.scrollTop = clamp(this.scrollTop + delta, maximum);
29
+ this.followingEnd = this.scrollTop === maximum && delta > 0;
30
+ }
31
+
32
+ scrollToTop(rowCount: number, viewportSize: number) {
33
+ this.scrollTop = 0;
34
+ this.followingEnd = false;
35
+ this.reconcile(rowCount, viewportSize);
36
+ }
37
+
38
+ scrollToEnd(rowCount: number, viewportSize: number) {
39
+ this.followingEnd = true;
40
+ this.reconcile(rowCount, viewportSize);
41
+ }
42
+
43
+ linesBelow(rowCount: number, viewportSize: number) {
44
+ return maxScrollTop(rowCount, viewportSize) - this.scrollTop;
45
+ }
46
+ }