@oh-my-pi/pi-coding-agent 16.1.10 → 16.1.12

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 (63) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli.js +2480 -2481
  3. package/dist/types/export/html/index.d.ts +31 -2
  4. package/dist/types/export/html/web-palette.d.ts +117 -0
  5. package/dist/types/export/share.d.ts +10 -5
  6. package/dist/types/hindsight/content.d.ts +7 -0
  7. package/dist/types/hindsight/transcript.d.ts +1 -1
  8. package/dist/types/modes/components/hook-editor.d.ts +2 -1
  9. package/dist/types/modes/interactive-mode.d.ts +5 -0
  10. package/dist/types/modes/types.d.ts +17 -0
  11. package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
  12. package/dist/types/secrets/index.d.ts +1 -1
  13. package/dist/types/secrets/obfuscator.d.ts +43 -9
  14. package/dist/types/session/agent-session.d.ts +4 -0
  15. package/dist/types/session/session-context.d.ts +2 -0
  16. package/dist/types/session/session-entries.d.ts +6 -0
  17. package/dist/types/session/session-manager.d.ts +2 -1
  18. package/dist/types/tools/acp-bridge.d.ts +29 -0
  19. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +7 -0
  20. package/dist/types/tools/browser/tab-worker.d.ts +20 -0
  21. package/dist/types/utils/jj.d.ts +25 -0
  22. package/dist/types/utils/title-generator.d.ts +0 -2
  23. package/package.json +12 -12
  24. package/scripts/generate-share-viewer.ts +4 -2
  25. package/src/autoresearch/git.ts +12 -0
  26. package/src/discovery/opencode.ts +47 -4
  27. package/src/edit/hashline/filesystem.ts +8 -0
  28. package/src/edit/modes/patch.ts +18 -2
  29. package/src/edit/modes/replace.ts +13 -10
  30. package/src/export/html/index.ts +50 -8
  31. package/src/export/html/web-palette.ts +142 -0
  32. package/src/export/share.ts +198 -8
  33. package/src/hindsight/backend.ts +4 -4
  34. package/src/hindsight/content.ts +17 -1
  35. package/src/hindsight/transcript.ts +2 -2
  36. package/src/internal-urls/docs-index.generated.txt +1 -1
  37. package/src/main.ts +8 -0
  38. package/src/modes/components/agent-dashboard.ts +8 -8
  39. package/src/modes/components/hook-editor.ts +13 -10
  40. package/src/modes/components/session-selector.ts +3 -0
  41. package/src/modes/controllers/event-controller.ts +9 -5
  42. package/src/modes/controllers/extension-ui-controller.ts +6 -2
  43. package/src/modes/interactive-mode.ts +69 -29
  44. package/src/modes/theme/dark.json +1 -1
  45. package/src/modes/types.ts +18 -0
  46. package/src/modes/utils/keybinding-matchers.ts +36 -1
  47. package/src/modes/utils/ui-helpers.ts +1 -0
  48. package/src/prompts/tools/browser.md +3 -2
  49. package/src/sdk.ts +14 -2
  50. package/src/secrets/index.ts +1 -1
  51. package/src/secrets/obfuscator.ts +220 -71
  52. package/src/session/agent-session.ts +57 -43
  53. package/src/session/session-context.ts +5 -0
  54. package/src/session/session-entries.ts +6 -0
  55. package/src/session/session-manager.ts +3 -1
  56. package/src/task/worktree.ts +12 -4
  57. package/src/thinking.ts +1 -1
  58. package/src/tools/acp-bridge.ts +66 -0
  59. package/src/tools/browser/cmux/cmux-tab.ts +37 -0
  60. package/src/tools/browser/tab-worker.ts +160 -37
  61. package/src/tools/write.ts +2 -25
  62. package/src/utils/jj.ts +47 -0
  63. package/src/utils/title-generator.ts +31 -99
@@ -91,15 +91,55 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
91
91
  /** OpenCode MCP server config (from opencode.json "mcp" key) */
92
92
  interface OpenCodeMCPConfig {
93
93
  type?: "local" | "remote";
94
- command?: string;
94
+ command?: string | string[];
95
95
  args?: string[];
96
96
  env?: Record<string, string>;
97
+ environment?: Record<string, string>;
97
98
  url?: string;
98
99
  headers?: Record<string, string>;
99
100
  enabled?: boolean;
100
101
  timeout?: number;
101
102
  }
102
103
 
