@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
@@ -1,6 +1,13 @@
1
- import { formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ formatSize,
3
+ truncateHead,
4
+ truncateTail,
5
+ } from "@earendil-works/pi-coding-agent";
2
6
 
3
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;
4
11
 
5
12
  function utf8Prefix(content: string, maxBytes: number) {
6
13
  const bytes = Buffer.from(content, "utf8");
@@ -14,16 +21,46 @@ function utf8Prefix(content: string, maxBytes: number) {
14
21
  return "";
15
22
  }
16
23
 
17
- function utf8Suffix(content: string, maxBytes: number) {
18
- const bytes = Buffer.from(content, "utf8");
19
- if (bytes.length <= maxBytes) return content;
20
- let start = Math.max(0, bytes.length - maxBytes);
21
- while (start < bytes.length) {
22
- const value = bytes.subarray(start).toString("utf8");
23
- if (!value.startsWith("�")) return value;
24
- start += 1;
25
- }
26
- return "";
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");
27
64
  }
28
65
 
29
66
  /** Keep decision context at the start and verdict/evidence at the end. */
@@ -31,26 +68,46 @@ export function projectText(
31
68
  content: string,
32
69
  options: { maxBytes: number; maxLines: number; recovery: string },
33
70
  ) {
71
+ if (options.maxBytes <= 0 || options.maxLines <= 0) return "";
72
+
34
73
  const probe = truncateHead(content, {
35
74
  maxBytes: options.maxBytes,
36
75
  maxLines: options.maxLines,
37
76
  });
38
77
  if (!probe.truncated) return content;
39
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
+
40
88
  let bodyBudget = options.maxBytes;
41
89
  let projected = "";
42
90
  for (let attempt = 0; attempt < 8; attempt++) {
43
91
  const headBytes = Math.max(1, Math.floor(bodyBudget * HEAD_SHARE));
44
92
  const tailBytes = Math.max(1, bodyBudget - headBytes);
45
- const head = utf8Prefix(content, headBytes);
46
- const tail = utf8Suffix(content, tailBytes);
93
+ const head = boundedHead(content, headBytes, headLines);
94
+ const tail = truncateTail(content, {
95
+ maxBytes: tailBytes,
96
+ maxLines: tailLines,
97
+ }).content;
47
98
  const shown =
48
99
  Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
49
100
  const footer = `[Projection bounded: showing ${formatSize(shown)} of ${formatSize(probe.totalBytes)} across the head and tail. ${options.recovery}]`;
50
- projected = `${head}\n\n[... middle omitted ...]\n\n${tail}\n\n${footer}`;
101
+ projected = `${head}\n\n${OMISSION_MARKER}\n\n${tail}\n\n${footer}`;
51
102
  const overflow = Buffer.byteLength(projected, "utf8") - options.maxBytes;
52
103
  if (overflow <= 0 || bodyBudget <= overflow + 2) break;
53
104
  bodyBudget -= overflow;
54
105
  }
55
- return projected;
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;
56
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
+ }
@@ -135,7 +135,7 @@ interface ToolSurfaceState {
135
135
  subscribed: boolean;
136
136
  }
137
137
 
138
- const OWNER_SOURCE_PATHS = {
138
+ export const OPENPI_OWNER_SOURCE_PATHS = {
139
139
  capabilities: fileURLToPath(
140
140
  new URL("../capabilities/index.ts", import.meta.url),
141
141
  ),
@@ -268,7 +268,7 @@ function availableOwnedToolNames(
268
268
  }
269
269
 
270
270
  const expectedSource =
271
- state.sourceByOwner.get(owner) ?? OWNER_SOURCE_PATHS[owner];
271
+ state.sourceByOwner.get(owner) ?? OPENPI_OWNER_SOURCE_PATHS[owner];
272
272
  return new Set(
273
273
  reportedTools
274
274
  .filter(
@@ -279,6 +279,33 @@ function availableOwnedToolNames(
279
279
  );
280
280
  }
281
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
+
282
309
  function projectedOwnerTools(
283
310
  pi: ActiveToolSurface,
284
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
+ }