@code-yeongyu/senpi 2026.10.4 → 2026.10.5

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 (91) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/bundle/chunks/{anthropic-messages-GE4OCV55.js → anthropic-messages-G5236QNK.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-I2AKSC6I.js → app-server-command-3Q5G4CAF.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-2CS2GZBE.js → chunk-2WV426OJ.js} +2 -2
  5. package/dist/bundle/chunks/{chunk-INGR4HFB.js → chunk-ECDLITEY.js} +2 -2
  6. package/dist/bundle/chunks/{chunk-6O26XO2C.js → chunk-EEOKVSNF.js} +220 -252
  7. package/dist/bundle/chunks/{chunk-MNLBL232.js → chunk-JVSOW4QU.js} +1 -1
  8. package/dist/bundle/chunks/chunk-K3BXFPWZ.js +2 -0
  9. package/dist/bundle/chunks/{chunk-QOT4HXY2.js → chunk-UHRXXTFZ.js} +3 -3
  10. package/dist/bundle/chunks/chunk-UUAKEDVS.js +14 -0
  11. package/dist/bundle/chunks/{chunk-ZKLQIAG5.js → chunk-UXIQK5NQ.js} +1 -1
  12. package/dist/bundle/chunks/chunk-WTJU5B2C.js +51 -0
  13. package/dist/bundle/chunks/chunk-YSPHACIN.js +2 -0
  14. package/dist/bundle/chunks/{cli-main-BLXUYHLQ.js → cli-main-VZWSUHFB.js} +1 -1
  15. package/dist/bundle/chunks/{help-fast-path-TQWU6DPG.js → help-fast-path-SHZJ3QWQ.js} +1 -1
  16. package/dist/bundle/chunks/{host-command-JXNOYPAC.js → host-command-7JIGGOWR.js} +1 -1
  17. package/dist/bundle/chunks/{interactive-mode-CSVSUAS7.js → interactive-mode-JA7AHYYL.js} +1 -1
  18. package/dist/bundle/chunks/multi-session-host-34WOINP4.js +23 -0
  19. package/dist/bundle/chunks/{openai-completions-2DUKDEVN.js → openai-completions-VSH22FCN.js} +1 -1
  20. package/dist/bundle/chunks/{openai-responses-WCB4HIPI.js → openai-responses-WLBHCFYL.js} +1 -1
  21. package/dist/bundle/chunks/{package-manager-cli-Q5EQZL7I.js → package-manager-cli-2QLVGQSC.js} +1 -1
  22. package/dist/bundle/chunks/{rotation-stream-VZLJIEP5.js → rotation-stream-JUCEXHWU.js} +1 -1
  23. package/dist/bundle/chunks/{rpc-mode-6VMXY46C.js → rpc-mode-QEBSN2OH.js} +1 -1
  24. package/dist/bundle/chunks/{session-control-endpoint-K3RWX652.js → session-control-endpoint-UGQIP6ZN.js} +1 -1
  25. package/dist/bundle/chunks/{session-picker-IPG3MKRC.js → session-picker-HX4LOJZ7.js} +1 -1
  26. package/dist/bundle/chunks/session-worker.js +251 -242
  27. package/dist/bundle/cli.js +1 -1
  28. package/dist/bundle/index.js +1 -1
  29. package/dist/bundle/rpc-entry.js +1 -1
  30. package/dist/bundle/runtime-manifest.json +1 -1
  31. package/dist/core/agent-session-runtime.d.ts +5 -0
  32. package/dist/core/agent-session-runtime.js +5 -0
  33. package/dist/core/agent-session-services.d.ts +1 -0
  34. package/dist/core/agent-session-services.js +1 -0
  35. package/dist/core/agent-session.d.ts +14 -0
  36. package/dist/core/agent-session.js +31 -0
  37. package/dist/core/browser-engine.d.ts +7 -0
  38. package/dist/core/browser-engine.js +7 -0
  39. package/dist/core/continue-from-leaf.d.ts +18 -0
  40. package/dist/core/continue-from-leaf.js +30 -0
  41. package/dist/core/extensions/builtin/gpt-apply-patch/line-endings.d.ts +28 -0
  42. package/dist/core/extensions/builtin/gpt-apply-patch/line-endings.js +76 -0
  43. package/dist/core/extensions/builtin/gpt-apply-patch/parser.js +21 -23
  44. package/dist/core/extensions/builtin/gpt-apply-patch/patch-replace.js +5 -16
  45. package/dist/core/extensions/builtin/gpt-apply-patch/streaming-parser.js +3 -1
  46. package/dist/core/extensions/builtin/gpt-apply-patch/text.d.ts +8 -0
  47. package/dist/core/extensions/builtin/gpt-apply-patch/text.js +26 -3
  48. package/dist/core/extensions/builtin/gpt-apply-patch/types.d.ts +2 -0
  49. package/dist/core/extensions/builtin/terminal/tools/bash.d.ts +1 -0
  50. package/dist/core/extensions/builtin/terminal/tools/bash.js +8 -1
  51. package/dist/core/extensions/runner.d.ts +1 -0
  52. package/dist/core/extensions/runner.js +7 -0
  53. package/dist/core/extensions/types.d.ts +4 -0
  54. package/dist/core/model-resolver.js +1 -1
  55. package/dist/core/sdk.d.ts +3 -0
  56. package/dist/core/sdk.js +1 -0
  57. package/dist/core/tools/bash.js +3 -0
  58. package/dist/main.js +1 -0
  59. package/dist/modes/interactive/components/session-selector.d.ts +11 -0
  60. package/dist/modes/interactive/components/session-selector.js +30 -5
  61. package/dist/modes/rpc/connection-handler.js +18 -1
  62. package/dist/modes/rpc/custom-capability.d.ts +9 -0
  63. package/dist/modes/rpc/custom-capability.js +9 -0
  64. package/dist/modes/rpc/host-daemon-env.js +2 -0
  65. package/dist/modes/rpc/host-idle-handover.js +1 -0
  66. package/dist/modes/rpc/media-placeholders.d.ts +23 -1
  67. package/dist/modes/rpc/media-placeholders.js +16 -13
  68. package/dist/modes/rpc/rpc-client.d.ts +8 -0
  69. package/dist/modes/rpc/rpc-client.js +8 -0
  70. package/dist/modes/rpc/rpc-input-validation.d.ts +6 -0
  71. package/dist/modes/rpc/rpc-input-validation.js +11 -0
  72. package/dist/modes/rpc/rpc-types.d.ts +19 -0
  73. package/dist/modes/rpc/session-command-router.js +11 -2
  74. package/dist/modes/rpc/session-event-writer.d.ts +4 -0
  75. package/dist/modes/rpc/session-event-writer.js +12 -1
  76. package/dist/modes/rpc/session-registry-attach.js +4 -0
  77. package/dist/modes/rpc/session-worker-client.d.ts +3 -0
  78. package/dist/modes/rpc/session-worker-client.js +6 -0
  79. package/dist/modes/rpc/session-worker-protocol.d.ts +5 -0
  80. package/dist/modes/rpc/session-worker.js +6 -0
  81. package/dist/modes/rpc/tool-media-store.d.ts +59 -0
  82. package/dist/modes/rpc/tool-media-store.js +190 -0
  83. package/dist/modes/rpc/worker-session-registry.js +8 -3
  84. package/docs/environment-variables.md +1 -0
  85. package/docs/rpc.md +62 -3
  86. package/package.json +7 -6
  87. package/dist/bundle/chunks/chunk-KMMECBM2.js +0 -15
  88. package/dist/bundle/chunks/chunk-LHYG7TK7.js +0 -2
  89. package/dist/bundle/chunks/chunk-LJWC5IVX.js +0 -2
  90. package/dist/bundle/chunks/chunk-PC3E2YS6.js +0 -9
  91. package/dist/bundle/chunks/multi-session-host-5AFJKSSM.js +0 -23
