@code-yeongyu/senpi 2026.10.10-4 → 2026.10.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 (59) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/bundle/chunks/{app-server-command-N3L2LNP7.js → app-server-command-6FTH3TK6.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-H4UIUFU5.js → chunk-2PUAD5TD.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-MP2DMTI7.js → chunk-2UUTN6QT.js} +79 -79
  5. package/dist/bundle/chunks/{chunk-3A3C5VVO.js → chunk-3OGEJMZ7.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-J5MUMVWB.js → chunk-64ZVBCTL.js} +8 -7
  7. package/dist/bundle/chunks/chunk-TIMLYJMK.js +14 -0
  8. package/dist/bundle/chunks/{chunk-Z7MMKKIY.js → chunk-TZVDXVHB.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-7GVD54RM.js → chunk-U3IL5N76.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-H67EVDWG.js → chunk-URQSNRHZ.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-M2IHGL3M.js → chunk-VW74BGIC.js} +1 -1
  12. package/dist/bundle/chunks/chunk-YG5UO3E4.js +7 -0
  13. package/dist/bundle/chunks/{cli-main-CHDR33FZ.js → cli-main-UYTUBG64.js} +1 -1
  14. package/dist/bundle/chunks/{host-command-VATE7HRG.js → host-command-TOVYSHYH.js} +1 -1
  15. package/dist/bundle/chunks/{host-lifecycle-GC3GIR2N.js → host-lifecycle-5AV4JXDQ.js} +1 -1
  16. package/dist/bundle/chunks/{interactive-mode-RVEM62WX.js → interactive-mode-KJIOCRS7.js} +1 -1
  17. package/dist/bundle/chunks/{list-tips-IIRFXBTO.js → list-tips-BXAA4FG5.js} +1 -1
  18. package/dist/bundle/chunks/{multi-session-host-CFND7CXH.js → multi-session-host-TEFLJTTA.js} +5 -5
  19. package/dist/bundle/chunks/{package-manager-cli-NWNECBF7.js → package-manager-cli-QSJSIHUR.js} +1 -1
  20. package/dist/bundle/chunks/{rpc-mode-JQFUDLNB.js → rpc-mode-RUKK7VBF.js} +1 -1
  21. package/dist/bundle/chunks/{schedule-command-EQ4PQCRP.js → schedule-command-K6CTAB4T.js} +1 -1
  22. package/dist/bundle/chunks/{session-control-endpoint-HMB5T5VD.js → session-control-endpoint-GZDV2FOZ.js} +1 -1
  23. package/dist/bundle/chunks/{session-picker-O7OESJWU.js → session-picker-6D4LC3KR.js} +1 -1
  24. package/dist/bundle/chunks/session-worker.js +144 -143
  25. package/dist/bundle/cli.js +1 -1
  26. package/dist/bundle/index.js +1 -1
  27. package/dist/bundle/rpc-entry.js +1 -1
  28. package/dist/bundle/runtime-manifest.json +1 -1
  29. package/dist/core/agent-session-runtime.d.ts +6 -0
  30. package/dist/core/agent-session-runtime.js +18 -0
  31. package/dist/core/agent-session.js +5 -2
  32. package/dist/core/extensions/builtin/html-render/bootstrap.d.ts +15 -0
  33. package/dist/core/extensions/builtin/html-render/bootstrap.js +123 -0
  34. package/dist/core/extensions/builtin/html-render/images.d.ts +16 -0
  35. package/dist/core/extensions/builtin/html-render/images.js +155 -0
  36. package/dist/core/extensions/builtin/html-render/index.d.ts +3 -0
  37. package/dist/core/extensions/builtin/html-render/index.js +5 -0
  38. package/dist/core/extensions/builtin/html-render/tool.d.ts +19 -0
  39. package/dist/core/extensions/builtin/html-render/tool.js +74 -0
  40. package/dist/core/extensions/builtin/index.js +4 -0
  41. package/dist/core/extensions/builtin/permission-system/index.js +40 -14
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/modes/interactive/interactive-mode.js +21 -4
  45. package/dist/modes/rpc/host-reservations.d.ts +1 -1
  46. package/dist/modes/rpc/host-reservations.js +9 -3
  47. package/dist/modes/rpc/session-registry-attach.js +8 -2
  48. package/dist/modes/rpc/session-registry.js +5 -7
  49. package/dist/modes/rpc/session-teardown.d.ts +6 -1
  50. package/dist/modes/rpc/session-teardown.js +26 -7
  51. package/dist/modes/rpc/session-worker-client.d.ts +2 -0
  52. package/dist/modes/rpc/session-worker-client.js +6 -0
  53. package/dist/modes/rpc/session-worker-protocol.d.ts +4 -0
  54. package/dist/modes/rpc/session-worker.js +6 -0
  55. package/dist/modes/rpc/worker-session-registry.js +6 -1
  56. package/docs/rpc.md +14 -4
  57. package/package.json +6 -6
  58. package/dist/bundle/chunks/chunk-RADHRQ4K.js +0 -7
  59. package/dist/bundle/chunks/chunk-USRAHAW4.js +0 -14
