@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
@@ -3,8 +3,12 @@ import {
3
3
  keyHint,
4
4
  type Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { Markdown, Text } from "@earendil-works/pi-tui";
7
- import { sanitizeText } from "./transcript.ts";
6
+ import {
7
+ type Component,
8
+ Markdown,
9
+ truncateToWidth,
10
+ } from "@earendil-works/pi-tui";
11
+ import { sanitizeText } from "../../../shared/agent-transcript.ts";
8
12
 
9
13
  const MAX_STATUS_ROWS = 4;
10
14
 
@@ -12,40 +16,116 @@ export interface WaitResultItem {
12
16
  readonly id: string;
13
17
  readonly title?: string;
14
18
  readonly status?: string;
19
+ readonly outcome?: "completed" | "failed" | "interrupted";
20
+ readonly worktreeBranch?: string;
21
+ readonly elapsed?: string;
22
+ readonly artifactSaveFailed?: boolean;
23
+ readonly fullResultSaved?: boolean;
15
24
  }
16
25
 
17
26
  export interface WaitResultDetails {
18
27
  readonly results?: readonly WaitResultItem[];
19
28
  }
20
29
 
30
+ function singleLine(value: string) {
31
+ return sanitizeText(value).replace(/\s+/gu, " ").trim();
32
+ }
33
+
34
+ function fixedRows(rows: readonly string[]): Component {
35
+ return {
36
+ render(width) {
37
+ return rows.map((row) => truncateToWidth(row, Math.max(1, width), "…"));
38
+ },
39
+ invalidate() {},
40
+ };
41
+ }
42
+
43
+ function selectStatusRows(results: readonly WaitResultItem[]) {
44
+ const visible = results.slice(0, MAX_STATUS_ROWS);
45
+ if (visible.some((result) => result.status === "error")) {
46
+ return visible;
47
+ }
48
+
49
+ const hiddenFailure = results
50
+ .slice(MAX_STATUS_ROWS)
51
+ .find((result) => result.status === "error");
52
+ return hiddenFailure ? [...visible.slice(0, -1), hiddenFailure] : visible;
53
+ }
54
+
21
55
  export function buildWaitResultPreview(
22
56
  content: string,
23
57
  details: WaitResultDetails | undefined,
24
58
  theme: Theme,
25
59
  ) {
26
60
  const results = details?.results ?? [];
27
- const failed = results.filter((result) => result.status === "error").length;
61
+ const uncertain = results.filter(
62
+ (result) => result.outcome === "interrupted",
63
+ ).length;
64
+ const failed = results.filter(
65
+ (result) => result.status === "error" && result.outcome !== "interrupted",
66
+ ).length;
67
+ const artifactFailures = results.filter(
68
+ (result) => result.artifactSaveFailed,
69
+ ).length;
70
+ const exceptions = [
71
+ failed > 0 ? theme.fg("error", `${failed} failed`) : "",
72
+ uncertain > 0 ? theme.fg("warning", `${uncertain} uncertain`) : "",
73
+ ].filter(Boolean);
28
74
  const header =
29
- theme.fg(failed > 0 ? "warning" : "success", failed > 0 ? "!" : "✓") +
30
- ` ${theme.fg("accent", theme.bold(`${results.length} subagent${results.length === 1 ? "" : "s"} settled`))}` +
31
- (failed > 0 ? theme.fg("error", ` · ${failed} failed`) : "");
75
+ theme.fg(
76
+ failed > 0 || uncertain > 0 ? "warning" : "success",
77
+ failed > 0 || uncertain > 0 ? "!" : "",
78
+ ) +
79
+ ` ${exceptions.length > 0 ? `${exceptions.join(" · ")} · ` : ""}` +
80
+ theme.fg(
81
+ "accent",
82
+ theme.bold(
83
+ `${results.length} subagent${results.length === 1 ? "" : "s"} settled`,
84
+ ),
85
+ ) +
86
+ (artifactFailures > 0
87
+ ? theme.fg(
88
+ "warning",
89
+ ` · ${artifactFailures} artifact${artifactFailures === 1 ? "" : "s"} not saved`,
90
+ )
91
+ : "");
32
92
  const lines = [header];
93
+ const statusRows = selectStatusRows(results);
33
94
 
34
- for (const result of results.slice(0, MAX_STATUS_ROWS)) {
35
- const isFailure = result.status === "error";
95
+ for (const result of statusRows) {
96
+ const isUncertain = result.outcome === "interrupted";
97
+ const isFailure = result.status === "error" && !isUncertain;
36
98
  const icon = theme.fg(
37
- isFailure ? "error" : "success",
38
- isFailure ? "x" : "✓",
99
+ isFailure ? "error" : isUncertain ? "warning" : "success",
100
+ isFailure ? "x" : isUncertain ? "?" : "✓",
39
101
  );
102
+ const id = singleLine(result.id);
103
+ const title = result.title ? singleLine(result.title) : "";
104
+ const status = isUncertain
105
+ ? "uncertain"
106
+ : singleLine(result.status ?? "settled");
107
+ const elapsed = result.elapsed ? singleLine(result.elapsed) : "";
108
+ const notices = [
109
+ result.artifactSaveFailed ? "artifact not saved" : "",
110
+ result.worktreeBranch
111
+ ? `worktree handoff · ${singleLine(result.worktreeBranch)}`
112
+ : "",
113
+ result.fullResultSaved ? "full result saved" : "",
114
+ ].filter(Boolean);
115
+ const attention = notices.length
116
+ ? theme.fg("warning", `${notices.join(" · ")} · `)
117
+ : "";
40
118
  lines.push(
41
- ` ${icon} ${theme.fg("accent", result.id)}${result.title ? theme.fg("muted", ` · ${result.title}`) : ""}${theme.fg("dim", ` · ${result.status ?? "settled"}`)}`,
119
+ ` ${icon} ${attention}${theme.fg("accent", id)}${title ? theme.fg("muted", ` · ${title}`) : ""}${theme.fg("dim", ` · ${status}${elapsed ? ` · ${elapsed}` : ""}`)}`,
42
120
  );
43
121
  }
44
- if (results.length > MAX_STATUS_ROWS) {
45
- lines.push(theme.fg("dim", ` … ${results.length - MAX_STATUS_ROWS} more`));
122
+ if (results.length > statusRows.length) {
123
+ lines.push(
124
+ theme.fg("dim", ` … ${results.length - statusRows.length} more`),
125
+ );
46
126
  }
47
127
 
48
- if (content.trim()) {
128
+ if (sanitizeText(content).trim()) {
49
129
  lines.push(
50
130
  theme.fg(
51
131
  "dim",
@@ -56,6 +136,14 @@ export function buildWaitResultPreview(
56
136
  return lines.join("\n");
57
137
  }
58
138
 
139
+ export function renderWaitResultPreview(
140
+ content: string,
141
+ details: WaitResultDetails | undefined,
142
+ theme: Theme,
143
+ ) {
144
+ return fixedRows(buildWaitResultPreview(content, details, theme).split("\n"));
145
+ }
146
+
59
147
  export function renderWaitResult(
60
148
  content: string,
61
149
  details: WaitResultDetails | undefined,
@@ -63,7 +151,7 @@ export function renderWaitResult(
63
151
  theme: Theme,
64
152
  ) {
65
153
  if (!expanded) {
66
- return new Text(buildWaitResultPreview(content, details, theme), 0, 0);
154
+ return renderWaitResultPreview(content, details, theme);
67
155
  }
68
156
 
69
157
  const markdown = new Markdown(
@@ -13,6 +13,10 @@ import {
13
13
  } from "../../shared/below-editor-navigation.ts";
14
14
 
15
15
  const FAKE_CURSOR_PATTERN = /\u001b\[7m \u001b\[(?:0|27)m/;
16
+ // Conservative idle-row band for terminal-owned CJK IME preedit. Pi's editor
17
+ // contract exposes only committed input, never a composition event, so the
18
+ // reservation cannot shrink to the frames that actually need it. A 1-cell
19
+ // hardware-cursor pad lets long preedit cover or truncate the ghost.
16
20
  const IME_PREEDIT_MIN_COLUMNS = 12;
17
21
  const IME_PREEDIT_MAX_COLUMNS = 32;
18
22
  const GHOST_MIN_COLUMNS = 8;
@@ -96,10 +100,12 @@ function ghostGeometry(lines: readonly string[], width: number) {
96
100
  .replaceAll(CURSOR_MARKER, "");
97
101
  const prefix = `${beforeCursor}${cursor[0]}`;
98
102
  const remaining = Math.max(0, width - visibleWidth(prefix));
99
- // One terminal cell must remain after the hidden hardware cursor. At
100
- // narrower widths, suppress the ghost rather than placing the cursor at the
101
- // terminal's out-of-range column width.
102
- if (remaining <= GHOST_MIN_COLUMNS) return undefined;
103
+ // Keep enough cells for a readable ghost and a conservative IME preedit
104
+ // band. At narrower widths, suppress the ghost rather than placing the
105
+ // hidden hardware cursor at the terminal's out-of-range column width.
106
+ if (remaining < GHOST_MIN_COLUMNS + IME_PREEDIT_MIN_COLUMNS) {
107
+ return undefined;
108
+ }
103
109
 
104
110
  const desiredPreedit = Math.min(
105
111
  IME_PREEDIT_MAX_COLUMNS,
@@ -34,7 +34,6 @@ import {
34
34
  type TaskToolDetails,
35
35
  } from "./ui.ts";
36
36
 
37
- const TOOL_NAMES = ["tasks_add", "tasks_update", "tasks_list"] as const;
38
37
  const TASK_WIDGET_KEY = "session-tasks-panel";
39
38
  const CONFLICT_NAMES = new Set(["todo", "TodoWrite", "update_plan"]);
40
39
  const TOOL_PURPOSE =
@@ -542,8 +541,6 @@ export default function sessionTasks(pi: ExtensionAPI) {
542
541
  });
543
542
  }
544
543
 
545
- export { TOOL_NAMES as TASK_TOOL_NAMES };
546
-
547
544
  function mutationWillCloseBatch(
548
545
  snapshot: TaskSnapshot,
549
546
  id: number,
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { relative } from "node:path";
2
+ import { posix, win32 } from "node:path";
3
3
  import type { Theme } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
5
  getCapabilities,
@@ -117,10 +117,21 @@ export function formatTokens(tokens: number) {
117
117
  return `${(tokens / 1_000_000).toFixed(1)}m`;
118
118
  }
119
119
 
120
- export function formatDirectory(cwd: string) {
121
- const home = homedir();
122
- if (cwd === home) return "~";
123
- const display = cwd.startsWith(`${home}/`) ? `~/${relative(home, cwd)}` : cwd;
120
+ export function formatDirectory(
121
+ cwd: string,
122
+ home = homedir(),
123
+ pathModule = process.platform === "win32" ? win32 : posix,
124
+ ) {
125
+ const relativePath = pathModule.relative(home, cwd);
126
+ const outsideHome =
127
+ relativePath === ".." ||
128
+ relativePath.startsWith(`..${pathModule.sep}`) ||
129
+ pathModule.isAbsolute(relativePath);
130
+ const display = outsideHome
131
+ ? cwd
132
+ : relativePath
133
+ ? `~/${relativePath.replaceAll(pathModule.sep, "/")}`
134
+ : "~";
124
135
  return sanitizeTerminalLabel(display);
125
136
  }
126
137
 
@@ -527,43 +538,3 @@ export function renderFooter(options: FooterRenderOptions) {
527
538
 
528
539
  return lines;
529
540
  }
530
-
531
- /** Legacy helper retained for tests that assert grouped content. */
532
- export function buildFooterContent(
533
- modelInfo: ModelInfoState,
534
- gitInfo: GitInfoState,
535
- selectedItems: readonly FooterItem[],
536
- formatPullRequest: (number: number, url: string) => string = (number) =>
537
- `PR #${number}`,
538
- ) {
539
- const items = new Set(selectedItems);
540
- const catalog = buildSegmentCatalog(
541
- "",
542
- modelInfo,
543
- gitInfo,
544
- formatPullRequest,
545
- );
546
- return {
547
- showCwd: items.has("cwd"),
548
- model: [
549
- items.has("model") ? catalog.model.text : "",
550
- items.has("thinking") ? catalog.thinking.text : "",
551
- ]
552
- .filter(Boolean)
553
- .join(" · "),
554
- usage: [
555
- items.has("context") ? catalog.context.text : "",
556
- items.has("cache") ? catalog.cache.text : "",
557
- items.has("cost") ? catalog.cost.text : "",
558
- items.has("throughput") ? catalog.throughput.text : "",
559
- ]
560
- .filter(Boolean)
561
- .join(" · "),
562
- git: [
563
- items.has("git") ? catalog.git.text : "",
564
- items.has("pr") ? catalog.pr.text : "",
565
- ]
566
- .filter(Boolean)
567
- .join(" · "),
568
- };
569
- }
@@ -21,13 +21,9 @@ import {
21
21
  REFRESH_CHANNEL,
22
22
  isGitInfoState,
23
23
  isModelInfoState,
24
- type GitInfoState,
25
- type ModelInfoState,
26
24
  } from "../shared/dashboard-state.ts";
27
25
  import { formatDirectory, renderFooter } from "./footer.ts";
28
26
 
29
- export { buildFooterContent, formatDirectory, renderFooter } from "./footer.ts";
30
-
31
27
  type Rgb = [number, number, number];
32
28
 
33
29
  const RESET = "\x1b[0m";
@@ -40,8 +40,35 @@ type Segment =
40
40
  | { kind: "prose"; lines: string[] }
41
41
  | { kind: "code"; open: string; content: string[]; close: string };
42
42
 
43
- const FENCE_OPEN = /^ {0,3}`{3,}/;
44
- const FENCE_CLOSE = /^ {0,3}`{3,}[ \t]*$/;
43
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
44
+
45
+ /**
46
+ * Parse an opening code fence (CommonMark §4.5): which character it uses and
47
+ * how long it is. A backtick fence's info string may not contain backticks.
48
+ */
49
+ function openFence(line: string) {
50
+ const match = FENCE_OPEN.exec(line);
51
+ if (!match) return undefined;
52
+ const fence = match[1];
53
+ if (fence[0] === "`" && line.slice(match[0].length).includes("`")) {
54
+ return undefined;
55
+ }
56
+ return { char: fence[0], length: fence.length };
57
+ }
58
+
59
+ /**
60
+ * A closing fence must use the same character as the opening fence and be at
61
+ * least as long: a ``` line does not close a ```` block, and backticks never
62
+ * close a tilde block.
63
+ */
64
+ function isCloseFence(line: string, open: { char: string; length: number }) {
65
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/.exec(line);
66
+ return (
67
+ match !== null &&
68
+ match[1][0] === open.char &&
69
+ match[1].length >= open.length
70
+ );
71
+ }
45
72
 
46
73
  function countLines(markdown: string) {
47
74
  const parts = markdown.split("\n");
@@ -60,7 +87,8 @@ function parseSegments(lines: string[]): Segment[] {
60
87
  let prose: string[] = [];
61
88
  let i = 0;
62
89
  while (i < lines.length) {
63
- if (!FENCE_OPEN.test(lines[i])) {
90
+ const fence = openFence(lines[i]);
91
+ if (!fence) {
64
92
  prose.push(lines[i]);
65
93
  i += 1;
66
94
  continue;
@@ -74,13 +102,21 @@ function parseSegments(lines: string[]): Segment[] {
74
102
  let close: string | undefined;
75
103
  let j = i + 1;
76
104
  while (j < lines.length && close === undefined) {
77
- if (FENCE_CLOSE.test(lines[j])) close = lines[j];
105
+ if (isCloseFence(lines[j], fence)) close = lines[j];
78
106
  else content.push(lines[j]);
79
107
  j += 1;
80
108
  }
81
109
  if (close === undefined) {
82
- // Unterminated fence: fold the whole message conservatively as text.
83
- return [{ kind: "prose", lines }];
110
+ // Unterminated fence: the block runs to the end of the message. Keep it
111
+ // as a code block with a synthesized closing fence so a folded preview
112
+ // never leaks an unclosed fence into the TUI.
113
+ segments.push({
114
+ kind: "code",
115
+ open,
116
+ content,
117
+ close: fence.char.repeat(fence.length),
118
+ });
119
+ return segments;
84
120
  }
85
121
  segments.push({ kind: "code", open, content, close });
86
122
  i = j;
@@ -0,0 +1,257 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type {
5
+ ExtensionAPI,
6
+ ExtensionCommandContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ missingPiCodingAgentDiagnostic,
10
+ PI_CODING_AGENT_ENTRY_ENV,
11
+ resolvePiCodingAgentEntry,
12
+ } from "../../web/host/pi-coding-agent-entry.ts";
13
+
14
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
15
+
16
+ export interface WebProcess {
17
+ readonly exitCode: number | null;
18
+ readonly signalCode: NodeJS.Signals | null;
19
+ once(event: "error", listener: (error: Error) => void): this;
20
+ once(
21
+ event: "close",
22
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
23
+ ): this;
24
+ kill(signal?: NodeJS.Signals): boolean;
25
+ }
26
+
27
+ interface SpawnWebOptions {
28
+ cwd: string;
29
+ env: NodeJS.ProcessEnv;
30
+ shell: false;
31
+ stdio: "inherit";
32
+ }
33
+
34
+ function webProcessEnvironment(
35
+ cwd: string,
36
+ piCodingAgentEntry: string | undefined,
37
+ ) {
38
+ const environment: NodeJS.ProcessEnv = { ...process.env, PWD: cwd };
39
+ delete environment.OLDPWD;
40
+ delete environment.INIT_CWD;
41
+ delete environment.PI_SESSION_ID;
42
+ delete environment.PI_SESSION_FILE;
43
+ if (piCodingAgentEntry) {
44
+ environment[PI_CODING_AGENT_ENTRY_ENV] = piCodingAgentEntry;
45
+ } else {
46
+ delete environment[PI_CODING_AGENT_ENTRY_ENV];
47
+ }
48
+ return environment;
49
+ }
50
+
51
+ export interface WebCommandDependencies {
52
+ entrypoint: string;
53
+ spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess;
54
+ clearTerminal(): void;
55
+ holdParentSigint(): () => void;
56
+ resolvePiCodingAgentEntry(): string | undefined;
57
+ shutdownTimeoutMs: number;
58
+ }
59
+
60
+ type WebExit =
61
+ | { kind: "close"; code: number | null; signal: NodeJS.Signals | null }
62
+ | { kind: "error"; error: Error };
63
+
64
+ interface ActiveWebProcess {
65
+ child: WebProcess;
66
+ closed: Promise<void>;
67
+ }
68
+
69
+ const defaultDependencies: WebCommandDependencies = {
70
+ entrypoint: fileURLToPath(new URL("../../bin/openpi.js", import.meta.url)),
71
+ spawn(command, args, options) {
72
+ return nodeSpawn(command, args, options);
73
+ },
74
+ clearTerminal() {
75
+ process.stdout.write("\u001b[2J\u001b[H");
76
+ },
77
+ holdParentSigint() {
78
+ const keepPiAlive = () => {};
79
+ process.on("SIGINT", keepPiAlive);
80
+ return () => process.removeListener("SIGINT", keepPiAlive);
81
+ },
82
+ resolvePiCodingAgentEntry: () =>
83
+ resolvePiCodingAgentEntry({ source: "host" }),
84
+ shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS,
85
+ };
86
+
87
+ function delay(milliseconds: number) {
88
+ return new Promise<void>((resolve) => {
89
+ const timer = setTimeout(resolve, milliseconds);
90
+ timer.unref();
91
+ });
92
+ }
93
+
94
+ async function stopWebProcess(active: ActiveWebProcess, timeoutMs: number) {
95
+ if (active.child.exitCode !== null || active.child.signalCode !== null)
96
+ return;
97
+ active.child.kill("SIGTERM");
98
+ const timedOut = await Promise.race([
99
+ active.closed.then(() => false),
100
+ delay(timeoutMs).then(() => true),
101
+ ]);
102
+ if (!timedOut) return;
103
+ if (active.child.exitCode === null && active.child.signalCode === null) {
104
+ active.child.kill("SIGKILL");
105
+ await Promise.race([active.closed, delay(timeoutMs)]);
106
+ }
107
+ }
108
+
109
+ function runWebInForeground(
110
+ ctx: ExtensionCommandContext,
111
+ dependencies: WebCommandDependencies,
112
+ setActive: (active: ActiveWebProcess | undefined) => void,
113
+ isShuttingDown: () => boolean,
114
+ piCodingAgentEntry: string,
115
+ ) {
116
+ return ctx.ui.custom<WebExit>((tui, _theme, _keybindings, done) => {
117
+ let finished = false;
118
+ let tuiStopped = false;
119
+ let resolveClosed = () => {};
120
+ const closed = new Promise<void>((resolve) => {
121
+ resolveClosed = resolve;
122
+ });
123
+ const releaseParentSigint = dependencies.holdParentSigint();
124
+
125
+ const finish = (result: WebExit) => {
126
+ if (finished) return;
127
+ finished = true;
128
+ releaseParentSigint();
129
+ setActive(undefined);
130
+ resolveClosed();
131
+ if (tuiStopped && !isShuttingDown()) {
132
+ tui.start();
133
+ tui.requestRender(true);
134
+ }
135
+ done(result);
136
+ };
137
+
138
+ try {
139
+ tui.stop();
140
+ tuiStopped = true;
141
+ dependencies.clearTerminal();
142
+ const childCwd = dirname(dependencies.entrypoint);
143
+ const child = dependencies.spawn(
144
+ process.execPath,
145
+ [dependencies.entrypoint, "web", "--no-workspace"],
146
+ {
147
+ cwd: childCwd,
148
+ env: webProcessEnvironment(childCwd, piCodingAgentEntry),
149
+ shell: false,
150
+ stdio: "inherit",
151
+ },
152
+ );
153
+ setActive({ child, closed });
154
+ child.once("error", (error) => finish({ kind: "error", error }));
155
+ child.once("close", (code, signal) =>
156
+ finish({ kind: "close", code, signal }),
157
+ );
158
+ } catch (error) {
159
+ finish({
160
+ kind: "error",
161
+ error: error instanceof Error ? error : new Error(String(error)),
162
+ });
163
+ }
164
+
165
+ return { render: () => [], invalidate: () => {} };
166
+ });
167
+ }
168
+
169
+ export default function web(
170
+ pi: ExtensionAPI,
171
+ dependencies: WebCommandDependencies = defaultDependencies,
172
+ ) {
173
+ let active: ActiveWebProcess | undefined;
174
+ let running = false;
175
+ let shuttingDown = false;
176
+
177
+ pi.on("session_start", () => {
178
+ shuttingDown = false;
179
+ });
180
+
181
+ pi.on("session_shutdown", async () => {
182
+ shuttingDown = true;
183
+ if (active) {
184
+ await stopWebProcess(active, dependencies.shutdownTimeoutMs);
185
+ }
186
+ });
187
+
188
+ pi.registerCommand("web", {
189
+ description:
190
+ "Open the separate OpenPI Web Workbench in this terminal until Ctrl+C",
191
+ handler: async (args, ctx) => {
192
+ if (args.trim()) {
193
+ ctx.ui.notify("Usage: /web", "warning");
194
+ return;
195
+ }
196
+ if (ctx.mode !== "tui") {
197
+ ctx.ui.notify("/web requires the interactive TUI.", "warning");
198
+ return;
199
+ }
200
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
201
+ ctx.ui.notify(
202
+ "Wait until the current Pi Session is idle before starting /web.",
203
+ "warning",
204
+ );
205
+ return;
206
+ }
207
+ if (running) {
208
+ ctx.ui.notify("OpenPI Web Workbench is already running.", "warning");
209
+ return;
210
+ }
211
+ const piCodingAgentEntry = dependencies.resolvePiCodingAgentEntry();
212
+ if (!piCodingAgentEntry) {
213
+ ctx.ui.notify(missingPiCodingAgentDiagnostic(), "error");
214
+ return;
215
+ }
216
+
217
+ running = true;
218
+ try {
219
+ const result = await runWebInForeground(
220
+ ctx,
221
+ dependencies,
222
+ (next) => {
223
+ active = next;
224
+ },
225
+ () => shuttingDown,
226
+ piCodingAgentEntry,
227
+ );
228
+ if (shuttingDown) return;
229
+ if (result.kind === "error") {
230
+ ctx.ui.notify(
231
+ `Failed to start OpenPI Web Workbench: ${result.error.message}`,
232
+ "error",
233
+ );
234
+ return;
235
+ }
236
+ if (result.signal !== null) {
237
+ ctx.ui.notify(
238
+ `OpenPI Web Workbench was terminated by ${result.signal}.`,
239
+ "error",
240
+ );
241
+ return;
242
+ }
243
+ if (result.code !== 0) {
244
+ ctx.ui.notify(
245
+ `OpenPI Web Workbench exited with code ${result.code ?? "unknown"}.`,
246
+ "error",
247
+ );
248
+ return;
249
+ }
250
+ ctx.ui.notify("OpenPI Web Workbench stopped.", "info");
251
+ } finally {
252
+ active = undefined;
253
+ running = false;
254
+ }
255
+ },
256
+ });
257
+ }