@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,133 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ /**
5
+ * Only the two paint calls this chrome makes, following the footer's precedent:
6
+ * views that carry a narrowed theme object can use it without a cast.
7
+ */
8
+ type Theme = Pick<ExtensionContext["ui"]["theme"], "fg" | "bold">;
9
+
10
+ /**
11
+ * Some labels arrive already styled (a task census colours each state). Painting
12
+ * a colour over them would apply only up to their first inner reset, leaving the
13
+ * rest a different shade than the caller asked for, so pre-styled text is passed
14
+ * through untouched.
15
+ */
16
+ const isStyled = (text: string) => text.includes("\u001b");
17
+
18
+ /**
19
+ * The chrome every full-screen OpenPI view shares: a title line, one bordered
20
+ * panel, a hint line, and one way to say "there is more above/below".
21
+ *
22
+ * Three views had grown their own copy of this (`/subagents`, `/ps`,
23
+ * `/workflows`), and they had drifted apart in the details you notice without
24
+ * being able to name: one framed the body in `border`, another in
25
+ * `borderMuted`; one wrote `... 3 more` and another `… 3 more`; hints were a
26
+ * single dim run in which the keys you are supposed to press read exactly as
27
+ * faint as the prose describing them. Fixing that in one place is also the only
28
+ * way it stays fixed.
29
+ */
30
+
31
+ /** Title left, quiet census right, with the same one-column inset as the panel. */
32
+ export function screenTitleLine(
33
+ theme: Theme,
34
+ title: string,
35
+ meta: string,
36
+ width: number,
37
+ ) {
38
+ const left = ` ${theme.bold(theme.fg("accent", title))}`;
39
+ const right = meta ? `${isStyled(meta) ? meta : theme.fg("dim", meta)} ` : "";
40
+ const rightWidth = visibleWidth(right);
41
+ const fittedLeft = truncateToWidth(
42
+ left,
43
+ Math.max(0, width - rightWidth - 1),
44
+ "…",
45
+ );
46
+ const pad = Math.max(1, width - visibleWidth(fittedLeft) - rightWidth);
47
+ return truncateToWidth(fittedLeft + " ".repeat(pad) + right, width, "");
48
+ }
49
+
50
+ /**
51
+ * Bordered panel with an optional label set into the top edge, padded to an
52
+ * exact height so a view's overlay never changes size as content streams in.
53
+ */
54
+ export function panelFrame(
55
+ theme: Theme,
56
+ options: {
57
+ label?: string;
58
+ rows: readonly string[];
59
+ width: number;
60
+ height: number;
61
+ },
62
+ ) {
63
+ const { label = "", rows, width, height } = options;
64
+ const inner = Math.max(0, width - 2);
65
+ const border = (text: string) => theme.fg("borderMuted", text);
66
+ // The label rides the border rather than sitting above it, so it reads as
67
+ // this panel's name instead of competing with the screen title.
68
+ const clippedLabel = label
69
+ ? truncateToWidth(` ${label} `, Math.max(0, inner - 2))
70
+ : "";
71
+ const labelText = clippedLabel
72
+ ? isStyled(clippedLabel)
73
+ ? clippedLabel
74
+ : theme.fg("muted", clippedLabel)
75
+ : "";
76
+ const dashes = Math.max(0, inner - visibleWidth(labelText) - 1);
77
+ const lines = [border("╭─") + labelText + border("─".repeat(dashes) + "╮")];
78
+ const bodyHeight = Math.max(0, height - 2);
79
+ for (let index = 0; index < bodyHeight; index += 1) {
80
+ const clipped = truncateToWidth(rows[index] ?? "", inner, "…");
81
+ const pad = Math.max(0, inner - visibleWidth(clipped));
82
+ lines.push(border("│") + clipped + " ".repeat(pad) + border("│"));
83
+ }
84
+ lines.push(border("╰" + "─".repeat(inner) + "╯"));
85
+ return lines;
86
+ }
87
+
88
+ /** One `keys label` pair of a hint line. */
89
+ export type ScreenHint = readonly [keys: string, label: string];
90
+
91
+ /**
92
+ * Keys read one step brighter than what they do, so the line scans as a
93
+ * keyboard legend instead of a sentence. A notice takes the whole line when
94
+ * there is one: it is the answer to what you just pressed, and the legend can
95
+ * wait a beat.
96
+ */
97
+ export function hintLine(
98
+ theme: Theme,
99
+ hints: readonly (ScreenHint | undefined)[],
100
+ width: number,
101
+ notice?: string,
102
+ ) {
103
+ if (notice) {
104
+ return truncateToWidth(theme.fg("accent", ` ${notice}`), width, "…");
105
+ }
106
+ const parts = hints
107
+ .filter((hint): hint is ScreenHint => Boolean(hint))
108
+ .map(([keys, label]) => {
109
+ // An empty key slot is a plain status segment (a scroll position, say);
110
+ // an empty label is a bare key. Both stay dimmer than a real key.
111
+ if (!keys) return theme.fg("dim", label);
112
+ if (!label) return theme.fg("muted", keys);
113
+ return `${theme.fg("muted", keys)} ${theme.fg("dim", label)}`;
114
+ });
115
+ return truncateToWidth(
116
+ ` ${parts.join(theme.fg("dim", " · "))}`,
117
+ width,
118
+ theme.fg("dim", "…"),
119
+ );
120
+ }
121
+
122
+ /** Single vocabulary for a clipped list: `… 3 more agents`. */
123
+ export function overflowNote(
124
+ theme: Theme,
125
+ count: number,
126
+ width: number,
127
+ noun = "",
128
+ ) {
129
+ return truncateToWidth(
130
+ theme.fg("dim", ` … ${count} more${noun ? ` ${noun}` : ""}`),
131
+ width,
132
+ );
133
+ }
@@ -69,7 +69,28 @@ export type CapabilityDiscoveryMode =
69
69
 
