@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
@@ -7,6 +7,7 @@ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"
7
7
  import type { ImageContent } from "@earendil-works/pi-ai";
8
8
  import type { PromptDisposition, QueuedInputDisposition, SessionStats } from "../../core/agent-session.ts";
9
9
  import type { BashResult } from "../../core/bash-executor.ts";
10
+ import type { BrowserEngine } from "../../core/browser-engine.ts";
10
11
  import { type ClientMessageIdentity } from "../../core/client-message-identity.ts";
11
12
  import type { CompactionResult } from "../../core/compaction/index.ts";
12
13
  import type { PromptSurface } from "../../core/dynamic-prompt/types.ts";
@@ -133,6 +134,8 @@ export declare class RpcClient {
133
134
  auto_title?: boolean;
134
135
  /** Where this session's replies render; needs the host's `prompt_surface` (`prompt_surface_chat` for `chat`). */
135
136
  promptSurface?: PromptSurface;
137
+ /** Which browser this session's skills drive; needs the host's `browser_engine`. */
138
+ browserEngine?: BrowserEngine;
136
139
  }): Promise<{
137
140
  sessionId: string;
138
141
  state: RpcSessionState;
@@ -176,6 +179,11 @@ export declare class RpcClient {
176
179
  triggerTurn?: boolean;
177
180
  deliverAs?: "steer" | "followUp" | "nextTurn";
178
181
  }): Promise<void>;
182
+ /**
183
+ * Start a turn from the current leaf with no new prompt (#1930), for example after an
184
+ * edited assistant response. Refused with `streaming` or `nothing_to_continue`.
185
+ */
186
+ continueFromLeaf(): Promise<void>;
179
187
  /**
180
188
  * Queue a steering message to interrupt the agent mid-run.
181
189
  */
