@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.
- package/CHANGELOG.md +24 -0
- package/dist/bundle/chunks/{app-server-command-N3L2LNP7.js → app-server-command-6FTH3TK6.js} +1 -1
- package/dist/bundle/chunks/{chunk-H4UIUFU5.js → chunk-2PUAD5TD.js} +1 -1
- package/dist/bundle/chunks/{chunk-MP2DMTI7.js → chunk-2UUTN6QT.js} +79 -79
- package/dist/bundle/chunks/{chunk-3A3C5VVO.js → chunk-3OGEJMZ7.js} +1 -1
- package/dist/bundle/chunks/{chunk-J5MUMVWB.js → chunk-64ZVBCTL.js} +8 -7
- package/dist/bundle/chunks/chunk-TIMLYJMK.js +14 -0
- package/dist/bundle/chunks/{chunk-Z7MMKKIY.js → chunk-TZVDXVHB.js} +1 -1
- package/dist/bundle/chunks/{chunk-7GVD54RM.js → chunk-U3IL5N76.js} +1 -1
- package/dist/bundle/chunks/{chunk-H67EVDWG.js → chunk-URQSNRHZ.js} +1 -1
- package/dist/bundle/chunks/{chunk-M2IHGL3M.js → chunk-VW74BGIC.js} +1 -1
- package/dist/bundle/chunks/chunk-YG5UO3E4.js +7 -0
- package/dist/bundle/chunks/{cli-main-CHDR33FZ.js → cli-main-UYTUBG64.js} +1 -1
- package/dist/bundle/chunks/{host-command-VATE7HRG.js → host-command-TOVYSHYH.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-GC3GIR2N.js → host-lifecycle-5AV4JXDQ.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-RVEM62WX.js → interactive-mode-KJIOCRS7.js} +1 -1
- package/dist/bundle/chunks/{list-tips-IIRFXBTO.js → list-tips-BXAA4FG5.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-CFND7CXH.js → multi-session-host-TEFLJTTA.js} +5 -5
- package/dist/bundle/chunks/{package-manager-cli-NWNECBF7.js → package-manager-cli-QSJSIHUR.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-JQFUDLNB.js → rpc-mode-RUKK7VBF.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-EQ4PQCRP.js → schedule-command-K6CTAB4T.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-HMB5T5VD.js → session-control-endpoint-GZDV2FOZ.js} +1 -1
- package/dist/bundle/chunks/{session-picker-O7OESJWU.js → session-picker-6D4LC3KR.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +144 -143
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-session-runtime.d.ts +6 -0
- package/dist/core/agent-session-runtime.js +18 -0
- package/dist/core/agent-session.js +5 -2
- package/dist/core/extensions/builtin/html-render/bootstrap.d.ts +15 -0
- package/dist/core/extensions/builtin/html-render/bootstrap.js +123 -0
- package/dist/core/extensions/builtin/html-render/images.d.ts +16 -0
- package/dist/core/extensions/builtin/html-render/images.js +155 -0
- package/dist/core/extensions/builtin/html-render/index.d.ts +3 -0
- package/dist/core/extensions/builtin/html-render/index.js +5 -0
- package/dist/core/extensions/builtin/html-render/tool.d.ts +19 -0
- package/dist/core/extensions/builtin/html-render/tool.js +74 -0
- package/dist/core/extensions/builtin/index.js +4 -0
- package/dist/core/extensions/builtin/permission-system/index.js +40 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/modes/interactive/interactive-mode.js +21 -4
- package/dist/modes/rpc/host-reservations.d.ts +1 -1
- package/dist/modes/rpc/host-reservations.js +9 -3
- package/dist/modes/rpc/session-registry-attach.js +8 -2
- package/dist/modes/rpc/session-registry.js +5 -7
- package/dist/modes/rpc/session-teardown.d.ts +6 -1
- package/dist/modes/rpc/session-teardown.js +26 -7
- package/dist/modes/rpc/session-worker-client.d.ts +2 -0
- package/dist/modes/rpc/session-worker-client.js +6 -0
- package/dist/modes/rpc/session-worker-protocol.d.ts +4 -0
- package/dist/modes/rpc/session-worker.js +6 -0
- package/dist/modes/rpc/worker-session-registry.js +6 -1
- package/docs/rpc.md +14 -4
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-RADHRQ4K.js +0 -7
- 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
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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 =
|
|
159
|
+
const auto = callPreset === "auto"
|
|
134
160
|
? await decideAuto(event.toolName, event.input, permissionRequest, ctx.cwd)
|
|
135
161
|
: undefined;
|
|
136
|
-
const askResultPromise =
|
|
162
|
+
const askResultPromise = activeService
|
|
137
163
|
.ask(request, {
|
|
138
164
|
autoApproveAsk: permissionRequest.autoApproveAsk ?? false,
|
|
139
165
|
approveBlanketAsk: auto?.approveBlanketAsk ?? false,
|
|
140
|
-
presetBound:
|
|
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 =
|
|
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
|
-
|
|
180
|
+
activeService.reply(reply);
|
|
155
181
|
}
|
|
156
182
|
else {
|
|
157
|
-
|
|
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:
|
|
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
|
-
|
|
4854
|
-
|
|
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
|
-
|
|
4865
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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")
|
|
@@ -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
|
|
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
|
-
|
|
905
|
-
|
|
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-
|
|
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-
|
|
61
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-
|
|
62
|
-
"@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-
|
|
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-
|
|
82
|
-
"@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-
|
|
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",
|