70
70
  /** Canonical default layout: one-line plain footer with flex alignment. */
71
71
  export const DEFAULT_FOOTER_LINES: FooterLines = [
72
- ["cwd", "git", "pr", "flex", "model", "context", "cost"],
72
+ ["model", "context", "flex", "git", "pr", "cwd"],
73
+ ];
74
+
75
+ /** Stable skeleton for the legacy flat-selection adapter. */
76
+ const FLAT_FOOTER_ITEM_LINES: FooterLines = [
77
+ [
78
+ "model",
79
+ "thinking",
80
+ "context",
81
+ "cache",
82
+ "cost",
83
+ "throughput",
84
+ "flex",
85
+ "git",
86
+ "pr",
87
+ "cwd",
88
+ ],
89
+ ];
90
+
91
+ /** The persisted canonical default before model/context moved to the left. */
92
+ const LEGACY_DEFAULT_FOOTER_LINES: FooterLines = [
93
+ ["cwd", "git", "pr", "flex", "model", "context"],
73
94
  ];
74
95
 
75
96
  export const DEFAULT_FOOTER_STYLE: FooterStyle = "plain";
@@ -88,7 +109,7 @@ export const FOOTER_PRESET_DEFINITIONS: Record<
88
109
  > = {
89
110
  compact: {
90
111
  style: "plain",
91
- lines: [["cwd", "model", "thinking", "context", "flex", "git", "pr"]],
112
+ lines: DEFAULT_FOOTER_LINES,
92
113
  },
93
114
  powerline: {
94
115
  style: "powerline",
@@ -131,9 +152,7 @@ export interface MyPiSetupConfig {
131
152
  readonly showHeader: boolean;
132
153
  readonly customFooter: boolean;
133
154
  readonly footerStyle: FooterStyle;
134
- /** Canonical footer layout. `footerItems` is always derived from this. */
135
155
  readonly footerLines: FooterLines;
136
- readonly footerItems: readonly FooterItem[];
137
156
  readonly subagentResultDisplay: DetailDisplay;
138
157
  readonly bashToolDisplay: DetailDisplay;
139
158
  readonly fileMutationDisplay: DetailDisplay;
@@ -164,8 +183,7 @@ export const DEFAULT_SETUP_CONFIG: MyPiSetupConfig = {
164
183
  customFooter: true,
165
184
  footerStyle: DEFAULT_FOOTER_STYLE,
166
185
  footerLines: DEFAULT_FOOTER_LINES,
167
- footerItems: DEFAULT_FOOTER_ITEMS,
168
- subagentResultDisplay: "full",
186
+ subagentResultDisplay: "compact",
169
187
  bashToolDisplay: "compact",
170
188
  fileMutationDisplay: "compact",
171
189
  },
@@ -256,13 +274,13 @@ export function normalizeFooterLines(value: unknown): FooterLines {
256
274
  return lines.length > 0 ? lines : DEFAULT_FOOTER_LINES;
257
275
  }
258
276
 
259
- /** Map a flat item list onto the default one-line skeleton (legacy compat). */
277
+ /** Map a flat item list onto the stable one-line skeleton (legacy compat). */
260
278
  export function footerLinesFromItems(
261
279
  items: readonly FooterItem[],
262
280
  ): FooterLines {
263
281
  const selected = new Set(items);
264
282
  return normalizeFooterLines(
265
- DEFAULT_FOOTER_LINES.map((line) =>
283
+ FLAT_FOOTER_ITEM_LINES.map((line) =>
266
284
  line.filter((item) => item === "flex" || selected.has(item)),
267
285
  ),
268
286
  );
@@ -330,7 +348,6 @@ export function applyFooterConfig(
330
348
  return {
331
349
  footerStyle: style,
332
350
  footerLines: normalized,
333
- footerItems: flattenFooterItems(normalized),
334
351
  };
335
352
  }
336
353
 
@@ -340,19 +357,32 @@ function parseFooterItems(value: unknown): readonly FooterItem[] {
340
357
  return items.length > 0 ? items : DEFAULT_FOOTER_ITEMS;
341
358
  }
342
359
 
360
+ function sameFooterLines(left: FooterLines, right: FooterLines) {
361
+ return (
362
+ left.length === right.length &&
363
+ left.every(
364
+ (line, lineIndex) =>
365
+ line.length === right[lineIndex]?.length &&
366
+ line.every((item, itemIndex) => item === right[lineIndex]?.[itemIndex]),
367
+ )
368
+ );
369
+ }
370
+
343
371
  function parseUiFooter(
344
372
  ui: Record<string, unknown>,
345
- ): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines" | "footerItems"> {
373
+ ): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines"> {
346
374
  const style = isFooterStyle(ui.footerStyle)
347
375
  ? ui.footerStyle
348
376
  : DEFAULT_FOOTER_STYLE;
349
377
 
350
378
  if (ui.footerLines !== undefined) {
351
- const lines = normalizeFooterLines(ui.footerLines);
379
+ const normalized = normalizeFooterLines(ui.footerLines);
380
+ const lines = sameFooterLines(normalized, LEGACY_DEFAULT_FOOTER_LINES)
381
+ ? DEFAULT_FOOTER_LINES
382
+ : normalized;
352
383
  return {
353
384
  footerStyle: style,
354
385
  footerLines: lines,
355
- footerItems: flattenFooterItems(lines),
356
386
  };
357
387
  }
358
388
 
@@ -363,14 +393,12 @@ function parseUiFooter(
363
393
  return {
364
394
  footerStyle: style,
365
395
  footerLines: lines,
366
- footerItems: flattenFooterItems(lines),
367
396
  };
368
397
  }
369
398
 
370
399
  return {
371
400
  footerStyle: style,
372
401
  footerLines: DEFAULT_FOOTER_LINES,
373
- footerItems: DEFAULT_FOOTER_ITEMS,
374
402
  };
375
403
  }
376
404
 
@@ -465,7 +493,7 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
465
493
  ui.subagentResultDisplay as DetailDisplay,
466
494
  )
467
495
  ? (ui.subagentResultDisplay as DetailDisplay)
468
- : "full",
496
+ : "compact",
469
497
  bashToolDisplay: DETAIL_DISPLAYS.includes(
470
498
  ui.bashToolDisplay as DetailDisplay,
471
499
  )
@@ -485,9 +513,9 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
485
513
  /** An empty or invalid value disables the post-edit command (the safe default). */
486
514
  function parsePostEditCommand(value: unknown) {
487
515
  if (!isRecord(value)) return "";
488
- return typeof value.command === "string"
489
- ? value.command.trim().slice(0, POST_EDIT_COMMAND_MAX_CHARS)
490
- : "";
516
+ if (typeof value.command !== "string") return "";
517
+ const command = value.command.trim();
518
+ return command.length <= POST_EDIT_COMMAND_MAX_CHARS ? command : "";
491
519
  }
492
520
 
493
521
  export function hasSavedSetupConfig() {
@@ -542,6 +570,9 @@ function replacedFields(
542
570
  ) {
543
571
  paths.push("summaries → suggestions");
544
572
  }
573
+ if (path === "ui" && "footerItems" in raw && "footerLines" in normalized) {
574
+ paths.push("ui.footerItems");
575
+ }
545
576
  for (const [key, value] of Object.entries(normalized)) {
546
577
  if (!(key in raw)) continue;
547
578
  const here = path ? `${path}.${key}` : key;
@@ -608,24 +639,55 @@ const sameOwner = (left: LockOwner, right: LockOwner) =>
608
639
  const sameFile = (left: Stats, right: Stats) =>
609
640
  left.dev === right.dev && left.ino === right.ino;
610
641
 
642
+ const parsePowerShellProcessStartedAt = (stdout: string) => {
643
+ const startedAt = Number(stdout.trim());
644
+ return isPositiveInteger(startedAt) ? startedAt : undefined;
645
+ };
646
+
647
+ const parsePsProcessStartedAt = (stdout: string) => {
648
+ const startedAt = Date.parse(stdout.trim());
649
+ return Number.isFinite(startedAt) ? startedAt : undefined;
650
+ };
651
+
652
+ export function processStartedAtQuery(
653
+ pid: number,
654
+ platform = process.platform,
655
+ ) {
656
+ if (platform === "win32") {
657
+ const script = [
658
+ "$ErrorActionPreference='Stop'",
659
+ `$p=Get-Process -Id ${pid}`,
660
+ "[Console]::Out.Write(([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds())",
661
+ ].join("; ");
662
+ return {
663
+ command: "powershell.exe",
664
+ args: ["-NoProfile", "-NonInteractive", "-Command", script],
665
+ env: undefined,
666
+ parseOutput: parsePowerShellProcessStartedAt,
667
+ };
668
+ }
669
+ return {
670
+ command: "ps",
671
+ args: ["-o", "lstart=", "-p", String(pid)],
672
+ env: { LC_ALL: "C" },
673
+ parseOutput: parsePsProcessStartedAt,
674
+ };
675
+ }
676
+
611
677
  const queryProcessStartedAt = (pid: number) =>
612
678
  new Promise<number | undefined>((resolve) => {
613
- if (process.platform === "win32") {
614
- resolve(undefined);
615
- return;
616
- }
679
+ const query = processStartedAtQuery(pid);
617
680
  try {
618
681
  execFile(
619
- "ps",
620
- ["-o", "lstart=", "-p", String(pid)],
682
+ query.command,
683
+ query.args,
621
684
  {
622
685
  encoding: "utf8",
623
- env: { ...process.env, LC_ALL: "C" },
686
+ env: query.env ? { ...process.env, ...query.env } : process.env,
624
687
  timeout: 1_000,
625
688
  },
626
689
  (error, stdout) => {
627
- const startedAt = error ? Number.NaN : Date.parse(stdout.trim());
628
- resolve(Number.isFinite(startedAt) ? startedAt : undefined);
690
+ resolve(error ? undefined : query.parseOutput(stdout));
629
691
  },
630
692
  );
631
693
  } catch {
@@ -919,13 +981,15 @@ async function withSetupConfigLock<A>(action: () => Promise<A>) {
919
981
  }
920
982
 
921
983
  async function writeSetupConfig(config: MyPiSetupConfig) {
984
+ const canonical = parseSetupConfig(config);
922
985
  const tempPath = `${SETUP_CONFIG_PATH}.${process.pid}.${randomUUID()}.tmp`;
923
986
  try {
924
- await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, {
987
+ await writeFile(tempPath, `${JSON.stringify(canonical, null, 2)}\n`, {
925
988
  encoding: "utf8",
926
989
  mode: 0o600,
927
990
  });
928
991
  await rename(tempPath, SETUP_CONFIG_PATH);
992
+ return canonical;
929
993
  } catch (error) {
930
994
  await unlink(tempPath).catch(() => undefined);
931
995
  throw error;
@@ -939,10 +1003,7 @@ export async function saveSetupConfig(config: MyPiSetupConfig) {
939
1003
  });
940
1004
  }
941
1005
 
942
- export function formatSetupConfig(
943
- config = loadSetupConfig(),
944
- integrationLines: readonly string[] = [],
945
- ) {
1006
+ export function formatSetupConfig(config = loadSetupConfig()) {
946
1007
  const suggestionModel = config.suggestions.model;
947
1008
  const suggestions =
948
1009
  !config.suggestions.enabled || !suggestionModel
@@ -957,11 +1018,10 @@ export function formatSetupConfig(
957
1018
  `Workflows: ${config.workflows.concurrency} concurrent agents · ${config.workflows.maxAgentCalls} total calls`,
958
1019
  `UI: large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`,
959
1020
  `Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact status summary (Ctrl+O expands full output)"}`,
960
- `Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "folded preview (Ctrl+O expands all)"}`,
961
- `Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "folded preview (Ctrl+O expands all)"}`,
962
- `Post-edit command: ${config.postEdit.command ? config.postEdit.command : "off"}`,
1021
+ `Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
1022
+ `Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
1023
+ `Post-edit command: ${config.postEdit.command ? "configured" : "off"}`,
963
1024
  `Agent role models (Subagents + Workflows): ${SUBAGENT_ROLE_NAMES.map((role) => `${role} ${config.subagents.roleModels[role] ? `${config.subagents.roleModels[role].provider}/${config.subagents.roleModels[role].model}` : "inherit"}`).join(" · ")}`,
964
- ...integrationLines,
965
1025
  ].join("\n");
966
1026
  }
967
1027
 
@@ -979,8 +1039,7 @@ export async function updateSetupConfig(
979
1039
  return withSetupConfigLock(async () => {
980
1040
  const raw = readDocumentForWrite();
981
1041
  const current = parseSetupConfig(raw);
982
- const config = mutate(current);
983
- await writeSetupConfig(config);
1042
+ const config = await writeSetupConfig(mutate(current));
984
1043
  return { config, replaced: replacedFields(raw, current) };
985
1044
  });
986
1045
  }
@@ -2,6 +2,6 @@
2
2
  export const OPENPI_SETUP_EPISODE_CHANNEL = "openpi:setup-episode";
3
3
 
4
4
  export interface OpenPiSetupEpisodeState {
5
- /** True for both armed and actively running setup episodes. */
5
+ /** True only while the owned setup writer is active for a delivered request. */
6
6
  readonly active: boolean;
7
7
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * One braille spinner for every running-state indicator in the package:
3
+ * transcripts, takeover and dashboard headers, and the below-editor strips all
4
+ * advance on the same cadence so concurrent views animate in step.
5
+ */
6
+
7
+ export const SPINNER_FRAMES = [
8
+ "⠋",
9
+ "⠙",
10
+ "⠹",
11
+ "⠸",
12
+ "⠼",
13
+ "⠴",
14
+ "⠦",
15
+ "⠧",
16
+ "⠇",
17
+ "⠏",
18
+ ] as const;
19
+
20
+ /** Frame cadence, shared with the dashboard and takeover headers. */
21
+ export const SPINNER_INTERVAL_MS = 120;
22
+
23
+ export function spinnerFrame(now: number) {
24
+ const frame = Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length;
25
+ return SPINNER_FRAMES[
26
+ (frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length
27
+ ];
28
+ }
@@ -1,35 +1,122 @@
1
- // OSC may end in BEL or ST. DCS/SOS/PM/APC end only in ST; treating BEL as
2
- // their terminator would expose the rest of a still-hidden payload. Both
3
- // patterns consume an unterminated string through the bounded input's end.
4
- // eslint-disable-next-line no-control-regex
5
- const OSC_PATTERN =
6
- /(?:\u001b\]|\u009d)(?:[^\u0007\u001b\u009c]|\u001b(?!\\))*(?:\u0007|\u001b\\|\u009c|$)/g;
7
- // eslint-disable-next-line no-control-regex
8
- const ST_STRING_PATTERN =
9
- /(?:\u001b[PX^_]|[\u0090\u0098\u009e\u009f])(?:[^\u001b\u009c]|\u001b(?!\\))*(?:\u001b\\|\u009c|$)/g;
10
- // eslint-disable-next-line no-control-regex
11
- const CSI_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
12
- // Remaining two-byte/charset escape forms (for example ESC ( 0).
13
- // eslint-disable-next-line no-control-regex
14
- const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
15
1
  // Invisible Unicode format controls can reorder or disguise untrusted text.
16
2
  // Keep only ZWNJ/ZWJ (U+200C/U+200D), which carry legitimate shaping and emoji
17
3
  // semantics; variation selectors are marks rather than Cf and remain intact.
18
- const UNSAFE_FORMAT_PATTERN = /(?![\u200c\u200d])\p{Cf}/gu;
4
+ const UNSAFE_FORMAT_CHARACTER = /\p{Cf}/u;
19
5
  // eslint-disable-next-line no-control-regex
20
6
  const TERMINAL_CONTROL_PATTERN =
21
7
  /[\u0000-\u001f\u007f-\u009f]|(?:(?![\u200c\u200d])\p{Cf})/u;
22
8
 
9
+ type SanitizerState =
10
+ | "text"
11
+ | "escape"
12
+ | "escape_intermediate"
13
+ | "csi"
14
+ | "osc"
15
+ | "st_string";
16
+
17
+ /** Stateful terminal-text projection for streams whose retained head can move. */
18
+ export class TerminalTextSanitizer {
19
+ private state: SanitizerState = "text";
20
+ private stringEscape = false;
21
+
22
+ push(text: string) {
23
+ let safe = "";
24
+ for (const character of text) {
25
+ const code = character.codePointAt(0)!;
26
+
27
+ if (this.state === "osc" || this.state === "st_string") {
28
+ if (character === "\u009c") {
29
+ this.state = "text";
30
+ this.stringEscape = false;
31
+ } else if (this.state === "osc" && character === "\u0007") {
32
+ this.state = "text";
33
+ this.stringEscape = false;
34
+ } else if (this.stringEscape && character === "\\") {
35
+ this.state = "text";
36
+ this.stringEscape = false;
37
+ } else {
38
+ this.stringEscape = character === "\u001b";
39
+ }
40
+ continue;
41
+ }
42
+
43
+ if (this.state === "csi") {
44
+ if (code >= 0x40 && code <= 0x7e) this.state = "text";
45
+ else if (code < 0x20 || code > 0x3f) {
46
+ this.state = "text";
47
+ safe += this.safeCharacter(character, code);
48
+ }
49
+ continue;
50
+ }
51
+
52
+ if (this.state === "escape" || this.state === "escape_intermediate") {
53
+ if (this.state === "escape") {
54
+ if (character === "[") {
55
+ this.state = "csi";
56
+ continue;
57
+ }
58
+ if (character === "]") {
59
+ this.state = "osc";
60
+ continue;
61
+ }
62
+ if (
63
+ character === "P" ||
64
+ character === "X" ||
65
+ character === "^" ||
66
+ character === "_"
67
+ ) {
68
+ this.state = "st_string";
69
+ continue;
70
+ }
71
+ }
72
+ if (code >= 0x20 && code <= 0x2f) {
73
+ this.state = "escape_intermediate";
74
+ } else if (code >= 0x30 && code <= 0x7e) {
75
+ this.state = "text";
76
+ } else {
77
+ this.state = "text";
78
+ safe += this.safeCharacter(character, code);
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (character === "\u001b") {
84
+ this.state = "escape";
85
+ } else if (character === "\u009b") {
86
+ this.state = "csi";
87
+ } else if (character === "\u009d") {
88
+ this.state = "osc";
89
+ } else if (
90
+ character === "\u0090" ||
91
+ character === "\u0098" ||
92
+ character === "\u009e" ||
93
+ character === "\u009f"
94
+ ) {
95
+ this.state = "st_string";
96
+ } else {
97
+ safe += this.safeCharacter(character, code);
98
+ }
99
+ }
100
+ return safe;
101
+ }
102
+
103
+ private safeCharacter(character: string, code: number) {
104
+ if (character === "\t") return " ";
105
+ if (
106
+ character === "\n" ||
107
+ character === "\u200c" ||
108
+ character === "\u200d"
109
+ ) {
110
+ return character;
111
+ }
112
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return "";
113
+ return UNSAFE_FORMAT_CHARACTER.test(character) ? "" : character;
114
+ }
115
+ }
116
+
23
117
  /** Strip terminal control sequences and direction-spoofing format controls. */
24
118
  export function sanitizeTerminalText(text: string) {
25
- return text
26
- .replace(OSC_PATTERN, "")
27
- .replace(ST_STRING_PATTERN, "")
28
- .replace(CSI_PATTERN, "")
29
- .replace(ESCAPE_PATTERN, "")
30
- .replace(UNSAFE_FORMAT_PATTERN, "")
31
- .replaceAll("\t", " ")
32
- .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
119
+ return new TerminalTextSanitizer().push(text);
33
120
  }
34
121
 
35
122
  /** Reject raw terminal or direction-spoofing controls in display patterns. */