@code-yeongyu/senpi 2026.10.10-3 → 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 +44 -0
- package/dist/bundle/chunks/{app-server-command-DVWM37GF.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-MT2C4I5R.js → chunk-2UUTN6QT.js} +105 -104
- 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/{chunk-CTERKXVZ.js → chunk-ZSYRWQWG.js} +2 -2
- package/dist/bundle/chunks/{cli-main-B4S4DGBX.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-QOFA64JL.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-E5RDPWQN.js → multi-session-host-TEFLJTTA.js} +5 -5
- package/dist/bundle/chunks/{package-manager-cli-YGTWXRG5.js → package-manager-cli-QSJSIHUR.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-HDAGQ4X3.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-GZDV2FOZ.js +3 -0
- package/dist/bundle/chunks/{session-picker-O7OESJWU.js → session-picker-6D4LC3KR.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +178 -176
- 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/core/extensions/session-control-types.d.ts +25 -0
- package/dist/core/extensions/session-control-types.js +16 -0
- package/dist/core/external-admission.js +7 -1
- package/dist/core/resource-loader.js +63 -2
- package/dist/core/session-rebind.js +16 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/modes/interactive/components/extension-editor.js +4 -0
- package/dist/modes/interactive/components/remote-delivery-message.d.ts +6 -3
- package/dist/modes/interactive/components/remote-delivery-message.js +32 -3
- package/dist/modes/interactive/interactive-mode.d.ts +17 -0
- package/dist/modes/interactive/interactive-mode.js +79 -33
- package/dist/modes/interactive/interactive-stderr-guard.d.ts +8 -0
- package/dist/modes/interactive/interactive-stderr-guard.js +70 -0
- package/dist/modes/interactive/session-control-commands.d.ts +2 -1
- package/dist/modes/interactive/session-control-commands.js +21 -7
- package/dist/modes/interactive/session-control-session-commands.d.ts +22 -0
- package/dist/modes/interactive/session-control-session-commands.js +47 -0
- package/dist/modes/interactive/stdout-fd-redirect.d.ts +12 -0
- package/dist/modes/interactive/stdout-fd-redirect.js +114 -0
- package/dist/modes/rpc/connection-handler.js +6 -15
- package/dist/modes/rpc/host-reservations.d.ts +1 -1
- package/dist/modes/rpc/host-reservations.js +9 -3
- package/dist/modes/rpc/rpc-types.d.ts +13 -0
- package/dist/modes/rpc/session-control-actions-data.d.ts +17 -0
- package/dist/modes/rpc/session-control-actions-data.js +38 -0
- 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/extensions.md +1 -1
- package/docs/rpc.md +52 -8
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-PDSG6VER.js +0 -14
- package/dist/bundle/chunks/chunk-RADHRQ4K.js +0 -7
- package/dist/bundle/chunks/session-control-endpoint-NABTZHCZ.js +0 -3
|
@@ -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;
|
|
@@ -25,9 +25,30 @@ export interface SessionControlWakeEvent {
|
|
|
25
25
|
readonly delivery_ids?: readonly string[];
|
|
26
26
|
}
|
|
27
27
|
export type ExternalDeliverAs = "steer" | "followUp";
|
|
28
|
+
/**
|
|
29
|
+
* Who sent a delivery, for the surfaces a human reads: another session (by id, with the name it had
|
|
30
|
+
* when it sent), the command line, or an external chat. The model reads `text`; this is not shown
|
|
31
|
+
* to it.
|
|
32
|
+
*/
|
|
33
|
+
export type SessionControlSender = {
|
|
34
|
+
readonly kind: "agent";
|
|
35
|
+
readonly session_id: string;
|
|
36
|
+
readonly name?: string;
|
|
37
|
+
} | {
|
|
38
|
+
readonly kind: "command_line";
|
|
39
|
+
readonly user?: string;
|
|
40
|
+
} | {
|
|
41
|
+
readonly kind: "external";
|
|
42
|
+
readonly platform: string;
|
|
43
|
+
readonly author?: string;
|
|
44
|
+
};
|
|
28
45
|
export interface AdmitExternalMessageInput {
|
|
29
46
|
readonly delivery_id: string;
|
|
30
47
|
readonly text: string;
|
|
48
|
+
/** Who sent it; a delivery without one renders as a generic remote message. */
|
|
49
|
+
readonly sender?: SessionControlSender;
|
|
50
|
+
/** The message as its sender wrote it, for human surfaces; `text` keeps the provenance the model reads. */
|
|
51
|
+
readonly display_text?: string;
|
|
31
52
|
readonly deliverAs: ExternalDeliverAs;
|
|
32
53
|
/** The `turn_epoch` the sender observed; a steer is admitted only while it is still current. */
|
|
33
54
|
readonly expected_turn_id?: number;
|
|
@@ -117,7 +138,11 @@ export interface SessionControlDeliveryDetails {
|
|
|
117
138
|
readonly delivery_id: string;
|
|
118
139
|
readonly source: "session_control";
|
|
119
140
|
readonly deliverAs: ExternalDeliverAs;
|
|
141
|
+
readonly sender?: SessionControlSender;
|
|
142
|
+
readonly display_text?: string;
|
|
120
143
|
}
|
|
144
|
+
/** The sender a delivery's `details` names, when it names a well-formed one. */
|
|
145
|
+
export declare function sessionControlSenderOf(details: unknown): SessionControlSender | undefined;
|
|
121
146
|
/** Whether a custom-message `details` value proves it came through session-control admission. */
|
|
122
147
|
export declare function isSessionControlDeliveryDetails(value: unknown): value is SessionControlDeliveryDetails;
|
|
123
148
|
//# sourceMappingURL=session-control-types.d.ts.map
|
|
@@ -10,6 +10,22 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** `customType` of the transcript entry an admitted delivery becomes. */
|
|
12
12
|
export const SESSION_CONTROL_DELIVERY_TYPE = "session_control_delivery";
|
|
13
|
+
/** The sender a delivery's `details` names, when it names a well-formed one. */
|
|
14
|
+
export function sessionControlSenderOf(details) {
|
|
15
|
+
if (!isSessionControlDeliveryDetails(details) || typeof details.sender !== "object" || details.sender === null)
|
|
16
|
+
return undefined;
|
|
17
|
+
const sender = { ...details.sender };
|
|
18
|
+
const optional = (key) => (typeof sender[key] === "string" ? { [key]: sender[key] } : {});
|
|
19
|
+
if (sender.kind === "agent" && typeof sender.session_id === "string") {
|
|
20
|
+
return { kind: "agent", session_id: sender.session_id, ...optional("name") };
|
|
21
|
+
}
|
|
22
|
+
if (sender.kind === "command_line")
|
|
23
|
+
return { kind: "command_line", ...optional("user") };
|
|
24
|
+
if (sender.kind === "external" && typeof sender.platform === "string") {
|
|
25
|
+
return { kind: "external", platform: sender.platform, ...optional("author") };
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
13
29
|
/** Whether a custom-message `details` value proves it came through session-control admission. */
|
|
14
30
|
export function isSessionControlDeliveryDetails(value) {
|
|
15
31
|
if (typeof value !== "object" || value === null)
|
|
@@ -173,7 +173,13 @@ function deliveryMessage(input) {
|
|
|
173
173
|
customType: SESSION_CONTROL_DELIVERY_TYPE,
|
|
174
174
|
content: input.text,
|
|
175
175
|
display: true,
|
|
176
|
-
details: {
|
|
176
|
+
details: {
|
|
177
|
+
delivery_id: input.delivery_id,
|
|
178
|
+
source: "session_control",
|
|
179
|
+
deliverAs: input.deliverAs,
|
|
180
|
+
...(input.sender === undefined ? {} : { sender: input.sender }),
|
|
181
|
+
...(input.display_text === undefined ? {} : { display_text: input.display_text }),
|
|
182
|
+
},
|
|
177
183
|
timestamp: Date.now(),
|
|
178
184
|
};
|
|
179
185
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { detectCapabilities, getTerminalColorMode } from "@earendil-works/pi-tui";
|
|
@@ -171,6 +171,10 @@ function buildGlobalDefaultExtensionShim(modulePath) {
|
|
|
171
171
|
function getGlobalDefaultExtensionShimPath(agentDir, extensionId) {
|
|
172
172
|
return join(agentDir, "extensions", `${extensionId}.js`);
|
|
173
173
|
}
|
|
174
|
+
function isGlobalDefaultExtensionShimLocation(extensionPath, agentDir) {
|
|
175
|
+
const resolvedExtensionPath = resolve(extensionPath);
|
|
176
|
+
return globalDefaultExtensionIds.some((extensionId) => resolvedExtensionPath === resolve(getGlobalDefaultExtensionShimPath(agentDir, extensionId)));
|
|
177
|
+
}
|
|
174
178
|
function findGeneratedGlobalDefaultExtensionId(extensionPath, agentDir) {
|
|
175
179
|
if (resolve(agentDir) !== resolve(getAgentDir())) {
|
|
176
180
|
return undefined;
|
|
@@ -183,7 +187,61 @@ function findGeneratedGlobalDefaultExtensionId(extensionPath, agentDir) {
|
|
|
183
187
|
}
|
|
184
188
|
return undefined;
|
|
185
189
|
}
|
|
190
|
+
/** The module a generated shim re-exports, when it is a generated shim whose target no longer exists. */
|
|
191
|
+
// Exactly what `buildGlobalDefaultExtensionShim` writes: one export line after the banner, nothing else.
|
|
192
|
+
const GENERATED_SHIM_EXPORT_LINE = /^export \{ default \} from ("[^"\n]*");\n?$/;
|
|
193
|
+
/**
|
|
194
|
+
* The module a generated shim re-exports, when the file is exactly a generated shim (an accepted banner
|
|
195
|
+
* followed by the single export line and nothing else) and that module no longer exists. A file that
|
|
196
|
+
* carries the banner but also anything a person wrote is never treated as a shim.
|
|
197
|
+
*/
|
|
198
|
+
function deadGeneratedShimTarget(content) {
|
|
199
|
+
const banner = LEGACY_GENERATED_GLOBAL_EXTENSION_BANNERS.find((candidate) => content.startsWith(candidate));
|
|
200
|
+
if (banner === undefined)
|
|
201
|
+
return undefined;
|
|
202
|
+
const specifier = GENERATED_SHIM_EXPORT_LINE.exec(content.slice(banner.length))?.[1];
|
|
203
|
+
if (specifier === undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
let target;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(specifier);
|
|
208
|
+
target = parsed.startsWith("file:") ? fileURLToPath(parsed) : parsed;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return existsSync(target) ? undefined : target;
|
|
214
|
+
}
|
|
215
|
+
function removeDeadGeneratedShim(shimPath) {
|
|
216
|
+
let content;
|
|
217
|
+
try {
|
|
218
|
+
content = readFileSync(shimPath, "utf-8");
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (deadGeneratedShimTarget(content) === undefined)
|
|
224
|
+
return;
|
|
225
|
+
try {
|
|
226
|
+
unlinkSync(shimPath);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// The loader skips a dead generated shim on its own; a failed removal only retries next start.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// A generated shim whose target is gone stands for "load the default extension" from an install that no
|
|
233
|
+
// longer exists. Importing it can only fail with "Cannot find module", so it is skipped without an error.
|
|
234
|
+
const skipDeadGeneratedShim = () => { };
|
|
186
235
|
function resolveGeneratedGlobalDefaultExtensionFactory(extensionPath, agentDir) {
|
|
236
|
+
if (isGlobalDefaultExtensionShimLocation(extensionPath, agentDir)) {
|
|
237
|
+
try {
|
|
238
|
+
if (deadGeneratedShimTarget(readFileSync(extensionPath, "utf-8")) !== undefined)
|
|
239
|
+
return skipDeadGeneratedShim;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Unreadable: let the regular load report it.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
187
245
|
const extensionId = findGeneratedGlobalDefaultExtensionId(extensionPath, agentDir);
|
|
188
246
|
if (!extensionId) {
|
|
189
247
|
return undefined;
|
|
@@ -591,10 +649,13 @@ export class DefaultResourceLoader {
|
|
|
591
649
|
mkdirSync(extensionsDir, { recursive: true });
|
|
592
650
|
for (const extensionId of globalDefaultExtensionIds) {
|
|
593
651
|
const modulePath = getGlobalDefaultExtensionModulePath(extensionId);
|
|
652
|
+
const targetPath = getGlobalDefaultExtensionShimPath(this.agentDir, extensionId);
|
|
594
653
|
if (!existsSync(modulePath)) {
|
|
654
|
+
// This engine has no on-disk builtin to point a shim at (a compiled binary). A shim an
|
|
655
|
+
// earlier install generated, whose target is gone, can only fail to load: remove it (#2765).
|
|
656
|
+
removeDeadGeneratedShim(targetPath);
|
|
595
657
|
continue;
|
|
596
658
|
}
|
|
597
|
-
const targetPath = getGlobalDefaultExtensionShimPath(this.agentDir, extensionId);
|
|
598
659
|
const shim = buildGlobalDefaultExtensionShim(modulePath);
|
|
599
660
|
if (existsSync(targetPath)) {
|
|
600
661
|
const existing = readFileSync(targetPath, "utf-8");
|
|
@@ -47,11 +47,26 @@ export async function rebindSessionFile(sourcePath, targetCwd, sessionDir, optio
|
|
|
47
47
|
const target = join(targetDir, basename(source));
|
|
48
48
|
if (target !== source && !existsSync(source) && existsSync(target))
|
|
49
49
|
return target;
|
|
50
|
-
const
|
|
50
|
+
const header = readHeaderBeforeLock(source, target);
|
|
51
|
+
if (header === undefined)
|
|
52
|
+
return target;
|
|
51
53
|
return withSessionMoveLock(source, header.id, () => moveSessionFile(source, target, cwd), {
|
|
52
54
|
...(options.moveLockWaitMs === undefined ? {} : { waitMs: options.moveLockWaitMs }),
|
|
53
55
|
});
|
|
54
56
|
}
|
|
57
|
+
// The lock is keyed by the session id, so the header is read before the lock is held. A concurrent rebind can finish
|
|
58
|
+
// the move between the existence check and this read; the mover writes the target before it removes the source, so a
|
|
59
|
+
// missing source next to an existing target means the move is already done (`undefined`).
|
|
60
|
+
function readHeaderBeforeLock(source, target) {
|
|
61
|
+
try {
|
|
62
|
+
return readHeaderLine(source).header;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === "ENOENT" && target !== source && existsSync(target))
|
|
66
|
+
return undefined;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
55
70
|
function moveSessionFile(source, target, cwd) {
|
|
56
71
|
if (target !== source && !existsSync(source) && existsSync(target))
|
|
57
72
|
return target;
|
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";
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Container, Editor, getKeybindings, Spacer, Text, } from "@earendil-works/pi-tui";
|
|
6
6
|
import { editInExternalEditor } from "../external-editor.js";
|
|
7
|
+
import { restoreInteractiveStderr, takeOverInteractiveStderr } from "../interactive-stderr-guard.js";
|
|
7
8
|
import { getEditorTheme, theme } from "../theme/theme.js";
|
|
8
9
|
import { DynamicBorder } from "./dynamic-border.js";
|
|
9
10
|
import { keyHint } from "./keybinding-hints.js";
|
|
@@ -79,6 +80,8 @@ export class ExtensionEditorComponent extends Container {
|
|
|
79
80
|
async handleOpenExternalEditor() {
|
|
80
81
|
const content = this.editor.getText();
|
|
81
82
|
this.tui.stop();
|
|
83
|
+
// The editor draws on the real terminal: hand fd 1 and fd 2 back first, as the main editor path does.
|
|
84
|
+
restoreInteractiveStderr();
|
|
82
85
|
try {
|
|
83
86
|
const result = await editInExternalEditor({
|
|
84
87
|
command: this.externalEditorCommand,
|
|
@@ -89,6 +92,7 @@ export class ExtensionEditorComponent extends Container {
|
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
94
|
finally {
|
|
95
|
+
takeOverInteractiveStderr();
|
|
92
96
|
this.tui.start();
|
|
93
97
|
this.tui.requestRender(true);
|
|
94
98
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { type MessageRenderer } from "../../../core/extensions/types.ts";
|
|
1
|
+
import { type MessageRenderer, type SessionControlSender } from "../../../core/extensions/types.ts";
|
|
2
2
|
/**
|
|
3
|
-
* A message another session delivered renders as its own
|
|
4
|
-
*
|
|
3
|
+
* A message another session delivered renders as its own block - never as the user's own input, so
|
|
4
|
+
* a reader always sees which text came from outside this terminal. A delivery that names its sender
|
|
5
|
+
* shows one label line ("Sent by another agent · <name>") over the message as it was written; one
|
|
6
|
+
* that does not keeps the generic heading over the text the model read.
|
|
5
7
|
*/
|
|
6
8
|
export declare const renderRemoteDelivery: MessageRenderer;
|
|
9
|
+
export declare function senderLabel(sender: SessionControlSender): string;
|
|
7
10
|
export declare function builtInMessageRenderer(customType: string): MessageRenderer | undefined;
|
|
8
11
|
//# sourceMappingURL=remote-delivery-message.d.ts.map
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
2
|
-
import { SESSION_CONTROL_DELIVERY_TYPE } from "../../../core/extensions/types.js";
|
|
2
|
+
import { isSessionControlDeliveryDetails, SESSION_CONTROL_DELIVERY_TYPE, sessionControlSenderOf, } from "../../../core/extensions/types.js";
|
|
3
3
|
import { getMarkdownTheme } from "../theme/theme.js";
|
|
4
4
|
/**
|
|
5
|
-
* A message another session delivered renders as its own
|
|
6
|
-
*
|
|
5
|
+
* A message another session delivered renders as its own block - never as the user's own input, so
|
|
6
|
+
* a reader always sees which text came from outside this terminal. A delivery that names its sender
|
|
7
|
+
* shows one label line ("Sent by another agent · <name>") over the message as it was written; one
|
|
8
|
+
* that does not keeps the generic heading over the text the model read.
|
|
7
9
|
*/
|
|
8
10
|
export const renderRemoteDelivery = (message, _options, theme) => {
|
|
9
11
|
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
12
|
+
const sender = sessionControlSenderOf(message.details);
|
|
13
|
+
const shown = sender === undefined ? undefined : displayTextOf(message.details);
|
|
14
|
+
if (sender !== undefined && shown !== undefined) {
|
|
15
|
+
box.addChild(new Text(theme.fg("dim", senderLabel(sender)), 0, 0));
|
|
16
|
+
box.addChild(new Spacer(1));
|
|
17
|
+
box.addChild(new Markdown(shown, 0, 0, getMarkdownTheme(), { color: (line) => theme.fg("customMessageText", line) }));
|
|
18
|
+
return box;
|
|
19
|
+
}
|
|
10
20
|
const label = theme.fg("customMessageLabel", theme.bold("remote message"));
|
|
11
21
|
box.addChild(new Text(`${label}${theme.fg("dim", ` · delivery ${deliveryIdOf(message.details)}`)}`, 0, 0));
|
|
12
22
|
box.addChild(new Spacer(1));
|
|
@@ -19,9 +29,28 @@ export const renderRemoteDelivery = (message, _options, theme) => {
|
|
|
19
29
|
box.addChild(new Markdown(text, 0, 0, getMarkdownTheme(), { color: (line) => theme.fg("customMessageText", line) }));
|
|
20
30
|
return box;
|
|
21
31
|
};
|
|
32
|
+
export function senderLabel(sender) {
|
|
33
|
+
switch (sender.kind) {
|
|
34
|
+
case "agent":
|
|
35
|
+
return sender.name === undefined || sender.name.trim() === ""
|
|
36
|
+
? "Sent by another agent"
|
|
37
|
+
: `Sent by another agent · ${sender.name}`;
|
|
38
|
+
case "command_line":
|
|
39
|
+
return "Sent from the command line";
|
|
40
|
+
case "external":
|
|
41
|
+
return sender.author === undefined || sender.author.trim() === ""
|
|
42
|
+
? `Sent from ${sender.platform}`
|
|
43
|
+
: `Sent from ${sender.platform} · ${sender.author}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
22
46
|
export function builtInMessageRenderer(customType) {
|
|
23
47
|
return customType === SESSION_CONTROL_DELIVERY_TYPE ? renderRemoteDelivery : undefined;
|
|
24
48
|
}
|
|
49
|
+
function displayTextOf(details) {
|
|
50
|
+
return isSessionControlDeliveryDetails(details) && typeof details.display_text === "string"
|
|
51
|
+
? details.display_text
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
25
54
|
function deliveryIdOf(details) {
|
|
26
55
|
if (typeof details !== "object" || details === null || !("delivery_id" in details))
|
|
27
56
|
return "unknown";
|
|
@@ -623,6 +623,12 @@ export declare class InteractiveMode {
|
|
|
623
623
|
private setToolsExpanded;
|
|
624
624
|
private toggleThinkingBlockVisibility;
|
|
625
625
|
private handleOpenExternalEditor;
|
|
626
|
+
/**
|
|
627
|
+
* Runs `work` (an external program that draws on the terminal itself) with the terminal handed
|
|
628
|
+
* back: the TUI stops, fd 1 and fd 2 point at the terminal again, and both are taken over again
|
|
629
|
+
* afterwards. An editor launched without this would draw into the debug log (#2815).
|
|
630
|
+
*/
|
|
631
|
+
private withTerminalHandedOver;
|
|
626
632
|
clearEditor(): void;
|
|
627
633
|
showError(errorMessage: string): void;
|
|
628
634
|
private getProviderErrors;
|
|
@@ -696,10 +702,21 @@ export declare class InteractiveMode {
|
|
|
696
702
|
* records it as the model's remembered level (the Ctrl+S path of the selector).
|
|
697
703
|
*/
|
|
698
704
|
private selectThinkingLevel;
|
|
705
|
+
/**
|
|
706
|
+
* The level switch itself; the control endpoint's `set_thinking_level` runs it too.
|
|
707
|
+
* Static and called through the class, so a handler invoked on a partial `this`
|
|
708
|
+
* reaches exactly the members the switch uses.
|
|
709
|
+
*/
|
|
710
|
+
private static applyThinkingLevel;
|
|
699
711
|
private showThinkingSelector;
|
|
700
712
|
private handleModelCommand;
|
|
701
713
|
private findExactModelMatch;
|
|
702
714
|
private selectModelFromUi;
|
|
715
|
+
/**
|
|
716
|
+
* The `/model` switch itself; the control endpoint's `set_model` runs it too, and reports a throw.
|
|
717
|
+
* Static and called through the class for the same reason as `applyThinkingLevel`.
|
|
718
|
+
*/
|
|
719
|
+
private static applyModelSelection;
|
|
703
720
|
private resolveFavoriteModelsForUi;
|
|
704
721
|
private getFavoriteModelIdsForUi;
|
|
705
722
|
private captureFavoritePatternSnapshot;
|