@@ -1,14 +1,8 @@
1
+ import { replacementsAroundContext, SourceText } from "./line-endings.js";
1
2
  import { seekSequenceWithFuzz } from "./seek-sequence.js";
2
- import { normalizePatchText } from "./text.js";
3
- function splitFileLines(content) {
4
- const lines = normalizePatchText(content).split("\n");
5
- if (lines[lines.length - 1] === "") {
6
- lines.pop();
7
- }
8
- return lines;
9
- }
10
3
  export function replaceChunks(content, filePath, chunks) {
11
- const originalLines = splitFileLines(content);
4
+ const source = SourceText.parse(content);
5
+ const originalLines = source.texts;
12
6
  const replacements = [];
13
7
  let lineIndex = 0;
14
8
  let fuzz = 0;
@@ -37,14 +31,9 @@ export function replaceChunks(content, filePath, chunks) {
37
31
  if (foundAt === undefined)
38
32
  throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`);
39
33
  fuzz += foundAt.fuzz;
40
- replacements.push({ start: foundAt.index, oldLength: pattern.length, newLines });
34
+ replacements.push(...replacementsAroundContext(foundAt.index, pattern.length, newLines, chunk.contextLineIndices));
41
35
  lineIndex = foundAt.index + pattern.length;
42
36
  }
43
- const nextLines = [...originalLines];
44
- for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
45
- nextLines.splice(replacement.start, replacement.oldLength, ...replacement.newLines);
46
- }
47
- nextLines.push("");
48
- return { content: nextLines.join("\n"), fuzz };
37
+ return { content: source.replace(replacements), fuzz };
49
38
  }
50
39
  //# sourceMappingURL=patch-replace.js.map
@@ -100,7 +100,7 @@ export class StreamingPatchParser {
100
100
  const hunk = this.currentUpdate();
101
101
  let chunk = hunk.chunks[hunk.chunks.length - 1];
102
102
  if (!chunk || chunk.isEndOfFile) {
103
- chunk = { changeContexts: [], oldLines: [], newLines: [], isEndOfFile: false };
103
+ chunk = { changeContexts: [], oldLines: [], newLines: [], contextLineIndices: [], isEndOfFile: false };
104
104
  hunk.chunks.push(chunk);
105
105
  }
106
106
  return chunk;
@@ -178,6 +178,7 @@ export class StreamingPatchParser {
178
178
  const value = line.slice(1);
179
179
  const chunk = this.currentChunk();
180
180
  if (prefix === " ") {
181
+ chunk.contextLineIndices.push([chunk.oldLines.length, chunk.newLines.length]);
181
182
  chunk.oldLines.push(value);
182
183
  chunk.newLines.push(value);
183
184
  }
@@ -188,6 +189,7 @@ export class StreamingPatchParser {
188
189
  chunk.newLines.push(value);
189
190
  }
190
191
  else if (prefix === undefined) {
192
+ chunk.contextLineIndices.push([chunk.oldLines.length, chunk.newLines.length]);
191
193
  chunk.oldLines.push("");
192
194
  chunk.newLines.push("");
193
195
  }
@@ -1,4 +1,12 @@
1
1
  export declare function normalizePatchText(patchText: string): string;
2
2
  export declare function stripHeredoc(input: string): string;
3
+ declare const FILE_HEADER_MARKERS: readonly [readonly ["add", "*** Add File: "], readonly ["delete", "*** Delete File: "], readonly ["update", "*** Update File: "]];
4
+ export type FileHeader = {
5
+ readonly kind: (typeof FILE_HEADER_MARKERS)[number][0];
6
+ readonly path: string;
7
+ };
8
+ export declare function parseFileHeader(line: string): FileHeader | undefined;
9
+ export declare function parseMoveTo(line: string): string | undefined;
3
10
  export declare function extractPatchedPaths(patchText: string): string[];
11
+ export {};
4
12
  //# sourceMappingURL=text.d.ts.map
@@ -8,9 +8,32 @@ export function stripHeredoc(input) {
8
8
  }
9
9
  return input;
10
10
  }
11
+ const FILE_HEADER_MARKERS = [
12
+ ["add", "*** Add File: "],
13
+ ["delete", "*** Delete File: "],
14
+ ["update", "*** Update File: "],
15
+ ];
16
+ const MOVE_TO_MARKER = "*** Move to: ";
17
+ // The parser and extractPatchedPaths both read headers through these two functions, so they share one
18
+ // whitespace definition (String.prototype.trim): every path the parser writes is a path the permission
19
+ // system's per-file approval sees, spelled the same way.
20
+ export function parseFileHeader(line) {
21
+ const trimmed = line.trim();
22
+ for (const [kind, marker] of FILE_HEADER_MARKERS) {
23
+ if (trimmed.startsWith(marker))
24
+ return { kind, path: trimmed.slice(marker.length) };
25
+ }
26
+ return undefined;
27
+ }
28
+ export function parseMoveTo(line) {
29
+ const trimmed = line.trimEnd();
30
+ return trimmed.startsWith(MOVE_TO_MARKER) ? trimmed.slice(MOVE_TO_MARKER.length) : undefined;
31
+ }
11
32
  export function extractPatchedPaths(patchText) {
12
- const normalized = stripHeredoc(normalizePatchText(patchText));
13
- const matches = normalized.matchAll(/^\*\*\* (?:(?:Add|Delete|Update) File|Move to): (.+)$/gm);
14
- return Array.from(matches, (match) => match[1] ?? "");
33
+ const lines = stripHeredoc(normalizePatchText(patchText)).split("\n");
34
+ return lines.flatMap((line) => {
35
+ const path = parseFileHeader(line)?.path ?? parseMoveTo(line.trimStart());
36
+ return path === undefined ? [] : [path];
37
+ });
15
38
  }
16
39
  //# sourceMappingURL=text.js.map
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ToolDefinition } from "../../types.ts";
2
2
  import type { APPLY_PATCH_PARAMS } from "./constants.ts";
3
+ import type { ContextLineIndex } from "./line-endings.ts";
3
4
  export type ParsedPatch = {
4
5
  type: "add";
5
6
  filePath: string;
@@ -17,6 +18,7 @@ export type PatchChunk = {
17
18
  changeContexts: string[];
18
19
  oldLines: string[];
19
20
  newLines: string[];
21
+ contextLineIndices: ContextLineIndex[];
20
22
  isEndOfFile: boolean;
21
23
  };
22
24
  export type ApplyPatchWireMode = "freeform" | "json" | "none";
@@ -32,6 +32,7 @@ type SessionEnvSource = {
32
32
  thinkingLevel?: string;
33
33
  cwd?: string;
34
34
  goalStoreFile?: string;
35
+ browserEngine?: string;
35
36
  };
36
37
  /** Build the PTY-backed `bash` tool definition. */
37
38
  export declare function createPtyBashTool(ctx: TerminalToolContext): {
@@ -79,7 +79,12 @@ function sessionSpawnContext(ctx) {
79
79
  return {
80
80
  ...ctx,
81
81
  // Explicit deletions survive backends that merge overrides with the host environment.
82
- getEnv: () => ({ ...ctx.getEnv(), PI_SESSION_CWD: undefined, PI_GOAL_STORE_FILE: undefined }),
82
+ getEnv: () => ({
83
+ ...ctx.getEnv(),
84
+ PI_SESSION_CWD: undefined,
85
+ PI_GOAL_STORE_FILE: undefined,
86
+ OMO_BROWSER_ENGINE: undefined,
87
+ }),
83
88
  };
84
89
  }
85
90
  function sessionEnvOverrides(ctx, execCtx) {
@@ -101,6 +106,8 @@ function sessionEnvOverrides(ctx, execCtx) {
101
106
  }
102
107
  if (session.thinkingLevel)
103
108
  env.PI_REASONING_LEVEL = session.thinkingLevel;
109
+ if (session.browserEngine)
110
+ env.OMO_BROWSER_ENGINE = session.browserEngine;
104
111
  return env;
105
112
  }
106
113
  /**
@@ -138,6 +138,7 @@ export declare class ExtensionRunner {
138
138
  private getPromptCacheKeepAliveSettingsFn;
139
139
  private getLookAtSettingsFn;
140
140
  private getAskUserSettingsFn;
141
+ private getBrowserEngineFn;
141
142
  private getImageSettingsFn;
142
143
  private sessionSettingsFn;
143
144
  private compactFn;
@@ -248,6 +248,7 @@ export class ExtensionRunner {
248
248
  enabled: true,
249
249
  timeoutMinutes: 30,
250
250
  });
251
+ this.getBrowserEngineFn = () => undefined;
251
252
  this.getImageSettingsFn = () => ({
252
253
  autoResize: true,
253
254
  blockImages: false,
@@ -356,6 +357,8 @@ export class ExtensionRunner {
356
357
  this.getLookAtSettingsFn = contextActions.getLookAtSettings;
357
358
  if (contextActions.getAskUserSettings)
358
359
  this.getAskUserSettingsFn = contextActions.getAskUserSettings;
360
+ if (contextActions.getBrowserEngine)
361
+ this.getBrowserEngineFn = contextActions.getBrowserEngine;
359
362
  this.getImageSettingsFn = contextActions.getImageSettings;
360
363
  this.sessionSettingsFn = contextActions.sessionSettings;
361
364
  this.compactFn = contextActions.compact;
@@ -908,6 +911,10 @@ export class ExtensionRunner {
908
911
  runner.assertActive();
909
912
  return goalFilePath(goalStoreRef(runner.sessionManager, runner.cwd));
910
913
  },
914
+ get browserEngine() {
915
+ runner.assertActive();
916
+ return runner.getBrowserEngineFn();
917
+ },
911
918
  get modelRegistry() {
912
919
  runner.assertActive();
913
920
  return runner.modelRegistry;
@@ -13,6 +13,7 @@ import type { AutocompleteItem, AutocompleteProvider, Component, EditorComponent
13
13
  import type { Static, TSchema } from "typebox";
14
14
  import type { Theme } from "../../modes/interactive/theme/theme.ts";
15
15
  import type { BashResult } from "../bash-executor.ts";
16
+ import type { BrowserEngine } from "../browser-engine.ts";
16
17
  import type { CompactionPreparation, CompactionResult } from "../compaction/index.ts";
17
18
  import type { WarmAnchorSnapshot } from "../compaction/warm-anchor.ts";
18
19
  import type { EventBus } from "../event-bus.ts";
@@ -355,6 +356,8 @@ export interface ExtensionContext {
355
356
  sessionManager: ReadonlySessionManager;
356
357
  /** Absolute goal-store path for this session; reading it does not create the file. */
357
358
  readonly goalStoreFile?: string;
359
+ /** Browser engine the opener chose for THIS session (`open_session.browserEngine`); absent when it chose none. */
360
+ readonly browserEngine?: BrowserEngine;
358
361
  /** Model registry for API key resolution */
359
362
  modelRegistry: ModelRegistry;
360
363
  /** Current model (may be undefined) */
@@ -2366,6 +2369,7 @@ export interface ExtensionContextActions {
2366
2369
  enabled: boolean;
2367
2370
  timeoutMinutes: number;
2368
2371
  };
2372
+ getBrowserEngine?: () => BrowserEngine | undefined;
2369
2373
  getImageSettings: () => {
2370
2374
  autoResize: boolean;
2371
2375
  blockImages: boolean;
@@ -24,7 +24,7 @@ export const defaultModelPerProvider = {
24
24
  cursor: "auto",
25
25
  // Radius resolves its catalog after discovery; "balanced" is the selectable default.
26
26
  radius: "balanced",
27
- nvidia: "nvidia/nemotron-3-super-120b-a12b",
27
+ nvidia: "nvidia/nemotron-3-ultra-550b-a55b",
28
28
  deepseek: "deepseek-v4-pro",
29
29
  google: "gemini-3.1-pro-preview",
30
30
  "google-vertex": "gemini-3.1-pro-preview",
@@ -3,6 +3,7 @@ import type { ThinkingSelection } from "@earendil-works/pi-ai";
3
3
  import { type Model } from "@earendil-works/pi-ai/compat";
4
4
  import { AgentSession } from "./agent-session.ts";
5
5
  import { AuthStorage } from "./auth-storage.ts";
6
+ import type { BrowserEngine } from "./browser-engine.ts";
6
7
  import type { PromptSurface } from "./dynamic-prompt/types.ts";
7
8
  import { type ServiceTier } from "./extensions/builtin/service-tier.ts";
8
9
  import type { LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
@@ -83,6 +84,8 @@ export interface CreateAgentSessionOptions {
83
84
  autoTitleSessions?: boolean;
84
85
  /** Where this session's replies render; omitted means `SENPI_PROMPT_SURFACE` decides. */
85
86
  promptSurface?: PromptSurface;
87
+ /** Browser engine this session's skills drive (`open_session.browserEngine`); omitted means none was chosen. */
88
+ browserEngine?: BrowserEngine;
86
89
  }
87
90
  /** Result from createAgentSession */
88
91
  export interface CreateAgentSessionResult {
package/dist/core/sdk.js CHANGED
@@ -395,6 +395,7 @@ export async function createAgentSession(options = {}) {
395
395
  sessionStartEvent,
396
396
  autoTitleSessions: options.autoTitleSessions,
397
397
  promptSurface: options.promptSurface,
398
+ browserEngine: options.browserEngine,
398
399
  });
399
400
  const liveContextTokens = hasExistingSession
400
401
  ? existingSession.messages.reduce((total, message) => total + estimateTokens(message), 0)
@@ -191,6 +191,7 @@ function resolveSpawnContext(command, cwd, spawnHook, exposeSessionEnvironment,
191
191
  delete env.PI_PROVIDER;
192
192
  delete env.PI_MODEL;
193
193
  delete env.PI_REASONING_LEVEL;
194
+ delete env.OMO_BROWSER_ENGINE;
194
195
  if (exposeSessionEnvironment && ctx) {
195
196
  const model = ctx.model;
196
197
  env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
@@ -206,6 +207,8 @@ function resolveSpawnContext(command, cwd, spawnHook, exposeSessionEnvironment,
206
207
  }
207
208
  if (ctx.thinkingLevel)
208
209
  env.PI_REASONING_LEVEL = ctx.thinkingLevel;
210
+ if (ctx.browserEngine)
211
+ env.OMO_BROWSER_ENGINE = ctx.browserEngine;
209
212
  }
210
213
  const baseContext = { command, cwd, env };
211
214
  return spawnHook ? spawnHook(baseContext) : baseContext;
package/dist/main.js CHANGED
@@ -813,6 +813,7 @@ export function createCliRuntimeFactory(configuration, local = {}) {
813
813
  customTools: sessionOptions.customTools,
814
814
  autoTitleSessions: resolveAutoTitleSessions(appMode, parsed, sessionManager.hasContextMessages(), parseClientCapabilities(envValue("RPC_CLIENT_CAPABILITIES")), launchProfile?.autoTitle),
815
815
  promptSurface: launchProfile?.promptSurface,
816
+ browserEngine: launchProfile?.browserEngine,
816
817
  });
817
818
  markSwitch("createSession");
818
819
  const cliThinkingOverride = runtimeParsed.thinking !== undefined || cliThinkingFromModel;
@@ -49,6 +49,17 @@ declare class SessionList implements Component, Focusable {
49
49
  handleInput(keyData: string): void;
50
50
  }
51
51
  type SessionsLoader = (onProgress?: SessionListProgress, signal?: AbortSignal) => Promise<SessionInfo[]>;
52
+ /**
53
+ * Delete a session file, trying the `trash` CLI first, then falling back to unlink. The session's
54
+ * tool images go with it; when they cannot be removed the session is still deleted (its id is gone
55
+ * with the file) and `mediaError` names the leftover directory so the caller can say so.
56
+ */
57
+ export declare function deleteSessionFile(sessionPath: string): Promise<{
58
+ ok: boolean;
59
+ method: "trash" | "unlink";
60
+ error?: string;
61
+ mediaError?: string;
62
+ }>;
52
63
  /**
53
64
  * Component that renders a session selector
54
65
  */
@@ -4,6 +4,7 @@ import { unlink } from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import { Container, getKeybindings, Input, Spacer, Text, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
6
6
  import { KeybindingsManager } from "../../../core/keybindings.js";
7
+ import { removeToolMedia, toolMediaRoot, toolMediaScopeOfSessionFile } from "../../rpc/tool-media-store.js";
7
8
  import { theme } from "../theme/theme.js";
8
9
  import { DynamicBorder } from "./dynamic-border.js";
9
10
  import { keyHint, keyText } from "./keybinding-hints.js";
@@ -466,10 +467,29 @@ class SessionList {
466
467
  }
467
468
  }
468
469
  }
470
+ function withMediaOutcome(result, mediaError) {
471
+ return mediaError === undefined ? result : { ...result, mediaError };
472
+ }
469
473
  /**
470
- * Delete a session file, trying the `trash` CLI first, then falling back to unlink
474
+ * Delete a session file, trying the `trash` CLI first, then falling back to unlink. The session's
475
+ * tool images go with it; when they cannot be removed the session is still deleted (its id is gone
476
+ * with the file) and `mediaError` names the leftover directory so the caller can say so.
471
477
  */
472
- async function deleteSessionFile(sessionPath) {
478
+ export async function deleteSessionFile(sessionPath) {
479
+ // The header is read before the file goes: the media directory is named after the session id.
480
+ const mediaScope = await toolMediaScopeOfSessionFile(sessionPath);
481
+ const removeMedia = () => {
482
+ if (mediaScope === undefined)
483
+ return undefined;
484
+ try {
485
+ removeToolMedia(mediaScope);
486
+ return undefined;
487
+ }
488
+ catch (cause) {
489
+ const reason = cause instanceof Error ? cause.message : String(cause);
490
+ return `${toolMediaRoot(mediaScope)}: ${reason}`;
491
+ }
492
+ };
473
493
  // Try `trash` first (if installed)
474
494
  const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath];
475
495
  const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" });
@@ -488,12 +508,12 @@ async function deleteSessionFile(sessionPath) {
488
508
  };
489
509
  // If trash reports success, or the file is gone afterwards, treat it as successful
490
510
  if (trashResult.status === 0 || !existsSync(sessionPath)) {
491
- return { ok: true, method: "trash" };
511
+ return withMediaOutcome({ ok: true, method: "trash" }, removeMedia());
492
512
  }
493
513
  // Fallback to permanent deletion
494
514
  try {
495
515
  await unlink(sessionPath);
496
- return { ok: true, method: "unlink" };
516
+ return withMediaOutcome({ ok: true, method: "unlink" }, removeMedia());
497
517
  }
498
518
  catch (err) {
499
519
  const unlinkError = err instanceof Error ? err.message : String(err);
@@ -628,7 +648,12 @@ export class SessionSelectorComponent extends Container {
628
648
  const showCwd = this.scope === "all";
629
649
  this.sessionList.setSessions(sessions, showCwd);
630
650
  const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted";
631
- this.header.setStatusMessage({ type: "info", message: msg }, 2000);
651
+ if (result.mediaError) {
652
+ this.header.setStatusMessage({ type: "error", message: `${msg}, but its images were not removed: ${result.mediaError}` }, 8000);
653
+ }
654
+ else {
655
+ this.header.setStatusMessage({ type: "info", message: msg }, 2000);
656
+ }
632
657
  await this.refreshSessionsAfterMutation();
633
658
  }
634
659
  else {
@@ -16,8 +16,10 @@
16
16
  */
17
17
  import * as crypto from "node:crypto";
18
18
  import { basename, dirname, extname } from "node:path";
19
+ import { modelSupportsAssistantPrefill } from "@earendil-works/pi-ai";
19
20
  import { VERSION } from "../../config.js";
20
21
  import { authMethodStatus, buildLoginProviderInfos } from "../../core/auth-providers.js";
22
+ import { ContinueFromLeafError } from "../../core/continue-from-leaf.js";
21
23
  import { getCredentialAccounts, pinCredentialAccount, removeCredentialAccount, } from "../../core/credential-accounts.js";
22
24
  import { AssistantEditError, SessionStreamingError } from "../../core/edited-assistant-message.js";
23
25
  import { UserEditError } from "../../core/edited-user-message.js";
@@ -32,7 +34,7 @@ import { ClientAdmissions } from "./client-admissions.js";
32
34
  import { handleClientInput } from "./client-input-handler.js";
33
35
  import { ClientMessageEvents } from "./client-message-events.js";
34
36
  import { ConnectionQuestionBridge, degradeQuestion, sessionQuestionBridges } from "./connection-question-bridge.js";
35
- import { AUTO_TITLE_SESSIONS_CAPABILITY, buildCustomUnsupportedRequest, DEFAULT_CUSTOM_EXTENSION_LABEL, EXTENSION_EVENTS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, QUESTION_CAPABILITY, } from "./custom-capability.js";
37
+ import { AUTO_TITLE_SESSIONS_CAPABILITY, buildCustomUnsupportedRequest, CONTINUE_FROM_LEAF_CAPABILITY, DEFAULT_CUSTOM_EXTENSION_LABEL, EXTENSION_EVENTS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, QUESTION_CAPABILITY, } from "./custom-capability.js";
36
38
  import { createRpcEventOutputBuffer } from "./event-output-buffer.js";
37
39
  import { settleExtensionUiResponse } from "./extension-ui-response.js";
38
40
  import { HostSessionControl } from "./host-session-control.js";
@@ -725,6 +727,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
725
727
  AUTO_TITLE_SESSIONS_CAPABILITY,
726
728
  MEDIA_PLACEHOLDERS_CAPABILITY,
727
729
  DURABLE_CLIENT_MESSAGE_ID_CAPABILITY,
730
+ CONTINUE_FROM_LEAF_CAPABILITY,
728
731
  ...(options.capabilities ?? []),
729
732
  ]),
730
733
  ],
@@ -756,6 +759,17 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
756
759
  session.appendSessionEntry(command.entry);
757
760
  return success(id, "append_session_entry");
758
761
  }
762
+ case "continue_from_leaf": {
763
+ try {
764
+ await session.continueFromLeaf();
765
+ return success(id, "continue_from_leaf");
766
+ }
767
+ catch (err) {
768
+ if (err instanceof ContinueFromLeafError)
769
+ return error(id, command.type, err.message, err.code);
770
+ throw err;
771
+ }
772
+ }
759
773
  case "send_custom_message": {
760
774
  await session.sendCustomMessage({
761
775
  customType: command.customType,
@@ -865,6 +879,9 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
865
879
  models: models.map((model) => ({
866
880
  ...model,
867
881
  supportedThinkingLevels: getSupportedThinkingLevels(model),
882
+ supportsAssistantPrefill: modelSupportsAssistantPrefill(model, {
883
+ thinkingEnabled: session.thinkingLevel !== "off",
884
+ }),
868
885
  })),
869
886
  });
870
887
  }
@@ -18,6 +18,8 @@ import type { RpcExtensionUIRequest } from "./rpc-types.ts";
18
18
  export declare const CUSTOM_UNSUPPORTED_CAPABILITY = "custom_unsupported";
19
19
  export declare const EXTENSION_EVENTS_CAPABILITY = "extension_events";
20
20
  export declare const AUTO_TITLE_SESSIONS_CAPABILITY = "auto_title_sessions";
21
+ /** Host capability: the `continue_from_leaf` command starts a turn with no new prompt (#1930). */
22
+ export declare const CONTINUE_FROM_LEAF_CAPABILITY = "continue_from_leaf";
21
23
  /**
22
24
  * Opt-in: the host replaces inline image bytes inside tool results with `image_ref`
23
25
  * placeholders for this connection; the client fetches a block on demand with `get_media`.
@@ -65,6 +67,13 @@ export declare const DURABLE_SESSION_ID_CAPABILITY = "durable_session_id";
65
67
  * `SENPI_PROMPT_SURFACE`. A later open that names another surface rebuilds that session's prompt.
66
68
  */
67
69
  export declare const PROMPT_SURFACE_CAPABILITY = "prompt_surface";
70
+ /**
71
+ * HOST capability: this host honors `open_session.browserEngine` (`connected` | `builtin` | `none`). The
72
+ * choice is per session, never per process: that session's tool subprocesses and eval kernel see
73
+ * `OMO_BROWSER_ENGINE=<value>` and no other session on the host does. Advertised only because the
74
+ * value reaches every consumer, so a client may rely on it the moment it sees this name.
75
+ */
76
+ export declare const BROWSER_ENGINE_CAPABILITY = "browser_engine";
68
77
  /**
69
78
  * HOST capability: `open_session.promptSurface` also accepts `chat` (a chat bridge: no routing line,
70
79
  * no handoff block, no todo cues). A host without it refuses `chat` with `invalid_launch_profile`,
@@ -18,6 +18,8 @@ import * as crypto from "node:crypto";
18
18
  export const CUSTOM_UNSUPPORTED_CAPABILITY = "custom_unsupported";
19
19
  export const EXTENSION_EVENTS_CAPABILITY = "extension_events";
20
20
  export const AUTO_TITLE_SESSIONS_CAPABILITY = "auto_title_sessions";
21
+ /** Host capability: the `continue_from_leaf` command starts a turn with no new prompt (#1930). */
22
+ export const CONTINUE_FROM_LEAF_CAPABILITY = "continue_from_leaf";
21
23
  /**
22
24
  * Opt-in: the host replaces inline image bytes inside tool results with `image_ref`
23
25
  * placeholders for this connection; the client fetches a block on demand with `get_media`.
@@ -65,6 +67,13 @@ export const DURABLE_SESSION_ID_CAPABILITY = "durable_session_id";
65
67
  * `SENPI_PROMPT_SURFACE`. A later open that names another surface rebuilds that session's prompt.
66
68
  */
67
69
  export const PROMPT_SURFACE_CAPABILITY = "prompt_surface";
70
+ /**
71
+ * HOST capability: this host honors `open_session.browserEngine` (`connected` | `builtin` | `none`). The
72
+ * choice is per session, never per process: that session's tool subprocesses and eval kernel see
73
+ * `OMO_BROWSER_ENGINE=<value>` and no other session on the host does. Advertised only because the
74
+ * value reaches every consumer, so a client may rely on it the moment it sees this name.
75
+ */
76
+ export const BROWSER_ENGINE_CAPABILITY = "browser_engine";
68
77
  /**
69
78
  * HOST capability: `open_session.promptSurface` also accepts `chat` (a chat bridge: no routing line,
70
79
  * no handoff block, no todo cues). A host without it refuses `chat` with `invalid_launch_profile`,
@@ -20,6 +20,7 @@ const ALLOWED_ENV_NAMES = [
20
20
  /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TERM|LANG)$/u,
21
21
  /^(LC|XDG)_/u,
22
22
  /^(SENPI|OMO|PI)_/u,
23
+ /^BSK_(HOME|BIN)$/u,
23
24
  /^(HTTPS?_PROXY|NO_PROXY)$/u,
24
25
  /^[A-Z0-9_]+_API_KEY$/u,
25
26
  /^(ANTHROPIC|OPENAI|GOOGLE|GEMINI|AZURE|AWS|OPENROUTER|XAI|MISTRAL|DEEPSEEK|GROQ|CEREBRAS|MINIMAX)_/u,
@@ -61,6 +62,7 @@ const TRANSIENT_ENV_NAMES = new Set([
61
62
  "PI_PROVIDER",
62
63
  "PI_MODEL",
63
64
  "PI_REASONING_LEVEL",
65
+ "OMO_BROWSER_ENGINE",
64
66
  "PI_PROMPT_CACHE_SAFE_WAIT_SECONDS",
65
67
  "SENPI_PY_KERNEL_PARENT_PID",
66
68
  "SENPI_RPC_HOST_WATCH_FD",
@@ -9,6 +9,7 @@ const NEW_WORK_COMMANDS = new Set([
9
9
  "steer",
10
10
  "follow_up",
11
11
  "send_custom_message",
12
+ "continue_from_leaf",
12
13
  "append_user_message",
13
14
  "bash",
14
15
  "compact",
@@ -22,6 +22,25 @@
22
22
  * User-authored images (`prompt.images`) stay intact: they are bounded by the client
23
23
  * that sent them; the fan-out hazard is tool output.
24
24
  */
25
+ /** Why a host could not keep a tool image on disk for the client to read. */
26
+ export type MediaUnavailableReason = "image_too_large" | "session_limit" | "storage_error";
27
+ /** Where a persisted image lives (absolute), or why there is no file. */
28
+ export type PersistedMedia = {
29
+ readonly path: string;
30
+ } | {
31
+ readonly unavailableReason: MediaUnavailableReason;
32
+ };
33
+ /**
34
+ * Writes one image's bytes to the session's media directory and reports the outcome. Returns
35
+ * `undefined` for a session that has nowhere durable to keep them: its placeholder carries neither field.
36
+ */
37
+ export type MediaPersister = (ref: {
38
+ readonly toolCallId: string;
39
+ readonly contentIndex: number;
40
+ }, block: {
41
+ readonly data: string;
42
+ readonly mimeType?: string;
43
+ }) => PersistedMedia | undefined;
25
44
  /** The block a capable client receives in place of inline image bytes. */
26
45
  export interface ImageRefBlock {
27
46
  readonly type: "image_ref";
@@ -31,6 +50,9 @@ export interface ImageRefBlock {
31
50
  readonly toolCallId: string;
32
51
  readonly contentIndex: number;
33
52
  };
53
+ /** Absolute path of the stored bytes; written before this placeholder was emitted. */
54
+ readonly path?: string;
55
+ readonly unavailableReason?: MediaUnavailableReason;
34
56
  }
35
57
  /**
36
58
  * Decoded byte length of a base64 string, derived from its length alone.
@@ -45,5 +67,5 @@ export declare function base64ByteLength(data: string): number;
45
67
  * Returns the SAME reference when nothing changed, so the caller can skip the second
46
68
  * serialization entirely.
47
69
  */
48
- export declare function omitInlineMedia<T extends object>(record: T): T;
70
+ export declare function omitInlineMedia<T extends object>(record: T, persist?: MediaPersister): T;
49
71
  //# sourceMappingURL=media-placeholders.d.ts.map
@@ -56,29 +56,32 @@ function isInlineImage(value) {
56
56
  return isPlainObject(value) && value.type === "image" && typeof value.data === "string";
57
57
  }
58
58
  /** Replace inline images in one tool-result content array; same reference when none. */
59
- function omitContentImages(content, toolCallId) {
59
+ function omitContentImages(content, toolCallId, persist) {
60
60
  let replaced;
61
61
  for (let index = 0; index < content.length; index++) {
62
62
  const block = content[index];
63
63
  if (!isInlineImage(block))
64
64
  continue;
65
65
  replaced ??= [...content];
66
+ const ref = { toolCallId, contentIndex: index };
67
+ // The bytes reach disk before the placeholder that names them leaves this function.
66
68
  replaced[index] = {
67
69
  type: "image_ref",
68
70
  mimeType: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
69
71
  byteLength: base64ByteLength(block.data),
70
- ref: { toolCallId, contentIndex: index },
72
+ ref,
73
+ ...persist?.(ref, block),
71
74
  };
72
75
  }
73
76
  return replaced ?? content;
74
77
  }
75
78
  /** A toolResult message whose content array carries images, rebuilt without them. */
76
- function omitToolResultImages(message) {
79
+ function omitToolResultImages(message, persist) {
77
80
  const content = message.content;
78
81
  const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
79
82
  if (!Array.isArray(content))
80
83
  return message;
81
- const omitted = omitContentImages(content, toolCallId);
84
+ const omitted = omitContentImages(content, toolCallId, persist);
82
85
  return omitted === content ? message : { ...message, content: omitted };
83
86
  }
84
87
  /**
@@ -86,11 +89,11 @@ function omitToolResultImages(message) {
86
89
  * scrubbed, wherever it sits (message arrays, `turn_end.toolResults`, session entries,
87
90
  * tree nodes, `state.entries`). Unchanged sub-trees are returned by reference.
88
91
  */
89
- function walk(value) {
92
+ function walk(value, persist) {
90
93
  if (Array.isArray(value)) {
91
94
  let replaced;
92
95
  for (let index = 0; index < value.length; index++) {
93
- const next = walk(value[index]);
96
+ const next = walk(value[index], persist);
94
97
  if (next === value[index])
95
98
  continue;
96
99
  replaced ??= [...value];
@@ -100,14 +103,14 @@ function walk(value) {
100
103
  }
101
104
  if (!isPlainObject(value))
102
105
  return value;
103
- const scrubbed = value.role === "toolResult" ? omitToolResultImages(value) : value;
106
+ const scrubbed = value.role === "toolResult" ? omitToolResultImages(value, persist) : value;
104
107
  let replaced = scrubbed === value ? undefined : { ...scrubbed };
105
108
  for (const [key, child] of Object.entries(scrubbed)) {
106
109
  if (key === "content" && scrubbed !== value)
107
110
  continue;
108
111
  if (!isPlainObject(child) && !Array.isArray(child))
109
112
  continue;
110
- const next = walk(child);
113
+ const next = walk(child, persist);
111
114
  if (next === child)
112
115
  continue;
113
116
  replaced ??= { ...scrubbed };
@@ -116,12 +119,12 @@ function walk(value) {
116
119
  return replaced ?? value;
117
120
  }
118
121
  /** `tool_execution_end.result.content` is not a toolResult message; scrub it explicitly. */
119
- function omitToolExecutionEndImages(record) {
122
+ function omitToolExecutionEndImages(record, persist) {
120
123
  const result = record.result;
121
124
  if (!isPlainObject(result) || !Array.isArray(result.content))
122
125
  return record;
123
126
  const toolCallId = typeof record.toolCallId === "string" ? record.toolCallId : "";
124
- const omitted = omitContentImages(result.content, toolCallId);
127
+ const omitted = omitContentImages(result.content, toolCallId, persist);
125
128
  return omitted === result.content ? record : { ...record, result: { ...result, content: omitted } };
126
129
  }
127
130
  /** True when this record type can carry a tool result at all (cheap gate). */
@@ -140,12 +143,12 @@ function carriesMedia(record) {
140
143
  * Returns the SAME reference when nothing changed, so the caller can skip the second
141
144
  * serialization entirely.
142
145
  */
143
- export function omitInlineMedia(record) {
146
+ export function omitInlineMedia(record, persist) {
144
147
  const typed = record;
145
148
  if (!carriesMedia(typed))
146
149
  return record;
147
- const scrubbed = typed.type === "tool_execution_end" ? omitToolExecutionEndImages(typed) : typed;
148
- const walked = walk(scrubbed);
150
+ const scrubbed = typed.type === "tool_execution_end" ? omitToolExecutionEndImages(typed, persist) : typed;
151
+ const walked = walk(scrubbed, persist);
149
152
  return (walked === typed ? record : walked);
150
153
  }
151
154
  //# sourceMappingURL=media-placeholders.js.map