104
+ function stringArray(value: unknown): string[] | undefined {
105
+ if (!Array.isArray(value)) return undefined;
106
+ for (const item of value) {
107
+ if (typeof item !== "string") return undefined;
108
+ }
109
+ return value;
110
+ }
111
+
112
+ function stringRecord(value: unknown): Record<string, string> | undefined {
113
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
114
+
115
+ const record: Record<string, string> = {};
116
+ for (const [key, item] of Object.entries(value)) {
117
+ if (typeof item !== "string") return undefined;
118
+ record[key] = item;
119
+ }
120
+ return record;
121
+ }
122
+
123
+ function normalizeCommand(
124
+ commandValue: string | string[] | undefined,
125
+ argsValue: unknown,
126
+ ): { command: string | undefined; args: string[] | undefined } {
127
+ const configuredArgs = stringArray(argsValue);
128
+ if (Array.isArray(commandValue)) {
129
+ const [command, ...commandArgs] = commandValue;
130
+ const args = configuredArgs ? [...commandArgs, ...configuredArgs] : commandArgs;
131
+ return {
132
+ command: typeof command === "string" ? command : undefined,
133
+ args: args.length > 0 ? args : undefined,
134
+ };
135
+ }
136
+
137
+ return {
138
+ command: typeof commandValue === "string" ? commandValue : undefined,
139
+ args: configuredArgs && configuredArgs.length > 0 ? configuredArgs : undefined,
140
+ };
141
+ }
142
+
103
143
  async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
104
144
  const items: MCPServer[] = [];
105
145
  const warnings: string[] = [];
@@ -161,11 +201,14 @@ function extractMCPServers(
161
201
  transport = "stdio";
162
202
  }
163
203
 