@@ -0,0 +1,74 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Type } from "typebox";
4
+ import { defineTool } from "../../types.js";
5
+ import { injectHtmlRenderBootstrap } from "./bootstrap.js";
6
+ import { inlineLocalImages } from "./images.js";
7
+ // The same input limit as the desktop's html_render. Images belong as absolute
8
+ // file paths, which are inlined after the check, so the document itself stays
9
+ // small: it travels in the tool call and again in the result details.
10
+ const MAX_HTML_CHARACTERS = 512_000;
11
+ const Params = Type.Object({
12
+ html: Type.String({
13
+ description: "A complete, self-contained HTML document.",
14
+ minLength: 1,
15
+ maxLength: MAX_HTML_CHARACTERS,
16
+ }),
17
+ title: Type.String({ description: "Short name for the page.", minLength: 1, maxLength: 200 }),
18
+ height: Type.Optional(Type.Integer({
19
+ description: "Frame height hint in CSS pixels, 80-2000. Defaults to the page's natural height.",
20
+ })),
21
+ });
22
+ const OUTPUT_DIR = ".senpi/html-pages";
23
+ const clampHeight = (height) => height === undefined ? undefined : Math.min(2000, Math.max(80, Math.round(height)));
24
+ export const showHtmlPageTool = defineTool({
25
+ name: "show_html_page",
26
+ label: "Show HTML Page",
27
+ description: "Show a finished self-contained HTML page (chart, table, diagram, mockup) to the reader. " +
28
+ "No network: inline every script, style and image (data: URIs). A <script src>, stylesheet link, font, image " +
29
+ "or fetch that points at a URL (a CDN included) is refused, and the page comes out blank or broken. " +
30
+ "Local images written as absolute file paths " +
31
+ "are inlined automatically after a byte check. In the desktop thread this renders inline above your " +
32
+ "reply; elsewhere it is written to a file you can open in the desktop.",
33
+ promptSnippet: "show_html_page: show a self-contained HTML page (chart/table/diagram) to the reader",
34
+ promptGuidelines: [
35
+ "No network: inline every script, style and image (data: URIs); a CDN <script src> or remote font is refused and leaves the page blank.",
36
+ "Absolute-path local images are inlined after a magic-byte check; a renamed non-image is refused.",
37
+ ],
38
+ parameters: Params,
39
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
40
+ const inlined = await inlineLocalImages(params.html);
41
+ const prepared = injectHtmlRenderBootstrap(inlined.html);
42
+ const height = clampHeight(params.height);
43
+ const dir = join(ctx.cwd, OUTPUT_DIR);
44
+ await mkdir(dir, { recursive: true });
45
+ const safeName = params.title
46
+ .replace(/[\\/:*?"<>|\p{Cc}]+/gu, " ")
47
+ .replace(/\s+/g, " ")
48
+ .trim()
49
+ .slice(0, 60) || "page";
50
+ const path = join(dir, `${safeName}-${Date.now().toString(36)}.html`);
51
+ await writeFile(path, prepared, "utf8");
52
+ const missing = inlined.missing.length === 0 ? undefined : inlined.missing;
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: `Wrote the page to ${path}. Open it in the desktop to see it rendered with your theme. ` +
58
+ (missing === undefined
59
+ ? "Local images were inlined."
60
+ : `Some local images could not be read and were left as written: ${missing.join(", ")}.`),
61
+ },
62
+ ],
63
+ details: {
64
+ path,
65
+ title: params.title,
66
+ height,
67
+ prepared: true,
68
+ ...(missing ? { missingImages: missing } : {}),
69
+ html: params.html,
70
+ },
71
+ };
72
+ },
73
+ });
74
+ //# sourceMappingURL=tool.js.map
@@ -19,6 +19,7 @@ import helpExtension from "./help/index.js";
19
19
  import herdrExtension from "./herdr/index.js";
20
20
  import historySearchExtension from "./history-search/index.js";
21
21
  import hooksExtension from "./hooks/index.js";
22
+ import htmlRenderExtension from "./html-render/index.js";
22
23
  import imageGenExtension from "./imagegen/index.js";
23
24
  import importReproExtension from "./import-repro.js";
24
25
  import lookAtExtension from "./look-at/index.js";