@@ -393,6 +393,14 @@ export class RpcClient {
393
393
  async sendCustomMessage(message, options) {
394
394
  await this.send({ type: "send_custom_message", ...message, ...options });
395
395
  }
396
+ /**
397
+ * Start a turn from the current leaf with no new prompt (#1930), for example after an
398
+ * edited assistant response. Refused with `streaming` or `nothing_to_continue`.
399
+ */
400
+ async continueFromLeaf() {
401
+ const response = await this.send({ type: "continue_from_leaf" });
402
+ this.getData(response);
403
+ }
396
404
  /**
397
405
  * Queue a steering message to interrupt the agent mid-run.
398
406
  */
@@ -34,6 +34,12 @@ export declare function sessionAutoTitleError(value: unknown): string | undefine
34
34
  * for the app or chat prompt must not silently get the routing line.
35
35
  */
36
36
  export declare function sessionPromptSurfaceError(value: unknown): string | undefined;
37
+ /**
38
+ * Detail for an `open_session.browserEngine` the host refuses, or undefined when the value is absent
39
+ * or a known engine. An unknown value is never read as `none`: a client that asked for the user's
40
+ * own browser must not silently run without one.
41
+ */
42
+ export declare function sessionBrowserEngineError(value: unknown): string | undefined;
37
43
  export declare function rpcCommandShapeError(command: unknown): string | undefined;
38
44
  export declare function rpcMessageLengthError(command: unknown): string | undefined;
39
45
  //# sourceMappingURL=rpc-input-validation.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { isBrowserEngine } from "../../core/browser-engine.js";
1
2
  import { clientMessageIdentitySchema } from "../../core/client-message-identity.js";
2
3
  export const MAX_RPC_MESSAGE_CHARACTERS = 1_000_000;
3
4
  /**
@@ -150,6 +151,16 @@ export function sessionPromptSurfaceError(value) {
150
151
  return undefined;
151
152
  return `promptSurface must be "terminal", "app" or "chat".`;
152
153
  }
154
+ /**
155
+ * Detail for an `open_session.browserEngine` the host refuses, or undefined when the value is absent
156
+ * or a known engine. An unknown value is never read as `none`: a client that asked for the user's
157
+ * own browser must not silently run without one.
158
+ */
159
+ export function sessionBrowserEngineError(value) {
160
+ if (value === undefined || isBrowserEngine(value))
161
+ return undefined;
162
+ return `browserEngine must be "connected", "builtin" or "none".`;
163
+ }
153
164
  export function rpcCommandShapeError(command) {
154
165
  if (typeof command !== "object" || command === null || Array.isArray(command)) {
155
166
  return "RPC command must be a JSON object.";
@@ -10,6 +10,7 @@ import type { SessionRuntimeKind } from "../../cli/args.ts";
10
10
  import type { AgentAbortSource } from "../../core/agent-abort-provenance.ts";
11
11
  import type { PromptDisposition, QueuedInput, QueuedInputDisposition, SessionStats } from "../../core/agent-session.ts";
12
12
  import type { BashResult } from "../../core/bash-executor.ts";
13
+ import type { BrowserEngine } from "../../core/browser-engine.ts";
13
14
  import type { ClientMessageIdentity } from "../../core/client-message-identity.ts";
14
15
  import type { CompactionResult } from "../../core/compaction/index.ts";
15
16
  import type { PromptSurface } from "../../core/dynamic-prompt/types.ts";
@@ -45,6 +46,9 @@ type RpcSessionCommand = {
45
46
  details?: unknown;
46
47
  triggerTurn?: boolean;
47
48
  deliverAs?: "steer" | "followUp" | "nextTurn";
49
+ } | {
50
+ id?: string;
51
+ type: "continue_from_leaf";
48
52
  } | {
49
53
  id?: string;
50
54
  type: "append_user_message";
@@ -491,6 +495,15 @@ export type RpcCommand = (RpcSessionCommand & {
491
495
  * Requires the host capability `prompt_surface`. Any other value is refused with `invalid_launch_profile`.
492
496
  */
493
497
  promptSurface?: PromptSurface;
498
+ /**
499
+ * Which browser THIS session's skills drive: `connected` (the user's own browser), `builtin` (the
500
+ * app's in-app browser) or `none`. Session-scoped, never process-wide: the session's tool
501
+ * subprocesses and eval kernel see `OMO_BROWSER_ENGINE=<value>`, other sessions on the host do
502
+ * not, and a session opened without it sees no such variable. A later open that attaches with
503
+ * another value moves the live session to it; an attach without it keeps the current engine.
504
+ * Requires the host capability `browser_engine`. Any other value is refused with `invalid_launch_profile`.
505
+ */
506
+ browserEngine?: BrowserEngine;
494
507
  } | {
495
508
  id?: string;
496
509
  type: "close_session";
@@ -881,6 +894,11 @@ export type RpcResponse = {
881
894
  thinkingLevel: ThinkingLevel;
882
895
  isScoped: boolean;
883
896
  } | null;
897
+ } | {
898
+ id?: string;
899
+ type: "response";
900
+ command: "continue_from_leaf";
901
+ success: true;
884
902
  } | {
885
903
  id?: string;
886
904
  type: "response";
@@ -889,6 +907,7 @@ export type RpcResponse = {
889
907
  data: {
890
908
  models: Array<Model<any> & {
891
909
  supportedThinkingLevels: ThinkingLevel[];
910
+ supportsAssistantPrefill: boolean;
892
911
  }>;
893
912
  };
894
913
  } | {
@@ -2,10 +2,10 @@ import { VERSION } from "../../config.js";
2
2
  import { ACCEPT_EDITS_PERMISSION_PRESET_CAPABILITY } from "../../core/extensions/builtin/permission-system/config.js";
3
3
  import { DURABLE_CLIENT_MESSAGE_ID_CAPABILITY } from "./client-admission-record.js";
4
4
  import { buildRpcSessionState } from "./connection-handler.js";
5
- import { AUTO_TITLE_PER_SESSION_CAPABILITY, AUTO_TITLE_SESSIONS_CAPABILITY, DURABLE_SESSION_ID_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, PROMPT_SURFACE_CAPABILITY, PROMPT_SURFACE_CHAT_CAPABILITY, RETAIN_ON_DISCONNECT_CAPABILITY, SESSION_CONTEXT_CAPABILITY, SESSION_KIND_CAPABILITY, WARM_CAPABILITY, } from "./custom-capability.js";
5
+ import { AUTO_TITLE_PER_SESSION_CAPABILITY, AUTO_TITLE_SESSIONS_CAPABILITY, BROWSER_ENGINE_CAPABILITY, CONTINUE_FROM_LEAF_CAPABILITY, DURABLE_SESSION_ID_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, PROMPT_SURFACE_CAPABILITY, PROMPT_SURFACE_CHAT_CAPABILITY, RETAIN_ON_DISCONNECT_CAPABILITY, SESSION_CONTEXT_CAPABILITY, SESSION_KIND_CAPABILITY, WARM_CAPABILITY, } from "./custom-capability.js";
6
6
  import { answerWarm } from "./host-warm.js";
7
7
  import { protocolIdentity } from "./protocol-identity.js";
8
- import { sessionAutoTitleError, sessionContextError, sessionKindError, sessionPromptSurfaceError, } from "./rpc-input-validation.js";
8
+ import { sessionAutoTitleError, sessionBrowserEngineError, sessionContextError, sessionKindError, sessionPromptSurfaceError, } from "./rpc-input-validation.js";
9
9
  import { RPC_ERROR_INVALID_LAUNCH_PROFILE, RPC_ERROR_INVALID_SESSION_CONTEXT, RPC_ERROR_INVALID_SESSION_KIND, RPC_ERROR_MISSING_SESSION_ID, RPC_ERROR_OPEN_FAILED, RPC_ERROR_SESSION_CLOSING, RPC_ERROR_UNKNOWN_SESSION, } from "./rpc-types.js";
10
10
  import { runWithSessionAttribution } from "./session-attribution.js";
11
11
  import { createRpcSessionBinding } from "./session-binding.js";
@@ -13,6 +13,7 @@ import { readDrainVerdicts } from "./session-drain.js";
13
13
  import { RpcSessionRegistryError } from "./session-registry.js";
14
14
  import { releaseSession } from "./session-release.js";
15
15
  import { selectSweepEvictions } from "./session-sweep.js";
16
+ import { liveToolMediaScope, toolMediaPersister } from "./tool-media-store.js";
16
17
  /** How often a draining host re-checks whether the work it is waiting for has settled. */
17
18
  const DRAIN_SWEEP_MS = 50;
18
19
  function error(id, command, code, data) {
@@ -195,6 +196,7 @@ export class SessionCommandRouter {
195
196
  AUTO_TITLE_SESSIONS_CAPABILITY,
196
197
  MEDIA_PLACEHOLDERS_CAPABILITY,
197
198
  DURABLE_CLIENT_MESSAGE_ID_CAPABILITY,
199
+ CONTINUE_FROM_LEAF_CAPABILITY,
198
200
  // Host capabilities, not client opt-ins: only a multi-session host owns the
199
201
  // attachment refcount `open_session.retain_on_disconnect` detaches from, the
200
202
  // per-session launch profile `context`/`auto_title` travel on, and the session
@@ -209,6 +211,8 @@ export class SessionCommandRouter {
209
211
  // Every session's prompt is built from its own launch profile, so one host serves both surfaces.
210
212
  PROMPT_SURFACE_CAPABILITY,
211
213
  PROMPT_SURFACE_CHAT_CAPABILITY,
214
+ // Each session's tool subprocesses and eval kernel get its own OMO_BROWSER_ENGINE from its launch profile.
215
+ BROWSER_ENGINE_CAPABILITY,
212
216
  ACCEPT_EDITS_PERMISSION_PRESET_CAPABILITY,
213
217
  // Only an in-process runtime shares the loop a warm loads into (senpi#2314).
214
218
  ...(this.registry.warm ? [WARM_CAPABILITY] : []),
@@ -479,6 +483,9 @@ export class SessionCommandRouter {
479
483
  const promptSurfaceError = sessionPromptSurfaceError(command.promptSurface);
480
484
  if (promptSurfaceError)
481
485
  return error(command.id, "open_session", `${RPC_ERROR_INVALID_LAUNCH_PROFILE}: ${promptSurfaceError}`);
486
+ const browserEngineError = sessionBrowserEngineError(command.browserEngine);
487
+ if (browserEngineError)
488
+ return error(command.id, "open_session", `${RPC_ERROR_INVALID_LAUNCH_PROFILE}: ${browserEngineError}`);
482
489
  let opened;
483
490
  try {
484
491
  opened = await this.registry.openSession({
@@ -496,12 +503,14 @@ export class SessionCommandRouter {
496
503
  ...(command.durableSessionId !== undefined ? { durableSessionId: command.durableSessionId } : {}),
497
504
  ...(typeof command.auto_title === "boolean" ? { autoTitle: command.auto_title } : {}),
498
505
  ...(command.promptSurface !== undefined ? { promptSurface: command.promptSurface } : {}),
506
+ ...(command.browserEngine !== undefined ? { browserEngine: command.browserEngine } : {}),
499
507
  },
500
508
  // Host lifecycle policy, deliberately outside the immutable launch profile.
501
509
  { retainOnDisconnect: command.retain_on_disconnect === true });
502
510
  const openedSession = opened;
503
511
  const entry = this.registry.getForCommand(openedSession.sessionId, "open_session");
504
512
  this.writer.setSessionKind(openedSession.sessionId, entry.kind);
513
+ this.writer.setSessionMedia(openedSession.sessionId, toolMediaPersister(() => liveToolMediaScope(entry)));
505
514
  if (owner !== undefined) {
506
515
  if (!this.writer.hasRegisteredConnectionCapabilities(owner))
507
516
  this.writer.setConnectionCapabilities(owner, this.pendingCapabilities.get(owner) ?? this.connectionOptions?.capabilities ?? []);
@@ -1,4 +1,5 @@
1
1
  import type { SessionKind } from "../../core/extensions/types.ts";
2
+ import { type MediaPersister } from "./media-placeholders.ts";
2
3
  import type { RpcHostLifecycleEvent, RpcOpenQueuedEvent, RpcSessionClosedReason } from "./rpc-types.ts";
3
4
  import { type SessionEventWriterConnection } from "./session-event-fanout.ts";
4
5
  export type { SessionEventWriterConnection } from "./session-event-fanout.ts";
@@ -26,6 +27,8 @@ export declare class SessionEventWriter {
26
27
  /** Sessions whose lifecycle records stay on their attached connections (`kind: "worker"`). */
27
28
  private readonly workerSessions;
28
29
  private readonly openTurns;
30
+ /** Per-session image persisters: bytes reach disk before the placeholder that names them is emitted. */
31
+ private readonly mediaPersisters;
29
32
  private readonly writeRaw;
30
33
  private readonly waitForBackpressure?;
31
34
  private readonly scheduleFlush;
@@ -61,6 +64,7 @@ export declare class SessionEventWriter {
61
64
  * instead of broadcast; an interactive session keeps the broadcast every client (the
62
65
  * desktop mirror, the supervisor's idle observer) relies on.
63
66
  */
67
+ setSessionMedia(sessionId: string, persister: MediaPersister | undefined): void;
64
68
  setSessionKind(sessionId: string, kind: SessionKind): void;
65
69
  /** Drain just this connection before closing it after its last handoff park. */
66
70
  flushConnection(id: string): Promise<void>;
@@ -67,6 +67,8 @@ export class SessionEventWriter {
67
67
  /** Sessions whose lifecycle records stay on their attached connections (`kind: "worker"`). */
68
68
  this.workerSessions = new Set();
69
69
  this.openTurns = new SessionOpenTurns();
70
+ /** Per-session image persisters: bytes reach disk before the placeholder that names them is emitted. */
71
+ this.mediaPersisters = new Map();
70
72
  this.flushScheduled = false;
71
73
  this.controlOverflowReported = false;
72
74
  this.closeOverflowReported = false;
@@ -129,6 +131,12 @@ export class SessionEventWriter {
129
131
  * instead of broadcast; an interactive session keeps the broadcast every client (the
130
132
  * desktop mirror, the supervisor's idle observer) relies on.
131
133
  */
134
+ setSessionMedia(sessionId, persister) {
135
+ if (persister === undefined)
136
+ this.mediaPersisters.delete(sessionId);
137
+ else
138
+ this.mediaPersisters.set(sessionId, persister);
139
+ }
132
140
  setSessionKind(sessionId, kind) {
133
141
  if (kind === "worker")
134
142
  this.workerSessions.add(sessionId);
@@ -175,7 +183,7 @@ export class SessionEventWriter {
175
183
  // A record is only walked and re-serialized when a target asked for placeholders;
176
184
  // otherwise this is byte-for-byte today's path, with serializeJsonLine called once.
177
185
  const hasPlaceholderTarget = targets.some((target) => this.fanout.connectionHas(target, MEDIA_PLACEHOLDERS_CAPABILITY));
178
- const redacted = hasPlaceholderTarget ? omitInlineMedia(tagged) : tagged;
186
+ const redacted = hasPlaceholderTarget ? omitInlineMedia(tagged, this.mediaPersisters.get(sessionId)) : tagged;
179
187
  const placeholderLine = redacted === tagged ? undefined : serializeJsonLine(redacted);
180
188
  if (!isTargeted)
181
189
  this.fanout.rememberSnapshot(sessionId, tagged, line, placeholderLine, tagged);
@@ -337,6 +345,7 @@ export class SessionEventWriter {
337
345
  return;
338
346
  this.sealedSessions.add(sessionId);
339
347
  this.fanout.forgetSession(sessionId);
348
+ this.mediaPersisters.delete(sessionId);
340
349
  const targetId = this.connectionContext.getStore();
341
350
  // `reason` tells an attached client WHY the handle ended, so a park it can reopen by path is
342
351
  // not read as a session that is gone. Absent unless the caller names one; clients tolerate that.
@@ -388,6 +397,7 @@ export class SessionEventWriter {
388
397
  return;
389
398
  this.sealedSessions.add(sessionId);
390
399
  this.fanout.forgetSession(sessionId);
400
+ this.mediaPersisters.delete(sessionId);
391
401
  if (this.fanout.isEmpty())
392
402
  this.appendSessionRecord(sessionId, lifecycle);
393
403
  else if (this.workerSessions.has(sessionId))
@@ -491,6 +501,7 @@ export class SessionEventWriter {
491
501
  this.openTurns.take(sessionId);
492
502
  this.workerSessions.delete(sessionId);
493
503
  this.fanout.forgetSession(sessionId);
504
+ this.mediaPersisters.delete(sessionId);
494
505
  }
495
506
  /** Drain every retained lane and the current in-flight record. */
496
507
  flush() {
@@ -21,6 +21,10 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
21
21
  entry.profile = frozenProfile({ ...entry.profile, promptSurface: profile.promptSurface });
22
22
  entry.runtime?.setPromptSurface(profile.promptSurface);
23
23
  }
24
+ if (profile.browserEngine !== undefined && profile.browserEngine !== entry.profile.browserEngine) {
25
+ entry.profile = frozenProfile({ ...entry.profile, browserEngine: profile.browserEngine });
26
+ entry.runtime?.setBrowserEngine(profile.browserEngine);
27
+ }
24
28
  entry.lastCommandAt = now;
25
29
  if (wasParked) {
26
30
  entry.lifecycleMutex = entry.lifecycleMutex.then(() => entry.runtime?.emitAttachmentEvent("session_resumed"));
@@ -1,4 +1,5 @@
1
1
  import { Worker } from "node:worker_threads";
2
+ import type { BrowserEngine } from "../../core/browser-engine.ts";
2
3
  import type { PromptSurface } from "../../core/dynamic-prompt/types.ts";
3
4
  import type { CliRuntimeConfiguration } from "../../main.ts";
4
5
  import type { RpcConnectionOptions } from "./connection-handler.ts";
@@ -37,6 +38,8 @@ export declare class SessionWorkerClient {
37
38
  prepare(configuration: CliRuntimeConfiguration, profile: RpcSessionLaunchProfile): Promise<string>;
38
39
  /** Moves the worker's live session to another prompt surface (a later `open_session.promptSurface`). */
39
40
  setPromptSurface(surface: PromptSurface): Promise<void>;
41
+ /** Moves the worker's live session to another browser engine (a later `open_session.browserEngine`). */
42
+ setBrowserEngine(engine: BrowserEngine): Promise<void>;
40
43
  commit(): Promise<WorkerSnapshot>;
41
44
  bind(sessionId: string, writer: SessionEventWriter, requestClose: () => void, options: Pick<RpcConnectionOptions, "capabilities" | "clientInfo">): Promise<RpcSessionBinding>;
42
45
  get busy(): boolean;
@@ -54,6 +54,12 @@ export class SessionWorkerClient {
54
54
  if (result.type !== "result")
55
55
  throw new Error("Invalid worker prompt_surface response");
56
56
  }
57
+ /** Moves the worker's live session to another browser engine (a later `open_session.browserEngine`). */
58
+ async setBrowserEngine(engine) {
59
+ const result = await this.request({ type: "browser_engine", engine });
60
+ if (result.type !== "result")
61
+ throw new Error("Invalid worker browser_engine response");
62
+ }
57
63
  async commit() {
58
64
  const result = await this.request({ type: "commit" });
59
65
  if (result.type !== "ready")
@@ -1,3 +1,4 @@
1
+ import type { BrowserEngine } from "../../core/browser-engine.ts";
1
2
  import type { PromptSurface } from "../../core/dynamic-prompt/types.ts";
2
3
  import type { CliRuntimeConfiguration } from "../../main.ts";
3
4
  import type { RpcSessionState } from "./rpc-types.ts";
@@ -55,6 +56,10 @@ export type HostToSessionWorker = {
55
56
  type: "prompt_surface";
56
57
  request: number;
57
58
  surface: PromptSurface;
59
+ } | {
60
+ type: "browser_engine";
61
+ request: number;
62
+ engine: BrowserEngine;
58
63
  } | {
59
64
  type: "cancel_ui";
60
65
  } | {
@@ -177,6 +177,12 @@ async function handle(message) {
177
177
  entry.runtime.setPromptSurface(message.surface);
178
178
  send({ type: "result", request: message.request });
179
179
  return;
180
+ case "browser_engine":
181
+ if (!entry?.runtime)
182
+ throw new Error("session_closing");
183
+ entry.runtime.setBrowserEngine(message.engine);
184
+ send({ type: "result", request: message.request });
185
+ return;
180
186
  case "cancel_ui":
181
187
  binding?.cancelPendingExtensionUiRequests?.();
182
188
  send({ type: "control_done", control: "cancel_ui" });
@@ -0,0 +1,59 @@
1
+ import { type MediaPersister, type PersistedMedia } from "./media-placeholders.ts";
2
+ /**
3
+ * Where a session keeps the images its tools produced.
4
+ *
5
+ * A `media_placeholders` client never receives the bytes on the socket. The host writes them
6
+ * here, once, BEFORE the placeholder that names the file is emitted, so a client can render the
7
+ * picture from the path whenever it likes: after a disconnect, an idle shutdown or a generation
8
+ * handover, because the files outlive every connection. The directory sits next to the session
9
+ * files, never under the user's project, and is removed with the durable session.
10
+ */
11
+ export interface ToolMediaScope {
12
+ readonly sessionDir: string;
13
+ readonly durableSessionId: string;
14
+ }
15
+ export declare const MAX_TOOL_IMAGE_BYTES: number;
16
+ export declare const MAX_SESSION_MEDIA_BYTES: number;
17
+ export interface ToolMediaLimits {
18
+ readonly perImage: number;
19
+ readonly perSession: number;
20
+ }
21
+ export interface LiveSessionSource {
22
+ readonly runtime?: {
23
+ readonly session: {
24
+ readonly sessionManager: {
25
+ getSessionFile(): string | undefined;
26
+ getSessionId(): string;
27
+ };
28
+ };
29
+ };
30
+ readonly worker?: {
31
+ readonly snapshot?: {
32
+ readonly state: {
33
+ readonly sessionFile?: string;
34
+ readonly sessionId: string;
35
+ };
36
+ };
37
+ };
38
+ }
39
+ export declare function liveToolMediaScope(entry: LiveSessionSource): ToolMediaScope | undefined;
40
+ export declare function toolMediaRoot(scope: ToolMediaScope): string;
41
+ /**
42
+ * Persist one tool-result image and report where it lives, or why it does not.
43
+ *
44
+ * Layout: `<sessionDir>/media/<durableSessionId>/<sha256(toolCallId)>/<contentIndex>-<sha256(bytes)>.<ext>`.
45
+ * The content hash makes a file immutable and a repeat a no-op; the per-image and per-session
46
+ * limits reject new storage and never evict a file a transcript may still reference.
47
+ */
48
+ export declare function persistToolImage(scope: ToolMediaScope, ref: {
49
+ readonly toolCallId: string;
50
+ readonly contentIndex: number;
51
+ }, block: {
52
+ readonly data: string;
53
+ readonly mimeType?: string;
54
+ }, limits?: ToolMediaLimits): PersistedMedia;
55
+ export declare function toolMediaPersister(scope: () => ToolMediaScope | undefined): MediaPersister;
56
+ export declare function removeToolMedia(scope: ToolMediaScope): void;
57
+ /** The media scope of the session stored in `sessionFile`; a file with no readable header has none. */
58
+ export declare function toolMediaScopeOfSessionFile(sessionFile: string): Promise<ToolMediaScope | undefined>;
59
+ //# sourceMappingURL=tool-media-store.d.ts.map
@@ -0,0 +1,190 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { assertValidSessionId } from "../../core/session-manager.js";
6
+ import { base64ByteLength } from "./media-placeholders.js";
7
+ export const MAX_TOOL_IMAGE_BYTES = 20 * 1024 * 1024;
8
+ export const MAX_SESSION_MEDIA_BYTES = 256 * 1024 * 1024;
9
+ const DEFAULT_LIMITS = { perImage: MAX_TOOL_IMAGE_BYTES, perSession: MAX_SESSION_MEDIA_BYTES };
10
+ const MEDIA_DIRECTORY = "media";
11
+ const EXTENSIONS = {
12
+ "image/png": "png",
13
+ "image/jpeg": "jpg",
14
+ "image/gif": "gif",
15
+ "image/webp": "webp",
16
+ };
17
+ /** Bytes already stored per media root: the reservation every concurrent write is checked against. */
18
+ const reserved = new Map();
19
+ const SIGNATURES = {
20
+ "image/png": (bytes) => bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
21
+ "image/jpeg": (bytes) => bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])),
22
+ "image/gif": (bytes) => bytes.subarray(0, 4).toString("latin1") === "GIF8",
23
+ "image/webp": (bytes) => bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP",
24
+ };
25
+ function assertRealDirectoryChain(root, callDirectory) {
26
+ for (const path of [dirname(root), root, callDirectory]) {
27
+ let stat;
28
+ try {
29
+ stat = lstatSync(path);
30
+ }
31
+ catch {
32
+ continue;
33
+ }
34
+ if (!stat.isDirectory())
35
+ throw new Error(`not a real directory: ${path}`);
36
+ }
37
+ }
38
+ /** Outcomes per live image block, so re-emitting a record (`get_messages`, `turn_end`) never rehashes it. */
39
+ const outcomes = new WeakMap();
40
+ export function liveToolMediaScope(entry) {
41
+ const manager = entry.runtime?.session.sessionManager;
42
+ const sessionFile = manager?.getSessionFile() ?? entry.worker?.snapshot?.state.sessionFile;
43
+ const durableSessionId = manager?.getSessionId() ?? entry.worker?.snapshot?.state.sessionId;
44
+ return sessionFile === undefined || durableSessionId === undefined
45
+ ? undefined
46
+ : { sessionDir: dirname(sessionFile), durableSessionId };
47
+ }
48
+ export function toolMediaRoot(scope) {
49
+ assertValidSessionId(scope.durableSessionId);
50
+ return join(scope.sessionDir, MEDIA_DIRECTORY, scope.durableSessionId);
51
+ }
52
+ function directoryBytes(directory) {
53
+ let total = 0;
54
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
55
+ const path = join(directory, entry.name);
56
+ total += entry.isDirectory() ? directoryBytes(path) : statSync(path).size;
57
+ }
58
+ return total;
59
+ }
60
+ function storedBytes(root) {
61
+ const known = reserved.get(root);
62
+ if (known !== undefined)
63
+ return known;
64
+ const scanned = existsSync(root) ? directoryBytes(root) : 0;
65
+ reserved.set(root, scanned);
66
+ return scanned;
67
+ }
68
+ const unavailable = (reason) => ({
69
+ unavailableReason: reason,
70
+ });
71
+ function writeImage(root, path, bytes) {
72
+ const callDirectory = dirname(path);
73
+ mkdirSync(callDirectory, { recursive: true, mode: 0o700 });
74
+ assertRealDirectoryChain(root, callDirectory);
75
+ const staging = `${path}.${randomBytes(6).toString("hex")}.tmp`;
76
+ try {
77
+ writeFileSync(staging, bytes, { mode: 0o600, flag: "wx" });
78
+ renameSync(staging, path);
79
+ chmodSync(path, 0o400);
80
+ }
81
+ catch (cause) {
82
+ rmSync(staging, { force: true });
83
+ throw cause;
84
+ }
85
+ }
86
+ /**
87
+ * Persist one tool-result image and report where it lives, or why it does not.
88
+ *
89
+ * Layout: `<sessionDir>/media/<durableSessionId>/<sha256(toolCallId)>/<contentIndex>-<sha256(bytes)>.<ext>`.
90
+ * The content hash makes a file immutable and a repeat a no-op; the per-image and per-session
91
+ * limits reject new storage and never evict a file a transcript may still reference.
92
+ */
93
+ export function persistToolImage(scope, ref, block, limits = DEFAULT_LIMITS) {
94
+ const known = outcomes.get(block);
95
+ if (known !== undefined)
96
+ return known;
97
+ const outcome = store(scope, ref, block, limits);
98
+ if (!("unavailableReason" in outcome) || outcome.unavailableReason !== "storage_error")
99
+ outcomes.set(block, outcome);
100
+ return outcome;
101
+ }
102
+ function store(scope, ref, block, limits) {
103
+ const extension = EXTENSIONS[(block.mimeType ?? "").toLowerCase()];
104
+ if (extension === undefined)
105
+ return unavailable("storage_error");
106
+ if (base64ByteLength(block.data) > limits.perImage)
107
+ return unavailable("image_too_large");
108
+ const bytes = Buffer.from(block.data, "base64");
109
+ if (bytes.length > limits.perImage)
110
+ return unavailable("image_too_large");
111
+ if (SIGNATURES[(block.mimeType ?? "").toLowerCase()]?.(bytes) !== true)
112
+ return unavailable("storage_error");
113
+ try {
114
+ const root = toolMediaRoot(scope);
115
+ const callDirectory = createHash("sha256").update(ref.toolCallId).digest("hex");
116
+ const digest = createHash("sha256").update(bytes).digest("hex");
117
+ const path = resolve(root, callDirectory, `${ref.contentIndex}-${digest}.${extension}`);
118
+ assertRealDirectoryChain(root, dirname(path));
119
+ if (existsSync(path))
120
+ return { path };
121
+ const used = storedBytes(root);
122
+ if (used + bytes.length > limits.perSession)
123
+ return unavailable("session_limit");
124
+ reserved.set(root, used + bytes.length);
125
+ try {
126
+ writeImage(root, path, bytes);
127
+ }
128
+ catch (cause) {
129
+ reserved.set(root, storedBytes(root) - bytes.length);
130
+ throw cause;
131
+ }
132
+ return { path };
133
+ }
134
+ catch {
135
+ return unavailable("storage_error");
136
+ }
137
+ }
138
+ export function toolMediaPersister(scope) {
139
+ return (ref, block) => {
140
+ const current = scope();
141
+ return current === undefined ? undefined : persistToolImage(current, ref, block);
142
+ };
143
+ }
144
+ export function removeToolMedia(scope) {
145
+ const root = toolMediaRoot(scope);
146
+ reserved.delete(root);
147
+ makeTreeWritable(root);
148
+ rmSync(root, { recursive: true, force: true });
149
+ }
150
+ function makeTreeWritable(path) {
151
+ let entries;
152
+ try {
153
+ if (!lstatSync(path).isDirectory())
154
+ return;
155
+ chmodSync(path, 0o700);
156
+ entries = readdirSync(path, { withFileTypes: true });
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ for (const entry of entries) {
162
+ const child = join(path, entry.name);
163
+ if (entry.isDirectory())
164
+ makeTreeWritable(child);
165
+ else if (entry.isFile())
166
+ try {
167
+ chmodSync(child, 0o600);
168
+ }
169
+ catch { }
170
+ }
171
+ }
172
+ /** The media scope of the session stored in `sessionFile`; a file with no readable header has none. */
173
+ export async function toolMediaScopeOfSessionFile(sessionFile) {
174
+ try {
175
+ const handle = await open(sessionFile, "r");
176
+ try {
177
+ const { buffer, bytesRead } = await handle.read(Buffer.alloc(4096), 0, 4096, 0);
178
+ const firstLine = buffer.subarray(0, bytesRead).toString("utf8").split("\n", 1)[0] ?? "";
179
+ const id = JSON.parse(firstLine).id;
180
+ return typeof id === "string" ? { sessionDir: dirname(sessionFile), durableSessionId: id } : undefined;
181
+ }
182
+ finally {
183
+ await handle.close();
184
+ }
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ }
190
+ //# sourceMappingURL=tool-media-store.js.map
@@ -51,7 +51,7 @@ export class WorkerSessionRegistry {
51
51
  const key = this.knownReservationKey(profile.sessionPath);
52
52
  const owner = key ? this.reservations.owner(key) : undefined;
53
53
  if (key && owner)
54
- return this.attach(owner, key, options, profile.promptSurface);
54
+ return this.attach(owner, key, options, profile);
55
55
  }
56
56
  if (this.size >= SESSION_WORKER_LIMITS.workers)
57
57
  throw new Error("too_many_sessions");
@@ -99,7 +99,7 @@ export class WorkerSessionRegistry {
99
99
  const path = await worker.prepare(this.options.configuration, profile);
100
100
  const owner = this.reservations.owner(path);
101
101
  if (owner) {
102
- const attached = await this.attach(owner, path, options, profile.promptSurface);
102
+ const attached = await this.attach(owner, path, options, profile);
103
103
  entry.state = "quarantined";
104
104
  worker.quarantine();
105
105
  return attached;
@@ -228,7 +228,8 @@ export class WorkerSessionRegistry {
228
228
  }
229
229
  return undefined;
230
230
  }
231
- async attach(owner, path, options, promptSurface) {
231
+ async attach(owner, path, options, requested = { cwd: "" }) {
232
+ const { promptSurface, browserEngine } = requested;
232
233
  const entry = this.entries.get(owner);
233
234
  if (entry?.state !== "open" || !entry.worker?.bindingReady || entry.worker.snapshot?.sessionPath !== path)
234
235
  throw new RpcSessionRegistryError("session_path_in_use");
@@ -237,6 +238,10 @@ export class WorkerSessionRegistry {
237
238
  entry.profile = frozenProfile({ ...entry.profile, promptSurface });
238
239
  await entry.worker.setPromptSurface(promptSurface);
239
240
  }
241
+ if (browserEngine !== undefined && browserEngine !== entry.profile.browserEngine) {
242
+ entry.profile = frozenProfile({ ...entry.profile, browserEngine });
243
+ await entry.worker.setBrowserEngine(browserEngine);
244
+ }
240
245
  const result = this.openResult(owner, entry);
241
246
  entry.attachments++;
242
247
  // Retention is a property of the live session: any attach may ask for it, and
@@ -30,6 +30,7 @@ Commands run by the `bash` and `powershell` tools receive the current Pi session
30
30
  | `PI_PROVIDER` | Currently selected model provider |
31
31
  | `PI_MODEL` | Currently selected model ID |
32
32
  | `PI_REASONING_LEVEL` | Current effective reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` |
33
+ | `OMO_BROWSER_ENGINE` | Browser the opener chose for this session: `connected` (the user's own browser), `builtin` (the app's in-app browser) or `none`. Set only for a session opened with `open_session.browserEngine`; a session that chose none never sees the variable, even if the host process has one |
33
34
 
34
35
  The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next shell command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally.
35
36