@tt-a1i/openpi 0.1.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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,421 @@
1
+ import type {
2
+ ExtensionCommandContext,
3
+ KeybindingsManager,
4
+ Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ Key,
8
+ matchesKey,
9
+ Text,
10
+ truncateToWidth,
11
+ type Component,
12
+ type TUI,
13
+ } from "@earendil-works/pi-tui";
14
+ import type { TaskItem, TaskSnapshot } from "./tasks.ts";
15
+
16
+ const STATUS_ICON: Record<TaskItem["status"], string> = {
17
+ pending: "○",
18
+ in_progress: "●",
19
+ blocked: "!",
20
+ done: "✓",
21
+ dropped: "×",
22
+ };
23
+
24
+ const TASK_WIDGET_ORDER: Record<TaskItem["status"], number> = {
25
+ in_progress: 0,
26
+ blocked: 1,
27
+ pending: 2,
28
+ done: 3,
29
+ dropped: 4,
30
+ };
31
+
32
+ export const TASK_WIDGET_LIMIT = 4;
33
+
34
+ /**
35
+ * A settled item's subject reads as struck-through and dimmed, so a glance at
36
+ * the list separates what is left from what is behind you.
37
+ *
38
+ * Both, not either: SGR 9 is widely but not universally supported, and a
39
+ * terminal that drops it would otherwise render done items identically to
40
+ * open ones. The dim color survives on its own.
41
+ */
42
+ function subjectStyle(status: TaskItem["status"], theme: Theme) {
43
+ if (status === "done" || status === "dropped") {
44
+ return (text: string) => theme.strikethrough(theme.fg("dim", text));
45
+ }
46
+ // The one item being worked on is the only thing most glances are looking
47
+ // for, so it is the only one rendered at full weight.
48
+ if (status === "in_progress") {
49
+ return (text: string) => theme.bold(theme.fg("text", text));
50
+ }
51
+ return (text: string) =>
52
+ theme.fg(status === "blocked" ? "text" : "muted", text);
53
+ }
54
+
55
+ const SUMMARY_LABEL: Record<TaskItem["status"], string> = {
56
+ done: "done",
57
+ in_progress: "in progress",
58
+ blocked: "blocked",
59
+ pending: "open",
60
+ dropped: "dropped",
61
+ };
62
+
63
+ /** Order the counts by how far along they are, not by status enum order. */
64
+ const SUMMARY_ORDER: TaskItem["status"][] = [
65
+ "done",
66
+ "in_progress",
67
+ "blocked",
68
+ "pending",
69
+ "dropped",
70
+ ];
71
+
72
+ /** Census of a whole batch, independent of which rows a view chooses to show. */
73
+ export type TaskCounts = Record<TaskItem["status"], number> & { total: number };
74
+
75
+ export function taskCounts(items: readonly TaskItem[]): TaskCounts {
76
+ const counts: TaskCounts = {
77
+ total: items.length,
78
+ pending: 0,
79
+ in_progress: 0,
80
+ blocked: 0,
81
+ done: 0,
82
+ dropped: 0,
83
+ };
84
+ for (const item of items) counts[item.status]++;
85
+ return counts;
86
+ }
87
+
88
+ /**
89
+ * One-line census: `4 tasks (3 done, 1 in progress, 0 open)`.
90
+ *
91
+ * Counts carry the weight and the words recede, so the shape of the batch
92
+ * reads before any individual row does. `done`, `in progress`, and `open` are
93
+ * always listed even at zero — a stable set of three keeps the line from
94
+ * reflowing as work moves between them. `blocked` and `dropped` are
95
+ * exceptional and appear only when they are not zero.
96
+ *
97
+ * Takes counts rather than items because a view often shows a bounded subset
98
+ * of rows; the header must describe the whole batch regardless.
99
+ */
100
+ export function renderTaskSummary(counts: TaskCounts, theme: Theme): string {
101
+ const number = (value: number) => theme.bold(theme.fg("text", String(value)));
102
+ const dim = (text: string) => theme.fg("dim", text);
103
+ // Coerced, not trusted: these counts can arrive from a tool-result record
104
+ // persisted by an older build, where a missing key would render the literal
105
+ // word "undefined" (or "NaN tasks") into the header.
106
+ const count = (status: TaskItem["status"]) =>
107
+ Number.isFinite(counts[status]) ? counts[status] : 0;
108
+ const total = Number.isFinite(counts.total) ? counts.total : 0;
109
+ // Built segment by segment rather than by wrapping the whole line: each
110
+ // styled run emits its own reset, so an outer color would stop applying at
111
+ // the first inner one.
112
+ const parts = SUMMARY_ORDER.filter(
113
+ (status) =>
114
+ status === "done" ||
115
+ status === "in_progress" ||
116
+ status === "pending" ||
117
+ count(status) > 0,
118
+ ).map((status) => `${number(count(status))} ${dim(SUMMARY_LABEL[status])}`);
119
+ return [
120
+ number(total),
121
+ " ",
122
+ dim(total === 1 ? "task" : "tasks"),
123
+ " ",
124
+ dim("("),
125
+ parts.join(dim(", ")),
126
+ dim(")"),
127
+ ].join("");
128
+ }
129
+
130
+ export interface TaskToolDetails {
131
+ action: "add" | "update" | "list";
132
+ items: TaskItem[];
133
+ total: number;
134
+ revision: number;
135
+ batchClosed?: boolean;
136
+ /** Census of the whole batch; `items` is only the rows this call touched. */
137
+ counts?: TaskCounts;
138
+ }
139
+
140
+ export function renderTaskRows(
141
+ items: readonly TaskItem[],
142
+ theme: Theme,
143
+ width: number,
144
+ ) {
145
+ if (items.length === 0) return [theme.fg("dim", "No task items.")];
146
+ // Ids address tasks in tasks_update, so they stay — but right-aligned, so a
147
+ // T10 appearing later never shifts every subject one column over.
148
+ // Floor of 3 ("T99"), not 2: the width is computed per view, so a batch
149
+ // crossing T9 — or the same batch shown collapsed (5 rows) then expanded
150
+ // (all of them) — would otherwise shift every subject sideways by a column.
151
+ const idWidth = Math.max(...items.map((item) => `T${item.id}`.length), 3);
152
+ return items.flatMap((item) => {
153
+ const color =
154
+ item.status === "done"
155
+ ? "success"
156
+ : item.status === "blocked"
157
+ ? "warning"
158
+ : item.status === "dropped"
159
+ ? "error"
160
+ : item.status === "in_progress"
161
+ ? "accent"
162
+ : "muted";
163
+ // No `[status]` text: the icon, its color, and the subject's own weight
164
+ // already say it, and repeating it in words crowded every row.
165
+ const id = `T${item.id}`.padStart(idWidth);
166
+ const rows = [
167
+ truncateToWidth(
168
+ `${theme.fg(color, STATUS_ICON[item.status])} ${theme.fg("dim", id)} ${subjectStyle(item.status, theme)(item.subject)}`,
169
+ width,
170
+ ),
171
+ ];
172
+ // Continuation lines hang under the subject, not the icon, so the eye
173
+ // follows one left edge down the list.
174
+ const indent = " ".repeat(idWidth + 3);
175
+ if (item.detail) {
176
+ rows.push(
177
+ truncateToWidth(`${indent}${theme.fg("dim", item.detail)}`, width),
178
+ );
179
+ }
180
+ if (item.note) {
181
+ const label =
182
+ item.status === "blocked"
183
+ ? "Blocked"
184
+ : item.status === "done"
185
+ ? "Evidence"
186
+ : item.status === "dropped"
187
+ ? "Reason"
188
+ : "Note";
189
+ rows.push(
190
+ truncateToWidth(
191
+ `${indent}${theme.fg("dim", `${label}:`)} ${theme.fg("muted", item.note)}`,
192
+ width,
193
+ ),
194
+ );
195
+ }
196
+ return rows;
197
+ });
198
+ }
199
+
200
+ export function renderTaskWidget(
201
+ snapshot: TaskSnapshot,
202
+ theme: Theme,
203
+ width: number,
204
+ expanded = false,
205
+ ) {
206
+ const tracked = snapshot.items.filter((item) => item.status !== "dropped");
207
+ const actionable = tracked
208
+ .filter((item) => item.status !== "done")
209
+ .sort(
210
+ (left, right) =>
211
+ TASK_WIDGET_ORDER[left.status] - TASK_WIDGET_ORDER[right.status] ||
212
+ left.id - right.id,
213
+ );
214
+ if (actionable.length === 0) return [];
215
+
216
+ const hasOverflow = actionable.length > TASK_WIDGET_LIMIT;
217
+ const toggleHint = hasOverflow
218
+ ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}`
219
+ : "";
220
+ // Same census as the full list and the /tasks screen. Counted over `tracked`
221
+ // rather than every item, because the widget deliberately hides dropped work
222
+ // and a total that included it would not add up against the rows shown.
223
+ const header =
224
+ theme.fg("accent", "◆ ") +
225
+ theme.fg("text", theme.bold("Tasks")) +
226
+ " " +
227
+ renderTaskSummary(taskCounts(tracked), theme) +
228
+ theme.fg("dim", ` · /tasks${toggleHint}`);
229
+ const visible = expanded
230
+ ? actionable
231
+ : actionable.slice(0, TASK_WIDGET_LIMIT);
232
+ const hidden = actionable.length - visible.length;
233
+ // Right-aligned like the full list, with the same floor: a widget whose ids
234
+ // are ragged next to a list whose ids are not reads as a different control.
235
+ const idWidth = Math.max(...visible.map((i) => `T${i.id}`.length), 3);
236
+ const lines = [truncateToWidth(header, width)];
237
+ for (const [index, item] of visible.entries()) {
238
+ const color =
239
+ item.status === "in_progress"
240
+ ? "warning"
241
+ : item.status === "blocked"
242
+ ? "error"
243
+ : "muted";
244
+ const branch = index === visible.length - 1 && hidden === 0 ? "╰─" : "├─";
245
+ lines.push(
246
+ truncateToWidth(
247
+ // Same subject weighting as the full list, so the item in flight reads
248
+ // the same wherever you happen to be looking.
249
+ `${theme.fg("dim", branch)} ${theme.fg(color, STATUS_ICON[item.status])} ${theme.fg("dim", `T${item.id}`.padStart(idWidth))} ${subjectStyle(item.status, theme)(item.subject)}`,
250
+ width,
251
+ ),
252
+ );
253
+ }
254
+ if (hidden > 0) {
255
+ lines.push(
256
+ truncateToWidth(theme.fg("dim", `╰─ … ${hidden} more tasks`), width),
257
+ );
258
+ }
259
+ return lines;
260
+ }
261
+
262
+ /**
263
+ * Rows are built at the width they will be shown at, not at a fixed width and
264
+ * then re-wrapped. `Text` re-wraps with a wrapper that closes a line's colour
265
+ * and underline but NOT strikethrough, so a row laid out for a wider terminal
266
+ * and folded here left SGR 9 open across the fold — the padding to the right
267
+ * of the break rendered as a solid struck-through bar. `truncateToWidth`
268
+ * emits a full reset, so cutting at the real width is safe.
269
+ */
270
+ class TaskResultView implements Component {
271
+ private readonly details: TaskToolDetails;
272
+ private readonly expanded: boolean;
273
+ private readonly theme: Theme;
274
+
275
+ constructor(details: TaskToolDetails, expanded: boolean, theme: Theme) {
276
+ this.details = details;
277
+ this.expanded = expanded;
278
+ this.theme = theme;
279
+ }
280
+
281
+ render(width: number): string[] {
282
+ const theme = this.theme;
283
+ const details = this.details;
284
+ const items = this.expanded ? details.items : details.items.slice(0, 5);
285
+ const rows: string[] = [];
286
+ // A closed batch has already been cleared from the live snapshot, so a
287
+ // census here would read "0 tasks" directly above the rows it describes.
288
+ // The "Batch complete" line below says everything that is left to say.
289
+ if (!details.batchClosed && details.counts) {
290
+ rows.push(renderTaskSummary(details.counts, theme));
291
+ }
292
+ rows.push(...renderTaskRows(items, theme, width));
293
+ if (!this.expanded && details.items.length > items.length) {
294
+ rows.push(
295
+ theme.fg("dim", `… ${details.items.length - items.length} more`),
296
+ );
297
+ }
298
+ if (details.batchClosed) {
299
+ rows.push(
300
+ theme.fg("success", "✓ Batch complete") +
301
+ theme.fg("dim", " · next request starts at T1"),
302
+ );
303
+ }
304
+ return rows.map((row) => truncateToWidth(row, width, "…"));
305
+ }
306
+
307
+ /** Nothing is cached between renders, so there is nothing to drop. */
308
+ invalidate() {}
309
+ }
310
+
311
+ export function renderToolResult(
312
+ details: unknown,
313
+ expanded: boolean,
314
+ theme: Theme,
315
+ fallbackText = "Tasks updated.",
316
+ ): Component {
317
+ if (
318
+ !details ||
319
+ typeof details !== "object" ||
320
+ !Array.isArray((details as Partial<TaskToolDetails>).items)
321
+ ) {
322
+ return new Text(theme.fg("dim", fallbackText), 0, 0);
323
+ }
324
+ return new TaskResultView(details as TaskToolDetails, expanded, theme);
325
+ }
326
+
327
+ class TasksScreen implements Component {
328
+ private offset = 0;
329
+ private readonly tui: TUI;
330
+ private readonly theme: Theme;
331
+ private readonly keybindings: KeybindingsManager;
332
+ private readonly snapshot: TaskSnapshot;
333
+ private readonly done: () => void;
334
+
335
+ constructor(
336
+ tui: TUI,
337
+ theme: Theme,
338
+ keybindings: KeybindingsManager,
339
+ snapshot: TaskSnapshot,
340
+ done: () => void,
341
+ ) {
342
+ this.tui = tui;
343
+ this.theme = theme;
344
+ this.keybindings = keybindings;
345
+ this.snapshot = snapshot;
346
+ this.done = done;
347
+ }
348
+
349
+ handleInput(data: string) {
350
+ if (
351
+ this.keybindings.matches(data, "tui.select.cancel") ||
352
+ matchesKey(data, Key.escape)
353
+ ) {
354
+ this.done();
355
+ return;
356
+ }
357
+ if (this.keybindings.matches(data, "tui.editor.cursorUp") || data === "k") {
358
+ this.offset = Math.max(0, this.offset - 1);
359
+ this.tui.requestRender();
360
+ return;
361
+ }
362
+ if (
363
+ this.keybindings.matches(data, "tui.editor.cursorDown") ||
364
+ data === "j"
365
+ ) {
366
+ this.offset += 1;
367
+ this.tui.requestRender();
368
+ return;
369
+ }
370
+ if (this.keybindings.matches(data, "tui.editor.pageUp")) {
371
+ this.offset = Math.max(0, this.offset - 10);
372
+ this.tui.requestRender();
373
+ return;
374
+ }
375
+ if (this.keybindings.matches(data, "tui.editor.pageDown")) {
376
+ this.offset += 10;
377
+ this.tui.requestRender();
378
+ }
379
+ }
380
+
381
+ render(width: number) {
382
+ const body = renderTaskRows(this.snapshot.items, this.theme, width - 4);
383
+ const rows = Math.max(8, (this.tui.terminal.rows || 30) - 8);
384
+ const maxOffset = Math.max(0, body.length - rows);
385
+ this.offset = Math.min(this.offset, maxOffset);
386
+ const visible = body.slice(this.offset, this.offset + rows);
387
+ const lines = [
388
+ truncateToWidth(
389
+ `${this.theme.fg("accent", this.theme.bold("Session tasks"))} ${renderTaskSummary(taskCounts(this.snapshot.items), this.theme)}`,
390
+ width,
391
+ ),
392
+ this.theme.fg("border", "─".repeat(Math.max(0, width))),
393
+ ...visible.map((line) => truncateToWidth(` ${line}`, width)),
394
+ ];
395
+ while (lines.length < rows + 2) lines.push("");
396
+ lines.push(
397
+ truncateToWidth(
398
+ this.theme.fg("dim", "j/k or ↑/↓ scroll · pgup/pgdn page · esc close"),
399
+ width,
400
+ ),
401
+ );
402
+ return lines;
403
+ }
404
+
405
+ invalidate() {}
406
+ }
407
+
408
+ export async function openTasksScreen(
409
+ ctx: ExtensionCommandContext,
410
+ snapshot: TaskSnapshot,
411
+ ) {
412
+ if (ctx.mode !== "tui") {
413
+ if (ctx.hasUI)
414
+ ctx.ui.notify(`${snapshot.items.length} task item(s)`, "info");
415
+ return;
416
+ }
417
+ await ctx.ui.custom<void>(
418
+ (tui, theme, keybindings, done) =>
419
+ new TasksScreen(tui, theme, keybindings, snapshot, () => done()),
420
+ );
421
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Close each finished request with the time it took, rendered under the final
3
+ * message. The entry is TUI-only: it is never sent to the model.
4
+ */
5
+
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { Text } from "@earendil-works/pi-tui";
8
+
9
+ interface TurnTimeData {
10
+ ms: number;
11
+ }
12
+
13
+ /** Sub-second requests are noise, not information. */
14
+ const MIN_REPORTED_MS = 1_000;
15
+
16
+ export function formatTurnDuration(ms: number) {
17
+ const seconds = Math.round(ms / 1000);
18
+ if (seconds < 60) return `${seconds}s`;
19
+ const minutes = Math.floor(seconds / 60);
20
+ if (minutes < 60) {
21
+ return `${minutes}m${(seconds % 60).toString().padStart(2, "0")}s`;
22
+ }
23
+ return `${Math.floor(minutes / 60)}h${(minutes % 60).toString().padStart(2, "0")}m`;
24
+ }
25
+
26
+ export default function (pi: ExtensionAPI) {
27
+ // Measured from the first agent run to the settle that ends the request, so
28
+ // retries, auto-compaction, and queued continuations are all included.
29
+ let startedAt: number | undefined;
30
+
31
+ pi.registerEntryRenderer<TurnTimeData>(
32
+ "turn-time",
33
+ (entry, _options, theme) => {
34
+ const ms = entry.data?.ms;
35
+ return new Text(
36
+ theme.fg(
37
+ "dim",
38
+ `✳ Worked for ${ms === undefined ? "?" : formatTurnDuration(ms)}`,
39
+ ),
40
+ 1,
41
+ 0,
42
+ );
43
+ },
44
+ );
45
+
46
+ pi.on("agent_start", () => {
47
+ startedAt ??= Date.now();
48
+ });
49
+
50
+ pi.on("agent_settled", () => {
51
+ if (startedAt === undefined) return;
52
+ const ms = Date.now() - startedAt;
53
+ startedAt = undefined;
54
+ if (ms < MIN_REPORTED_MS) return;
55
+ pi.appendEntry<TurnTimeData>("turn-time", { ms });
56
+ });
57
+
58
+ pi.on("session_shutdown", () => {
59
+ startedAt = undefined;
60
+ });
61
+ }