@@ -103,6 +104,9 @@ export const builtinExtensions = [
103
104
  { id: "cache-keepalive", factory: cacheKeepAliveExtension },
104
105
  { id: "ttsr", factory: ttsrExtension },
105
106
  { id: "btw", factory: btwExtension },
107
+ // HTML page rendering for standalone senpi (TUI/local); the desktop thread reaches the
108
+ // same capability through the desktop's MCP server instead.
109
+ { id: "html-render", factory: htmlRenderExtension },
106
110
  // Provider-neutral account listing; sits before the provider lanes so their
107
111
  // dedicated commands (claude-account, cursor accounts) keep their own names.
108
112
  { id: "account", factory: accountExtension },
@@ -52,6 +52,8 @@ export default function permissionSystemExtension(pi) {
52
52
  let activePreset = null;
53
53
  let initialApprovedCount = 0;
54
54
  let setupError = null;
55
+ // The `permission-preset` flag value the rules were last loaded from; absent until session_start.
56
+ let loadedPresetFlag;
55
57
  const nextRequestID = createRequestIDFactory();
56
58
  pi.registerFlag("permission", {
57
59
  description: "Set permission rules (format: tool=action or tool:pattern=action)",
@@ -74,10 +76,12 @@ export default function permissionSystemExtension(pi) {
74
76
  }
75
77
  applyToolDenials();
76
78
  });
77
- const loadPermissionRules = (cwd) => {
79
+ // `sessionApproved` carries this session's "Always" answers, not yet written to disk, across a reload.
80
+ const loadPermissionRules = (cwd, sessionApproved = []) => {
78
81
  const settingsManager = SettingsManager.create(cwd);
79
82
  const permissionFlag = pi.getFlag("permission");
80
83
  const permissionPresetFlag = pi.getFlag("permission-preset");
84
+ loadedPresetFlag = { value: permissionPresetFlag };
81
85
  cliRuleset = typeof permissionFlag === "string" ? parsePermissionFlag(permissionFlag) : [];
82
86
  const cliPreset = typeof permissionPresetFlag === "string" ? parsePermissionPresetFlag(permissionPresetFlag) : undefined;
83
87
  if (typeof permissionPresetFlag === "string" && !cliPreset) {
@@ -88,22 +92,44 @@ export default function permissionSystemExtension(pi) {
88
92
  activePreset = loadedSettings.preset;
89
93
  const approved = loadedSettings.approved;
90
94
  parserRegistry = createBuiltinParserRegistry();
91
- service = new PermissionService(staticRuleset, approved, createEventEmitter(pi));
95
+ service = new PermissionService(staticRuleset, [...approved, ...sessionApproved], createEventEmitter(pi));
92
96
  initialApprovedCount = approved.length;
93
97
  };
94
- const applyToolDenials = () => {
98
+ const applyToolDenials = (onlyWhenNarrowed = false) => {
95
99
  const allTools = pi.getAllTools().map((tool) => tool.name);
96
100
  const disabledTools = disabled(allTools, staticRuleset);
97
- const activeTools = pi
98
- .getActiveTools()
99
- .filter((toolName) => INTERNAL_PERMISSION_TOOLS.has(toolName) || !disabledTools.has(toolName));
101
+ const currentTools = pi.getActiveTools();
102
+ const activeTools = currentTools.filter((toolName) => INTERNAL_PERMISSION_TOOLS.has(toolName) || !disabledTools.has(toolName));
103
+ if (onlyWhenNarrowed && activeTools.length === currentTools.length)
104
+ return;
100
105
  pi.setActiveTools(activeTools);
101
106
  };
107
+ // A host moves a live session to another preset (an attach naming one) by changing the flag;
108
+ // the rules follow from the next tool call on, and a failed reload refuses calls like session_start.
109
+ const followPresetFlag = (cwd) => {
110
+ if (loadedPresetFlag === undefined || loadedPresetFlag.value === pi.getFlag("permission-preset"))
111
+ return;
112
+ const sessionApproved = service ? service.getApproved().slice(initialApprovedCount) : [];
113
+ setupError = null;
114
+ try {
115
+ loadPermissionRules(cwd, sessionApproved);
116
+ }
117
+ catch (error) {
118
+ setupError = getReason(error);
119
+ return;
120
+ }
121
+ applyToolDenials(true);
122
+ };
102
123
  pi.on("tool_call", async (event, ctx) => {
124
+ followPresetFlag(ctx.cwd);
103
125
  if (setupError !== null) {
104
126
  return { block: true, reason: `Permission setup failed: ${setupError}` };
105
127
  }
106
- if (!service || !parserRegistry) {
128
+ // Pinned for this call: a preset change while it waits on a prompt must not move its reply
129
+ // to another service or its decision to another preset.
130
+ const activeService = service;
131
+ const callPreset = activePreset;
132
+ if (!activeService || !parserRegistry) {
107
133
  return undefined;
108
134
  }
109
135
  const toolOwnedRequests = parserRegistry.has(event.toolName)
@@ -130,18 +156,18 @@ export default function permissionSystemExtension(pi) {
130
156
  ...(event.parentToolCallId === undefined ? {} : { parentCallID: event.parentToolCallId }),
131
157
  },
132
158
  };
133
- const auto = activePreset === "auto"
159
+ const auto = callPreset === "auto"
134
160
  ? await decideAuto(event.toolName, event.input, permissionRequest, ctx.cwd)
135
161
  : undefined;
136
- const askResultPromise = service
162
+ const askResultPromise = activeService
137
163
  .ask(request, {
138
164
  autoApproveAsk: permissionRequest.autoApproveAsk ?? false,
139
165
  approveBlanketAsk: auto?.approveBlanketAsk ?? false,
140
- presetBound: activePreset === "auto",
166
+ presetBound: callPreset === "auto",
141
167
  ...(permissionRequest.ruleAliases ? { ruleAliases: permissionRequest.ruleAliases } : {}),
142
168
  })
143
169
  .then(() => ({ ok: true }), (error) => ({ ok: false, error }));
144
- const isPending = service.list().some((pendingRequest) => pendingRequest.id === request.id);
170
+ const isPending = activeService.list().some((pendingRequest) => pendingRequest.id === request.id);
145
171
  if (!isPending) {
146
172
  const askResult = await askResultPromise;
147
173
  if (!askResult.ok) {
@@ -151,16 +177,16 @@ export default function permissionSystemExtension(pi) {
151
177
  }
152
178
  if (ctx.hasUI) {
153
179
  const reply = await showPermissionPrompt(ctx, request);
154
- service.reply(reply);
180
+ activeService.reply(reply);
155
181
  }
156
182
  else {
157
- service.reply(handleNoUI(request, {
183
+ activeService.reply(handleNoUI(request, {
158
184
  emitEvent: (eventName, data) => {
159
185
  if (eventName !== "permission_asked") {
160
186
  pi.events.emit(eventName, data);
161
187
  }
162
188
  },
163
- presetBound: activePreset === "auto",
189
+ presetBound: callPreset === "auto",
164
190
  }));
165
191
  }
166
192
  const askResult = await askResultPromise;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { sanitizeTerminalLabel } from "@earendil-works/pi-tui";
1
+ export { sanitizeTerminalLabel, visibleWidth } from "@earendil-works/pi-tui";
2
2
  export { type Args, parseArgs } from "./cli/args.ts";
3
3
  export { runHostCommand } from "./cli/host-command.ts";
4
4
  export { CONFIG_DIR_NAME, getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION, } from "./config.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Core session management
2
- export { sanitizeTerminalLabel } from "@earendil-works/pi-tui";
2
+ export { sanitizeTerminalLabel, visibleWidth } from "@earendil-works/pi-tui";
3
3
  export { parseArgs } from "./cli/args.js";
4
4
  // The shared-daemon command surface: one JSON line, one exit code, for launchers without a shell
5
5
  export { runHostCommand } from "./cli/host-command.js";
@@ -4123,6 +4123,11 @@ export class InteractiveMode {
4123
4123
  switch (event.type) {
4124
4124
  case "agent_start":
4125
4125
  this.agentIdle = false;
4126
+ // Keep a scrolled-up reader in place for this turn: rows re-laid out above the viewport are not
4127
+ // replayed until the next key press (#2836). No catch-up here: a turn nobody typed (auto-retry,
4128
+ // an extension's triggerTurn) can start while the reader is still scrolled up, and a turn the user
4129
+ // started already caught up on their Enter key.
4130
+ this.ui.setScrollbackReplayHold(true);
4126
4131
  this.transcriptWriteNoticeShown = false;
4127
4132
  this.clearPendingTools();
4128
4133
  this.clearActiveToolExecutionStatus();
@@ -4411,6 +4416,8 @@ export class InteractiveMode {
4411
4416
  if (this.settingsManager.getShowTerminalProgress() && this.ui.terminal) {
4412
4417
  this.ui.terminal.setProgress(false);
4413
4418
  }
4419
+ // Keep holding until the next key press: a replay at turn end would snap a reader who is scrolled up.
4420
+ this.ui.setScrollbackReplayHold("until-input");
4414
4421
  this.clearActiveToolExecutionStatus();
4415
4422
  this.clearToolHookStatuses();
4416
4423
  this.streamingReveal.stop();
@@ -4850,8 +4857,13 @@ export class InteractiveMode {
4850
4857
  */
4851
4858
  showStatus(message) {
4852
4859
  const children = this.chatContainer.children;
4853
- const last = children.length > 0 ? children[children.length - 1] : undefined;
4854
- const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
4860
+ // While a turn streams, a notice goes above the live message, like a custom entry does. Appended
4861
+ // after it, a notice taller than the screen pushed the live output above the viewport, and every
4862
+ // streamed delta then replayed the whole scrollback: the view kept jumping to the top (#2836).
4863
+ const streamingIndex = this.streamingComponent ? children.indexOf(this.streamingComponent) : -1;
4864
+ const end = streamingIndex >= 0 ? streamingIndex : children.length;
4865
+ const last = end > 0 ? children[end - 1] : undefined;
4866
+ const secondLast = end > 1 ? children[end - 2] : undefined;
4855
4867
  if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
4856
4868
  this.lastStatusMessage = message;
4857
4869
  this.lastStatusText.invalidate();
@@ -4861,8 +4873,13 @@ export class InteractiveMode {
4861
4873
  const spacer = new Spacer(1);
4862
4874
  this.lastStatusMessage = message;
4863
4875
  const text = new ThemedText(() => theme.fg("dim", this.lastStatusMessage), 1, 0);
4864
- this.chatContainer.addChild(spacer);
4865
- this.chatContainer.addChild(text);
4876
+ if (streamingIndex >= 0) {
4877
+ children.splice(streamingIndex, 0, spacer, text);
4878
+ }
4879
+ else {
4880
+ this.chatContainer.addChild(spacer);
4881
+ this.chatContainer.addChild(text);
4882
+ }
4866
4883
  this.lastStatusSpacer = spacer;
4867
4884
  this.lastStatusText = text;
4868
4885
  this.ui.requestRender();
@@ -14,7 +14,7 @@ export interface SessionPathReservations {
14
14
  /** Records this host as the holder of `sessionPath`, or reports the holder whose claim stands. */
15
15
  claim(sessionPath: string, attached?: boolean): Promise<SessionPathOwner | undefined>;
16
16
  /** Drops this host's claim. A claim made by another generation is never touched. */
17
- release(sessionPath: string): void;
17
+ release(sessionPath: string): Promise<void>;
18
18
  /** Republishes this host's claim with the attachment state that session now has. */
19
19
  setAttached(sessionPath: string, attached: boolean): void;
20
20
  }
@@ -58,6 +58,7 @@ export function createSessionPathReservations(options) {
58
58
  const pid = options.pid ?? process.pid;
59
59
  const startTime = readProcessStartTime(pid).then((value) => value ?? null, () => null);
60
60
  const held = new Set();
61
+ const updates = new Map();
61
62
  const report = (message) => options.onFailure?.(message);
62
63
  const publish = async (sessionPath, attached, stillHeld) => {
63
64
  const owner = {
@@ -100,19 +101,24 @@ export function createSessionPathReservations(options) {
100
101
  }
101
102
  return undefined;
102
103
  },
103
- release(sessionPath) {
104
+ async release(sessionPath) {
104
105
  if (!held.delete(sessionPath))
105
106
  return;
106
- void rm(reservationFile(dir, sessionPath), { force: true }).catch((cause) => {
107
+ await updates.get(sessionPath);
108
+ updates.delete(sessionPath);
109
+ await rm(reservationFile(dir, sessionPath), { force: true }).catch((cause) => {
107
110
  report(`session path reservation for ${sessionPath} could not be removed (${errorMessage(cause)})`);
108
111
  });
109
112
  },
110
113
  setAttached(sessionPath, attached) {
111
114
  if (!held.has(sessionPath))
112
115
  return;
113
- void publish(sessionPath, attached, () => held.has(sessionPath)).catch((cause) => {
116
+ const update = (updates.get(sessionPath) ?? Promise.resolve())
117
+ .then(() => publish(sessionPath, attached, () => held.has(sessionPath)))
118
+ .catch((cause) => {
114
119
  report(`session path reservation for ${sessionPath} could not be updated (${errorMessage(cause)})`);
115
120
  });
121
+ updates.set(sessionPath, update);
116
122
  },
117
123
  };
118
124
  }
@@ -16,8 +16,8 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
16
16
  entry.retainOnDisconnect = true;
17
17
  if (!entry.durableSessionId)
18
18
  throw new RpcSessionRegistryError("session_path_in_use");
19
- // The surface follows the client that renders the replies: an attach that names one moves
20
- // the live session to it; an attach without one keeps what the session has.
19
+ // The surface, browser engine and permission preset follow the client: an attach that names one
20
+ // moves the live session to it; an attach without one keeps what the session has.
21
21
  if (profile.promptSurface !== undefined && profile.promptSurface !== entry.profile.promptSurface) {
22
22
  entry.profile = frozenProfile({ ...entry.profile, promptSurface: profile.promptSurface });
23
23
  entry.runtime?.setPromptSurface(profile.promptSurface);
@@ -26,6 +26,12 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
26
26
  entry.profile = frozenProfile({ ...entry.profile, browserEngine: profile.browserEngine });
27
27
  entry.runtime?.setBrowserEngine(profile.browserEngine);
28
28
  }
29
+ // The preset is applied even when the record already names it: the record is not the live session,
30
+ // so a resent attach also repairs a session that drifted from it (senpi#2842).
31
+ if (profile.permissionPreset !== undefined) {
32
+ entry.profile = frozenProfile({ ...entry.profile, permissionPreset: profile.permissionPreset });
33
+ entry.runtime?.setPermissionPreset(profile.permissionPreset);
34
+ }
29
35
  entry.lastCommandAt = now;
30
36
  if (wasParked) {
31
37
  entry.lifecycleMutex = entry.lifecycleMutex.then(() => entry.runtime?.emitAttachmentEvent("session_resumed"));
@@ -11,7 +11,7 @@ import { attachToOpenSession } from "./session-registry-attach.js";
11
11
  import { settleClosingReservation, syncRuntimeMetadata } from "./session-registry-claims.js";
12
12
  import { createEntrySwitchSession } from "./session-registry-switch.js";
13
13
  import { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry-types.js";
14
- import { beginSessionClose, closeMarkedSession, closeSession } from "./session-teardown.js";
14
+ import { beginSessionClose, closeMarkedSession, closeSession, releaseWithinGrace, } from "./session-teardown.js";
15
15
  export { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry-types.js";
16
16
  /** Process-local lifecycle owner for multi-session RPC runtimes. */
17
17
  export class RpcSessionRegistry {
@@ -41,9 +41,9 @@ export class RpcSessionRegistry {
41
41
  this.options.onSizeChange?.(this.entries.size);
42
42
  return deleted;
43
43
  },
44
- releaseReservation: (key) => {
44
+ releaseReservation: async (key) => {
45
+ await this.options.pathReservations?.release(key);
45
46
  this.reservations.delete(key);
46
- this.options.pathReservations?.release(key);
47
47
  },
48
48
  markDetached: (key) => this.options.pathReservations?.setAttached(key, false),
49
49
  now: () => this.now(),
@@ -174,10 +174,8 @@ export class RpcSessionRegistry {
174
174
  finally {
175
175
  this.entries.delete(handle);
176
176
  this.options.onSizeChange?.(this.entries.size);
177
- if (sessionPath) {
178
- this.reservations.delete(sessionPath);
179
- this.options.pathReservations?.release(sessionPath);
180
- }
177
+ if (sessionPath)
178
+ await releaseWithinGrace(this.teardownHost, handle, sessionPath);
181
179
  }
182
180
  }
183
181
  if (error instanceof RpcSessionRegistryError)
@@ -3,7 +3,7 @@ export interface SessionTeardownHost {
3
3
  readonly closeGraceMs: number;
4
4
  get(handle: string): RpcSessionEntry | undefined;
5
5
  delete(handle: string): void;
6
- releaseReservation(key: string): void;
6
+ releaseReservation(key: string): Promise<void>;
7
7
  /** Publishes that this path is retained with no client attached, for the cross-generation claim. */
8
8
  markDetached(key: string): void;
9
9
  /** The registry's clock, so the detach stamp shares the sweep's timeline. */
@@ -13,6 +13,11 @@ export interface SessionTeardownHost {
13
13
  export declare function beginSessionClose(host: SessionTeardownHost, handle: string, onRole?: (finalizer: boolean) => void, options?: {
14
14
  detach?: boolean;
15
15
  }): RpcSessionEntry;
16
+ /**
17
+ * Close waits for the claim file to be gone, but a removal that never settles (a wedged mount)
18
+ * must not hold the close past the grace window: the close completes and the failure is reported.
19
+ */
20
+ export declare function releaseWithinGrace(host: Pick<SessionTeardownHost, "closeGraceMs" | "releaseReservation">, handle: string, key: string): Promise<void>;
16
21
  export declare function closeSession(host: SessionTeardownHost, handle: string): Promise<void>;
17
22
  export declare function closeMarkedSession(host: SessionTeardownHost, handle: string): Promise<void>;
18
23
  //# sourceMappingURL=session-teardown.d.ts.map
@@ -40,6 +40,25 @@ export function beginSessionClose(host, handle, onRole, options) {
40
40
  });
41
41
  return entry;
42
42
  }
43
+ /**
44
+ * Close waits for the claim file to be gone, but a removal that never settles (a wedged mount)
45
+ * must not hold the close past the grace window: the close completes and the failure is reported.
46
+ */
47
+ export async function releaseWithinGrace(host, handle, key) {
48
+ let timer;
49
+ const late = new Promise((resolve) => {
50
+ timer = setTimeout(() => resolve("late"), host.closeGraceMs);
51
+ });
52
+ const removal = host.releaseReservation(key).then(() => "removed", (cause) => {
53
+ reportDetachedFailure(handle, cause);
54
+ return "failed";
55
+ });
56
+ const outcome = await Promise.race([removal, late]);
57
+ clearTimeout(timer);
58
+ if (outcome === "late") {
59
+ reportDetachedFailure(handle, new Error(`session path reservation was not removed within ${host.closeGraceMs} ms; closing anyway`));
60
+ }
61
+ }
43
62
  export function closeSession(host, handle) {
44
63
  const entry = beginSessionClose(host, handle);
45
64
  if (entry.state !== "closing")
@@ -80,16 +99,16 @@ export function closeMarkedSession(host, handle) {
80
99
  await disposeOnce().catch(() => undefined);
81
100
  await entry.scope.close?.();
82
101
  };
83
- const release = () => {
102
+ const release = async () => {
84
103
  if (released)
85
104
  return;
86
105
  released = true;
87
106
  if (releaseTimer)
88
107
  clearTimeout(releaseTimer);
108
+ if (entry.reservationKey)
109
+ await releaseWithinGrace(host, handle, entry.reservationKey);
89
110
  entry.state = "closed";
90
111
  host.delete(handle);
91
- if (entry.reservationKey)
92
- host.releaseReservation(entry.reservationKey);
93
112
  };
94
113
  const graceful = (async () => {
95
114
  await previousLifecycle;
@@ -119,22 +138,22 @@ export function closeMarkedSession(host, handle) {
119
138
  }
120
139
  })();
121
140
  let settled = false;
122
- const finish = () => {
141
+ const finish = async () => {
123
142
  if (settled)
124
143
  return;
125
144
  settled = true;
126
- release();
145
+ await release();
127
146
  entry.closeResolve?.();
128
147
  };
129
148
  releaseTimer = setTimeout(() => {
130
149
  void disposeOnce().catch((cause) => reportDetachedFailure(handle, cause));
131
150
  void closeScopeOnce().catch((cause) => reportDetachedFailure(handle, cause));
132
151
  void graceful.catch((cause) => reportDetachedFailure(handle, cause));
133
- finish();
152
+ void finish();
134
153
  }, host.closeGraceMs);
135
154
  void graceful.then(finish, (cause) => {
136
155
  reportDetachedFailure(handle, cause);
137
- finish();
156
+ return finish();
138
157
  });
139
158
  return completion;
140
159
  }
@@ -40,6 +40,8 @@ export declare class SessionWorkerClient {
40
40
  setPromptSurface(surface: PromptSurface): Promise<void>;
41
41
  /** Moves the worker's live session to another browser engine (a later `open_session.browserEngine`). */
42
42
  setBrowserEngine(engine: BrowserEngine): Promise<void>;
43
+ /** Moves the worker's live session to another permission preset (a later `open_session.permissionPreset`). */
44
+ setPermissionPreset(preset: string): Promise<void>;
43
45
  commit(): Promise<WorkerSnapshot>;
44
46
  bind(sessionId: string, writer: SessionEventWriter, requestClose: () => void, options: Pick<RpcConnectionOptions, "capabilities" | "clientInfo">): Promise<RpcSessionBinding>;
45
47
  get busy(): boolean;
@@ -60,6 +60,12 @@ export class SessionWorkerClient {
60
60
  if (result.type !== "result")
61
61
  throw new Error("Invalid worker browser_engine response");
62
62
  }
63
+ /** Moves the worker's live session to another permission preset (a later `open_session.permissionPreset`). */
64
+ async setPermissionPreset(preset) {
65
+ const result = await this.request({ type: "permission_preset", preset });
66
+ if (result.type !== "result")
67
+ throw new Error("Invalid worker permission_preset response");
68
+ }
63
69
  async commit() {
64
70
  const result = await this.request({ type: "commit" });
65
71
  if (result.type !== "ready")
@@ -60,6 +60,10 @@ export type HostToSessionWorker = {
60
60
  type: "browser_engine";
61
61
  request: number;
62
62
  engine: BrowserEngine;
63
+ } | {
64
+ type: "permission_preset";
65
+ request: number;
66
+ preset: string;
63
67
  } | {
64
68
  type: "cancel_ui";
65
69
  } | {
@@ -183,6 +183,12 @@ async function handle(message) {
183
183
  entry.runtime.setBrowserEngine(message.engine);
184
184
  send({ type: "result", request: message.request });
185
185
  return;
186
+ case "permission_preset":
187
+ if (!entry?.runtime)
188
+ throw new Error("session_closing");
189
+ entry.runtime.setPermissionPreset(message.preset);
190
+ send({ type: "result", request: message.request });
191
+ return;
186
192
  case "cancel_ui":
187
193
  binding?.cancelPendingExtensionUiRequests?.();
188
194
  send({ type: "control_done", control: "cancel_ui" });
@@ -233,7 +233,7 @@ export class WorkerSessionRegistry {
233
233
  return undefined;
234
234
  }
235
235
  async attach(owner, path, options, requested = { cwd: "" }) {
236
- const { promptSurface, browserEngine } = requested;
236
+ const { promptSurface, browserEngine, permissionPreset } = requested;
237
237
  const entry = this.entries.get(owner);
238
238
  if (entry?.state !== "open" || !entry.worker?.bindingReady || entry.worker.snapshot?.sessionPath !== path)
239
239
  throw new RpcSessionRegistryError("session_path_in_use");
@@ -246,6 +246,11 @@ export class WorkerSessionRegistry {
246
246
  entry.profile = frozenProfile({ ...entry.profile, browserEngine });
247
247
  await entry.worker.setBrowserEngine(browserEngine);
248
248
  }
249
+ // Sent even when the record already names it: only the worker sees its live session (senpi#2842).
250
+ if (permissionPreset !== undefined) {
251
+ entry.profile = frozenProfile({ ...entry.profile, permissionPreset });
252
+ await entry.worker.setPermissionPreset(permissionPreset);
253
+ }
249
254
  const result = this.openResult(owner, entry);
250
255
  entry.attachments++;
251
256
  // Retention is a property of the live session: any attach may ask for it, and
package/docs/rpc.md CHANGED
@@ -870,7 +870,8 @@ session's prompt for the named surface, so one host serves a terminal client and
870
870
  uses the host process's `SENPI_PROMPT_SURFACE` (`app` or `chat` selects that surface; unset or any other value is `terminal`),
871
871
  which remains the only switch for a single-process `--mode rpc` client. A later `open_session` that attaches to a live
872
872
  session with another `promptSurface` rebuilds that session's prompt; an attach without the field keeps the current
873
- surface, and `new_session` / `switch_session` / `fork` inside the session keep it too. Probe `prompt_surface` in
873
+ surface, and `new_session` / `switch_session` / `fork` inside the session keep it too, also when the attach lands while
874
+ one of them is still building the replacement. Probe `prompt_surface` in
874
875
  `get_protocol_info` before sending the field; an older host ignores it. Probe `prompt_surface_chat` before sending
875
876
  `chat`: a host without it refuses `chat` with `invalid_launch_profile`, so a gateway falls back to `app`. Any value other
876
877
  than `terminal`, `app` or `chat` is refused with `invalid_launch_profile`.
@@ -900,9 +901,10 @@ subprocesses (the bash tool and everything it spawns) and its eval kernels, incl
900
901
  variable, even when the host process itself exports one (a daemon never inherits it from the process that ensured it).
901
902
  `BSK_HOME` and `BSK_BIN` are per install, not per session: they pass from the host environment to every session
902
903
  unchanged. A later `open_session` that attaches to a live session with another `browserEngine` moves that session to
903
- it, and an attach without the field keeps the current engine; `new_session` / `switch_session` / `fork` keep it too.
904
- Probe `browser_engine` in `get_protocol_info` before sending the field; the capability is advertised only because the
905
- value reaches every consumer above. Any other value is refused with `invalid_launch_profile`.
904
+ it, and an attach without the field keeps the current engine; `new_session` / `switch_session` / `fork` keep it too,
905
+ also when the attach lands while one of them is still building the replacement. Probe `browser_engine` in
906
+ `get_protocol_info` before sending the field; the capability is advertised only because the value reaches every
907
+ consumer above. Any other value is refused with `invalid_launch_profile`.
906
908
 
907
909
  A skill that must tell the client what the browser is doing publishes it as that session's own event: an extension
908
910
  registers a tool that calls `pi.rpc.emit(name, data)`, an eval cell reaches it as `tool.<name>(...)`, and the host adds
@@ -1486,6 +1488,14 @@ capability must use `ask`, never `workspace`, for an edit-only approval promise.
1486
1488
  Hosts that support `permissionPreset: "auto"` advertise `permission_preset_auto`;
1487
1489
  clients without it must not send `auto`.
1488
1490
  Explicit permission rules and remembered approvals retain their existing precedence, except under `auto`: there settings and CLI rules can only narrow the preset (the more restrictive decision wins), and an "Always" answer (saved in `.senpi/permissions-approved.jsonl`, from this or an earlier session) still allows its pattern.
1491
+ An `open_session` that attaches to a live session with another `permissionPreset` moves that session to it: the next
1492
+ tool call is decided under the new preset, in either direction, and `new_session` / `switch_session` / `fork` keep it,
1493
+ also when the attach lands while one of them or a `reload` (or two overlapping reloads) is still rebuilding the
1494
+ session. An attach that names a preset is applied even when the session was opened or last attached with the same one.
1495
+ An attach without the field keeps the current preset. An attach accepts exactly the values `open_session` accepts and
1496
+ treats them the same way: an unknown preset name is applied like one given to `open_session`, so the session's next
1497
+ tool call is refused with `Permission setup failed: Invalid --permission-preset "<name>". ...` until a later attach
1498
+ names a valid preset.
1489
1499
 
1490
1500
  | Command | Params | Success data | Notes |
1491
1501
  | --- | --- | --- | --- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.10-4",
3
+ "version": "2026.10.10-5",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@earendil-works/chord": "0.99.1",
60
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-4",
61
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-4",
62
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-4",
60
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-5",
61
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-5",
62
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-5",
63
63
  "@silvia-odwyer/photon-node": "0.3.4",
64
64
  "chalk": "6.0.0",
65
65
  "cross-spawn": "7.0.6",
@@ -78,8 +78,8 @@
78
78
  "yaml": "2.9.1",
79
79
  "@anthropic-ai/claude-agent-sdk": "0.3.289",
80
80
  "@anthropic-ai/sdk": "0.127.0",
81
- "@code-yeongyu/senpi-codemode": "2026.10.10-4",
82
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-4",
81
+ "@code-yeongyu/senpi-codemode": "2026.10.10-5",
82
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-5",
83
83
  "@modelcontextprotocol/sdk": "1.30.0",
84
84
  "@mozilla/readability": "0.6.0",
85
85
  "linkedom": "0.18.13",