204
+ const command = normalizeCommand(serverConfig.command, serverConfig.args);
205
+ const env = stringRecord(serverConfig.environment) ?? stringRecord(serverConfig.env);
206
+
164
207
  items.push({
165
208
  name,
166
- command: serverConfig.command,
167
- args: Array.isArray(serverConfig.args) ? (serverConfig.args as string[]) : undefined,
168
- env: serverConfig.env && typeof serverConfig.env === "object" ? serverConfig.env : undefined,
209
+ command: command.command,
210
+ args: command.args,
211
+ env,
169
212
  url: typeof serverConfig.url === "string" ? serverConfig.url : undefined,
170
213
  headers: serverConfig.headers && typeof serverConfig.headers === "object" ? serverConfig.headers : undefined,
171
214
  enabled: serverConfig.enabled,
@@ -20,6 +20,7 @@ import { Filesystem, NotFoundError, type WriteResult } from "@oh-my-pi/hashline"
20
20
  import { isEnoent } from "@oh-my-pi/pi-utils";
21
21
  import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
22
22
  import type { ToolSession } from "../../tools";
23
+ import { routeWriteThroughBridge } from "../../tools/acp-bridge";
23
24
  import { assertEditableFileContent } from "../../tools/auto-generated-guard";
24
25
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
25
26
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
@@ -110,6 +111,13 @@ export class HashlineFilesystem extends Filesystem {
110
111
  await this.preflightWrite(relativePath);
111
112
  const absolutePath = this.resolveAbsolute(relativePath);
112
113
  const finalContent = await serializeEditFileText(absolutePath, relativePath, content);
114
+
115
+ // Route through ACP bridge when available; skips internal artifacts.
116
+ if (await routeWriteThroughBridge(this.session, relativePath, absolutePath, finalContent)) {
117
+ this.#diagnosticsByPath.set(relativePath, undefined);
118
+ return { text: finalContent };
119
+ }
120
+
113
121
  const diagnostics = await this.#writethrough(
114
122
  absolutePath,
115
123
  finalContent,
@@ -17,6 +17,7 @@ import {
17
17
  type WritethroughDeferredHandle,
18
18
  } from "../../lsp";
19
19
  import type { ToolSession } from "../../tools";
20
+ import { routeWriteThroughBridge } from "../../tools/acp-bridge";
20
21
  import { assertEditableFile } from "../../tools/auto-generated-guard";
21
22
  import {
22
23
  invalidateFsScanAfterDelete,
@@ -1663,6 +1664,8 @@ class LspFileSystem implements FileSystem {
1663
1664
  #fileCache: Record<string, Bun.BunFile> = {};
1664
1665
 
1665
1666
  constructor(
1667
+ private readonly session: ToolSession,
1668
+ private readonly requestedPath: string,
1666
1669
  private readonly writethrough: WritethroughCallback,
1667
1670
  private readonly signal?: AbortSignal,
1668
1671
  private readonly batchRequest?: LspBatchRequest,
@@ -1692,8 +1695,14 @@ class LspFileSystem implements FileSystem {
1692
1695
  }
1693
1696
 
1694
1697
  async write(path: string, content: string): Promise<void> {
1695
- const file = this.#getFile(path);
1696
1698
  const finalContent = await serializeEditFileText(path, path, content);
1699
+
1700
+ // Route through ACP bridge when available; skips internal artifacts and local:// paths.
1701
+ if (await routeWriteThroughBridge(this.session, this.requestedPath, path, finalContent)) {
1702
+ return;
1703
+ }
1704
+
1705
+ const file = this.#getFile(path);
1697
1706
  const deferredForPath = this.deferredForPath;
1698
1707
  const result = await this.writethrough(
1699
1708
  path,
@@ -1785,7 +1794,14 @@ export async function executePatchSingle(
1785
1794
  }
1786
1795
 
1787
1796
  const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff };
1788
- const patchFileSystem = new LspFileSystem(writethrough, signal, batchRequest, beginDeferredDiagnosticsForPath);
1797
+ const patchFileSystem = new LspFileSystem(
1798
+ session,
1799
+ path, // original user-provided path for bridge guard (may be local://, vault://, etc.)
1800
+ writethrough,
1801
+ signal,
1802
+ batchRequest,
1803
+ beginDeferredDiagnosticsForPath,
1804
+ );
1789
1805
  const result = await applyPatch(input, {
1790
1806
  cwd: session.cwd,
1791
1807
  fs: patchFileSystem,
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
8
8
  import { type } from "arktype";
9
- import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
9
+ import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
10
10
  import type { ToolSession } from "../../tools";
11
+ import { routeWriteThroughBridge } from "../../tools/acp-bridge";
11
12
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
12
13
  import { outputMeta } from "../../tools/output-meta";
13
14
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
@@ -1098,15 +1099,17 @@ export async function executeReplaceSingle(
1098
1099
  path,
1099
1100
  bom + restoreLineEndings(result.content, originalEnding),
1100
1101
  );
1101
- const diagnostics = await writethrough(
1102
- absolutePath,
1103
- finalContent,
1104
- signal,
1105
- Bun.file(absolutePath),
1106
- batchRequest,
1107
- dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1108
- );
1109
- invalidateFsScanAfterWrite(absolutePath);
1102
+
1103
+ // Route through ACP bridge when available; skips internal artifacts.
1104
+ let diagnostics: FileDiagnosticsResult | undefined;
1105
+ if (await routeWriteThroughBridge(session, path, absolutePath, finalContent)) {
1106
+ // bridge handled the write; diagnostics not available via writethrough
1107
+ } else {
1108
+ diagnostics = await writethrough(absolutePath, finalContent, signal, Bun.file(absolutePath), batchRequest, dst =>
1109
+ dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined,
1110
+ );
1111
+ invalidateFsScanAfterWrite(absolutePath);
1112
+ }
1110
1113
 
1111
1114
  const diffResult = generateDiffString(normalizedContent, result.content, undefined, { path });
1112
1115
  const resultText =
@@ -12,6 +12,7 @@ import templateJs from "./template.js" with { type: "text" };
12
12
  // Pre-built React tool renderers: built by `bun --cwd=packages/collab-web run build:tool-views`,
13
13
  // run automatically by root `prepare` on install and by `prepack` at publish.
14
14
  import toolViewsJs from "./tool-views.generated.js" with { type: "text" };
15
+ import { webExportThemeVars } from "./web-palette";
15
16
 
16
17
  let cachedTemplate: string | undefined;
17
18
 
@@ -36,6 +37,19 @@ export function getTemplate(): string {
36
37
 
37
38
  export interface ExportOptions {
38
39
  outputPath?: string;
40
+ /**
41
+ * Which color palette the export ships with.
42
+ * - `"web"` (default) — the omp brand identity (collab-web pink/purple),
43
+ * so public HTML exports and the `/s/<id>` share viewer match the live
44
+ * `my.omp.sh` client. See `web-palette.ts`.
45
+ * - `"theme"` — derive from `themeName` (or the active TUI theme), preserving
46
+ * the pre-15.12 behavior where an export mirrored the user's terminal.
47
+ */
48
+ palette?: "web" | "theme";
49
+ /**
50
+ * TUI theme to derive colors from when `palette: "theme"`. Ignored for the
51
+ * default `"web"` palette. Resolves to the active TUI theme when omitted.
52
+ */
39
53
  themeName?: string;
40
54
  /** Embed subagent session transcripts found next to the session file (default true). */
41
55
  includeSubSessions?: boolean;
@@ -101,12 +115,38 @@ function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string
101
115
  };
102
116
  }
103
117
 
104
- /** Generate CSS custom properties for theme. Exported for the share-viewer build script. */
105
- export async function generateThemeVars(themeName?: string): Promise<string> {
118
+ /**
119
+ * Generate CSS custom properties for the export `:root`.
120
+ *
121
+ * Two call shapes:
122
+ * • `generateThemeVars("web" | "theme", themeName?)` — explicit palette.
123
+ * `"web"` (the default for public artifacts) returns the fixed omp brand
124
+ * palette from `web-palette.ts` — collab-web pink/purple identity, shared
125
+ * with the live `my.omp.sh` client, so exports and the share viewer render
126
+ * identically to it. `"theme"` derives from the TUI theme via
127
+ * `getResolvedThemeColors(themeName)` plus the three
128
+ * `export.{pageBg,cardBg,infoBg}` surface overrides.
129
+ * • `generateThemeVars(themeName)` — legacy single-arg form: derive from the
130
+ * named TUI theme. Kept so existing callers (and the theme-islight test)
131
+ * keep working; equivalent to `generateThemeVars("theme", themeName)`.
132
+ *
133
+ * Exported for the share-viewer build script.
134
+ */
135
+ export async function generateThemeVars(
136
+ palette: "web" | "theme" | (string & {}) = "web",
137
+ themeName?: string,
138
+ ): Promise<string> {
139
+ // Legacy single-arg form: `generateThemeVars("my-theme")` — the first arg
140
+ // is a theme name, not a palette. Route it to the themed path.
141
+ if (palette !== "web" && palette !== "theme") {
142
+ return generateThemeVars("theme", palette);
143
+ }
144
+ if (palette === "web") return webExportThemeVars();
145
+
106
146
  const colors = await getResolvedThemeColors(themeName);
107
147
  const lines: string[] = [];
108
- for (const [key, value] of Object.entries(colors)) {
109
- lines.push(`--${key}: ${value};`);
148
+ for (const key in colors) {
149
+ lines.push(`--${key}: ${colors[key]};`);
110
150
  }
111
151
 
112
152
  const themeExport = await getThemeExportColors(themeName);
@@ -200,8 +240,8 @@ async function collectSubSessionsFromDir(
200
240
  }
201
241
 
202
242
  /** Generate HTML from bundled template with runtime substitutions. */
203
- async function generateHtml(sessionData: SessionData, themeName?: string): Promise<string> {
204
- const themeVars = await generateThemeVars(themeName);
243
+ async function generateHtml(sessionData: SessionData, palette: "web" | "theme", themeName?: string): Promise<string> {
244
+ const themeVars = await generateThemeVars(palette, themeName);
205
245
  const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toBase64();
206
246
 
207
247
  // Use function replacements so `$'`, `$&`, `$$`, `$n`, etc. in the
@@ -229,7 +269,8 @@ export async function exportSessionToHtml(
229
269
  if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions;
230
270
  }
231
271
 
232
- const html = await generateHtml(sessionData, opts.themeName);
272
+ const palette = opts.palette ?? (opts.themeName ? "theme" : "web");
273
+ const html = await generateHtml(sessionData, palette, opts.themeName);
233
274
  const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`;
234
275
 
235
276
  await Bun.write(outputPath, html);
@@ -258,7 +299,8 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
258
299
  if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions;
259
300
  }
260
301
 
261
- const html = await generateHtml(sessionData, opts.themeName);
302
+ const palette = opts.palette ?? (opts.themeName ? "theme" : "web");
303
+ const html = await generateHtml(sessionData, palette, opts.themeName);
262
304
  const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`;
263
305
 
264
306
  await Bun.write(outputPath, html);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Web/export palette — the omp brand identity shared by the collab-web live
3
+ * client (`my.omp.sh/`) and every public HTML export / share viewer (`/s/<id>`).
4
+ *
5
+ * Why this exists separately from `modes/theme/dark.json`: the `dark` theme is
6
+ * the **default TUI theme** — its amber accent (`#febc38`) drives the terminal
7
+ * status line, syntax highlighting, thinking levels, and bash/python mode
8
+ * colors for every omp user. The public web artifacts want the collab-web
9
+ * pink/purple identity instead, so they pin this palette rather than inheriting
10
+ * the TUI's. Editing `dark.json` to repurpose it for the web would repaint
11
+ * every terminal; this file keeps the two surfaces decoupled.
12
+ *
13
+ * Token layout — emitted as CSS custom properties on `:root`:
14
+ * • Legacy export names consumed by `template.css` / `template.js`
15
+ * (`--text`, `--body-bg`, `--container-bg`, `--info-bg`, `--accent`,
16
+ * `--border`, `--success`, `--error`, `--warning`, `--muted`, `--dim`,
17
+ * `--borderAccent`, `--selectedBg`, `--userMessageBg`, `--customMessageBg`,
18
+ * `--customMessageLabel`, `--mdHeading`, `--mdLink`, `--mdCode`,
19
+ * `--mdListBullet`, `--toolOutput`, `--thinkingText`, syntax*, …).
20
+ * • collab-web-native aliases consumed by the `tv-` tool-render bridge
21
+ * (`tool-render.css`: `var(--bg-inset, …)`, `var(--fg, …)`, …) so embedded
22
+ * tool cards resolve to the *real* collab-web tokens and render
23
+ * pixel-identical to the live client.
24
+ *
25
+ * Alpha-bearing tokens (`--border`, `--ring`, `--accent-muted`, …) keep their
26
+ * `oklch(… / N%)` form — flattening them to opaque hex would produce harsh
27
+ * white borders and non-matching translucent focus rings. Opaque surfaces are
28
+ * sRGB hex (the collab-web `tokens.css` OKLCH dark-theme tokens converted via
29
+ * the standard OKLab→linear-sRGB→gamma path); if the live client palette
30
+ * changes, regenerate those from there.
31
+ */
32
+ export const WEB_EXPORT_PALETTE = {
33
+ // --- collab-web-native aliases (tv- bridge) ---
34
+ "--bg": "#0f0b14",
35
+ "--bg-raised": "#16111c",
36
+ "--bg-inset": "#09060c",
37
+ "--bg-overlay": "#211b28",
38
+ "--fg": "#e6e3ea",
39
+ "--fg-muted": "#a49faa",
40
+ "--fg-faint": "#6e6974",
41
+ "--accent": "#ed4abf",
42
+ "--accent-muted": "oklch(0.674 0.23 341 / 18%)",
43
+ "--ok": "#68ca80",
44
+ "--err": "#f05653",
45
+ "--warn": "#e4b33f",
46
+ "--ring": "oklch(0.817 0.112 205 / 70%)",
47
+ "--font-mono": 'ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace',
48
+
49
+ // --- legacy export names (template.css / template.js) ---
50
+ // surfaces — map onto the collab-web purple ramp
51
+ "--body-bg": "#0f0b14", // = --bg
52
+ "--container-bg": "#16111c", // = --bg-raised
53
+ "--info-bg": "#09060c", // = --bg-inset (recessed wells: code blocks, tool output)
54
+ // text
55
+ "--text": "#e6e3ea", // = --fg
56
+ "--muted": "#a49faa", // = --fg-muted
57
+ "--dim": "#6e6974", // = --fg-faint
58
+ "--thinkingText": "#a49faa",
59
+ // hairlines — white-alpha, matching collab-web's --border/--border-strong
60
+ "--border": "oklch(1 0 0 / 9%)",
61
+ "--borderMuted": "oklch(1 0 0 / 6%)",
62
+ // accent + brand purple (the gradient's mid stop) for secondary highlights
63
+ "--borderAccent": "#945ff9",
64
+ "--selectedBg": "#2d2535",
65
+ // status — semantic, used sparingly (cancelled / exit-code / success dots)
66
+ "--success": "#68ca80", // = --ok
67
+ "--error": "#f05653", // = --err
68
+ "--warning": "#e4b33f", // = --warn
69
+ // message bubbles
70
+ "--userMessageBg": "oklch(0.674 0.23 341 / 6%)", // accent-tinted, like template.css user-message
71
+ "--userMessageText": "#e6e3ea",
72
+ "--customMessageBg": "#211b28", // = --bg-overlay
73
+ "--customMessageText": "#a49faa", // = --fg-muted
74
+ "--customMessageLabel": "#b281d6", // lilac, matching dark.json's label hue
75
+ // tool surfaces
76
+ "--toolPendingBg": "#16111c",
77
+ "--toolSuccessBg": "#09060c",
78
+ "--toolErrorBg": "oklch(0.66 0.19 25 / 14%)",
79
+ "--toolTitle": "#e6e3ea",
80
+ "--toolOutput": "#a49faa", // = --fg-muted
81
+ // markdown
82
+ "--mdHeading": "#ed4abf", // accent — headings carry the brand
83
+ "--mdLink": "#5ad8e5", // ring cyan — links distinct from the pink accent
84
+ "--mdLinkUrl": "#6e6974", // = --fg-faint
85
+ "--mdCode": "#e6e3ea",
86
+ "--mdCodeBlock": "#e6e3ea",
87
+ "--mdCodeBlockBorder": "oklch(1 0 0 / 9%)",
88
+ "--mdQuote": "#a49faa",
89
+ "--mdQuoteBorder": "oklch(1 0 0 / 13%)",
90
+ "--mdHr": "oklch(1 0 0 / 9%)",
91
+ "--mdListBullet": "#ed4abf", // accent — bullets carry the brand
92
+ // diff
93
+ "--toolDiffAdded": "#68ca80",
94
+ "--toolDiffRemoved": "#f05653",
95
+ "--toolDiffContext": "#6e6974",
96
+ // syntax — cool-neutral with pink/purple accents for keywords/types
97
+ "--syntaxComment": "#6e6974", // = --fg-faint
98
+ "--syntaxKeyword": "#945ff9", // brand purple
99
+ "--syntaxFunction": "#e4b33f", // warn amber (analog of dark.json's DCDCAA)
100
+ "--syntaxVariable": "#5ad8e5", // ring cyan
101
+ "--syntaxString": "#68ca80", // ok green
102
+ "--syntaxNumber": "#ed4abf", // accent
103
+ "--syntaxType": "#b281d6", // lilac
104
+ "--syntaxOperator": "#e6e3ea",
105
+ "--syntaxPunctuation": "#a49faa",
106
+ // thinking-level ramp — purple → lilac, matching the brand gradient
107
+ "--thinkingOff": "#6e6974",
108
+ "--thinkingMinimal": "#6e6974",
109
+ "--thinkingLow": "#945ff9",
110
+ "--thinkingMedium": "#b281d6",
111
+ "--thinkingHigh": "#ed4abf",
112
+ "--thinkingXhigh": "#e4b33f",
113
+ // mode tints (sidebar/role tags) — not surfaced in the export tree but
114
+ // emitted for completeness so template.js role classes resolve cleanly
115
+ "--bashMode": "#5ad8e5",
116
+ "--pythonMode": "#e4b33f",
117
+ // status-line tokens are TUI-only; not consumed by the export template, but
118
+ // emitted so any future surface that reads them inherits the brand.
119
+ "--statusLineBg": "#0f0b14",
120
+ "--statusLineSep": "#6e6974",
121
+ "--statusLineModel": "#ed4abf",
122
+ "--statusLinePath": "#5ad8e5",
123
+ "--statusLineGitClean": "#68ca80",
124
+ "--statusLineGitDirty": "#e4b33f",
125
+ "--statusLineContext": "#a49faa",
126
+ "--statusLineSpend": "#5ad8e5",
127
+ "--statusLineStaged": "#68ca80",
128
+ "--statusLineDirty": "#e4b33f",
129
+ "--statusLineUntracked": "#945ff9",
130
+ "--statusLineOutput": "#b281d6",
131
+ "--statusLineCost": "#b281d6",
132
+ "--statusLineSubagents": "#ed4abf",
133
+ } as const satisfies Record<string, string>;
134
+
135
+ /** Serialize the palette as `--key: value;` declarations for `:root { … }`. */
136
+ export function webExportThemeVars(): string {
137
+ let out = "";
138
+ for (const k in WEB_EXPORT_PALETTE) {
139
+ out += `${k}: ${WEB_EXPORT_PALETTE[k as keyof typeof WEB_EXPORT_PALETTE]}; `;
140
+ }
141
+ return out.trimEnd();
142
+ }
@@ -19,13 +19,16 @@
19
19
  import * as fs from "node:fs/promises";
20
20
  import * as os from "node:os";
21
21
  import * as path from "node:path";
22
- import type { AgentState } from "@oh-my-pi/pi-agent-core";
22
+ import type { AgentMessage, AgentState } from "@oh-my-pi/pi-agent-core";
23
+ import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
23
24
  import { $which, logger } from "@oh-my-pi/pi-utils";
24
25
  import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
25
26
  import { $ } from "bun";
26
- import type { SecretObfuscator } from "../secrets/obfuscator";
27
+ import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
28
+ import type { SessionEntry, SessionHeader } from "../session/session-entries";
27
29
  import type { SessionManager } from "../session/session-manager";
28
- import { buildSessionData, type SessionData } from "./html";
30
+ import type { OutputMeta } from "../tools/output-meta";
31
+ import { buildSessionData, type SessionData, type SubSession } from "./html";
29
32
 
30
33
  export { DEFAULT_SHARE_URL };
31
34
 
@@ -53,10 +56,15 @@ export interface ShareSessionOptions {
53
56
  /** Agent state for system prompt + tool descriptions in the snapshot. */
54
57
  state?: AgentState;
55
58
  /**
56
- * Redacts the snapshot before sealing: deep-walks every string (entries,
57
- * header, system prompt, tool descriptions) through the obfuscator, so
58
- * secrets that landed in persisted entries (tool outputs reading .env,
59
- * etc.) never leave the machine. Pass undefined to skip.
59
+ * Redacts the snapshot before sealing via a typed, per-field walk over the
60
+ * session (header title/cwd, system prompt, tool descriptions, entry summaries,
61
+ * labels, and message text including tool-result output and `@file` mentions),
62
+ * so secrets that landed in persisted entries (tool outputs reading .env, etc.)
63
+ * never leave the machine. Inline image bytes are preserved (size-trimmed
64
+ * separately); opaque provider-replay blobs (`providerPayload`,
65
+ * `redactedThinking`, `compaction.preserveData`) and untyped extension payloads
66
+ * (`details`/`data`/`outputSchema`) are dropped rather than walked. Pass
67
+ * undefined to skip redaction entirely.
60
68
  */
61
69
  obfuscator?: SecretObfuscator;
62
70
  }
@@ -75,7 +83,189 @@ export interface ShareSessionResult {
75
83
  /** Build the snapshot that gets sealed and uploaded, redacted when an obfuscator is provided. */
76
84
  export function buildShareSnapshot(sm: SessionManager, options?: ShareSessionOptions): SessionData {
77
85
  const data = buildSessionData(sm, options?.state);
78
- return options?.obfuscator?.hasSecrets() ? options.obfuscator.obfuscateObject(data) : data;
86
+ return options?.obfuscator?.hasSecrets() ? redactSessionDataForShare(options.obfuscator, data) : data;
87
+ }
88
+
89
+ /**
90
+ * Redact secrets from a share snapshot. A share blob leaves the machine, so
91
+ * every text-bearing field is rewritten through the obfuscator. The walk is
92
+ * typed end-to-end (no generic object traversal): inline image bytes are left
93
+ * intact (size-trimmed later by {@link stripImagePayloads}) and opaque,
94
+ * untyped payloads we cannot redact field-by-field (`compaction.preserveData`,
95
+ * extension `details`/`data`, `mode_change.data`, structured output schemas)
96
+ * are dropped so they cannot leak.
97
+ */
98
+ function redactShareHeader(o: SecretObfuscator, header: SessionHeader | null): SessionHeader | null {
99
+ if (!header) return header;
100
+ return {
101
+ ...header,
102
+ title: header.title === undefined ? undefined : o.obfuscate(header.title),
103
+ cwd: o.obfuscate(header.cwd),
104
+ };
105
+ }
106
+
107
+ function redactSessionDataForShare(o: SecretObfuscator, data: SessionData): SessionData {
108
+ return {
109
+ ...data,
110
+ header: redactShareHeader(o, data.header),
111
+ systemPrompt: data.systemPrompt === undefined ? undefined : o.obfuscate(data.systemPrompt),
112
+ tools: data.tools?.map(tool => ({ ...tool, description: o.obfuscate(tool.description) })),
113
+ entries: data.entries.map(entry => redactShareEntry(o, entry)),
114
+ subSessions: data.subSessions
115
+ ? Object.fromEntries(
116
+ Object.entries(data.subSessions).map(([key, sub]) => [key, redactShareSubSession(o, sub)]),
117
+ )
118
+ : data.subSessions,
119
+ };
120
+ }
121
+
122
+ function redactShareSubSession(o: SecretObfuscator, sub: SubSession): SubSession {
123
+ return {
124
+ ...sub,
125
+ header: redactShareHeader(o, sub.header),
126
+ entries: sub.entries.map(entry => redactShareEntry(o, entry)),
127
+ };
128
+ }
129
+
130
+ function redactShareEntry(o: SecretObfuscator, entry: SessionEntry): SessionEntry {
131
+ switch (entry.type) {
132
+ case "message":
133
+ return { ...entry, message: redactShareMessage(o, entry.message) };
134
+ case "compaction":
135
+ return {
136
+ ...entry,
137
+ summary: o.obfuscate(entry.summary),
138
+ shortSummary: entry.shortSummary === undefined ? undefined : o.obfuscate(entry.shortSummary),
139
+ details: undefined,
140
+ preserveData: undefined,
141
+ };
142
+ case "branch_summary":
143
+ return { ...entry, summary: o.obfuscate(entry.summary), details: undefined };
144
+ case "custom_message":
145
+ return { ...entry, content: redactShareContent(o, entry.content), details: undefined };
146
+ case "custom":
147
+ return { ...entry, data: undefined };
148
+ case "mode_change":
149
+ return { ...entry, data: undefined };
150
+ case "session_init":
151
+ return {
152
+ ...entry,
153
+ systemPrompt: o.obfuscate(entry.systemPrompt),
154
+ task: o.obfuscate(entry.task),
155
+ outputSchema: undefined,
156
+ };
157
+ case "label":
158
+ return { ...entry, label: entry.label === undefined ? undefined : o.obfuscate(entry.label) };
159
+ default:
160
+ return entry;
161
+ }
162
+ }
163
+
164
+ function redactShareContent(
165
+ o: SecretObfuscator,
166
+ content: string | (TextContent | ImageContent)[],
167
+ ): string | (TextContent | ImageContent)[] {
168
+ if (typeof content === "string") return o.obfuscate(content);
169
+ return content.map(block => (block.type === "text" ? { ...block, text: o.obfuscate(block.text) } : block));
170
+ }
171
+
172
+ /** Redact freeform strings in tool output metadata (source path/URL, diagnostics); numeric truncation info is preserved. */
173
+ function redactShareOutputMeta(o: SecretObfuscator, meta: OutputMeta | undefined): OutputMeta | undefined {
174
+ if (!meta) return meta;
175
+ return {
176
+ ...meta,
177
+ source: meta.source ? { ...meta.source, value: o.obfuscate(meta.source.value) } : meta.source,
178
+ diagnostics: meta.diagnostics
179
+ ? {
180
+ summary: o.obfuscate(meta.diagnostics.summary),
181
+ messages: meta.diagnostics.messages.map(message => o.obfuscate(message)),
182
+ }
183
+ : meta.diagnostics,
184
+ };
185
+ }
186
+
187
+ function redactShareMessage(o: SecretObfuscator, message: AgentMessage): AgentMessage {
188
+ switch (message.role) {
189
+ case "user":
190
+ case "developer":
191
+ return {
192
+ ...message,
193
+ providerPayload: undefined,
194
+ content: redactShareContent(o, message.content),
195
+ } as AgentMessage;
196
+ case "custom":
197
+ case "hookMessage":
198
+ return { ...message, details: undefined, content: redactShareContent(o, message.content) } as AgentMessage;
199
+ case "toolResult":
200
+ return {
201
+ ...message,
202
+ details: undefined,
203
+ content: redactShareContent(o, message.content) as (TextContent | ImageContent)[],
204
+ };
205
+ case "assistant":
206
+ // Drop opaque provider-replay state (encrypted reasoning / native history) the viewer
207
+ // never reads and we cannot redact field-by-field: `providerPayload` and any
208
+ // `redactedThinking` blocks.
209
+ return {
210
+ ...message,
211
+ providerPayload: undefined,
212
+ errorMessage: message.errorMessage === undefined ? undefined : o.obfuscate(message.errorMessage),
213
+ content: message.content.flatMap((block): AssistantMessage["content"] => {
214
+ if (block.type === "redactedThinking") return [];
215
+ if (block.type === "text") return [{ ...block, text: o.obfuscate(block.text) }];
216
+ if (block.type === "thinking") return [{ ...block, thinking: o.obfuscate(block.thinking) }];
217
+ if (block.type === "toolCall") {
218
+ return [
219
+ {
220
+ ...block,
221
+ arguments: obfuscateToolArguments(o, block.arguments),
222
+ intent: block.intent === undefined ? undefined : o.obfuscate(block.intent),
223
+ rawBlock: block.rawBlock === undefined ? undefined : o.obfuscate(block.rawBlock),
224
+ },
225
+ ];
226
+ }
227
+ return [block];
228
+ }),
229
+ };
230
+ case "bashExecution":
231
+ return {
232
+ ...message,
233
+ command: o.obfuscate(message.command),
234
+ output: o.obfuscate(message.output),
235
+ meta: redactShareOutputMeta(o, message.meta),
236
+ };
237
+ case "pythonExecution":
238
+ return {
239
+ ...message,
240
+ code: o.obfuscate(message.code),
241
+ output: o.obfuscate(message.output),
242
+ meta: redactShareOutputMeta(o, message.meta),
243
+ };
244
+ case "branchSummary":
245
+ return { ...message, summary: o.obfuscate(message.summary) };
246
+ case "compactionSummary":
247
+ return {
248
+ ...message,
249
+ providerPayload: undefined,
250
+ summary: o.obfuscate(message.summary),
251
+ shortSummary: message.shortSummary === undefined ? undefined : o.obfuscate(message.shortSummary),
252
+ blocks:
253
+ message.blocks === undefined
254
+ ? undefined
255
+ : (redactShareContent(o, message.blocks) as (TextContent | ImageContent)[]),
256
+ };
257
+ case "fileMention":
258
+ return {
259
+ ...message,
260
+ files: message.files.map(file => ({
261
+ ...file,
262
+ path: o.obfuscate(file.path),
263
+ content: o.obfuscate(file.content),
264
+ })),
265
+ };
266
+ default:
267
+ return message;
268
+ }
79
269
  }
80
270
 
81
271
  /** Share the session; tries a secret gist first, then the share server. */