@code-yeongyu/senpi 2026.10.10-2 → 2026.10.10-4
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 +40 -0
- package/dist/bundle/chunks/{anthropic-messages-CVXI6CLT.js → anthropic-messages-OE3QBX4P.js} +1 -1
- package/dist/bundle/chunks/{app-server-command-WZG7WYMD.js → app-server-command-N3L2LNP7.js} +1 -1
- package/dist/bundle/chunks/{chunk-Q2ZTPMWD.js → chunk-4TK7VCZZ.js} +1 -1
- package/dist/bundle/chunks/{chunk-SB22X6ZR.js → chunk-H4UIUFU5.js} +1 -1
- package/dist/bundle/chunks/chunk-I5HLTDT4.js +2 -0
- package/dist/bundle/chunks/{chunk-W6B7SEAK.js → chunk-MP2DMTI7.js} +48 -46
- package/dist/bundle/chunks/{chunk-LSE7KE2C.js → chunk-ODYQK5XJ.js} +1 -1
- package/dist/bundle/chunks/chunk-USRAHAW4.js +14 -0
- package/dist/bundle/chunks/{chunk-3YXB7Z7O.js → chunk-Z7MMKKIY.js} +1 -1
- package/dist/bundle/chunks/{chunk-CTERKXVZ.js → chunk-ZSYRWQWG.js} +2 -2
- package/dist/bundle/chunks/{cli-main-T4T5UNRJ.js → cli-main-CHDR33FZ.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-44VUGB7H.js → help-fast-path-EHRZEBRO.js} +1 -1
- package/dist/bundle/chunks/{host-command-X64D7PT3.js → host-command-VATE7HRG.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-ULFSI46Z.js → interactive-mode-RVEM62WX.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-VM7WJBC5.js → multi-session-host-CFND7CXH.js} +1 -1
- package/dist/bundle/chunks/{openai-completions-6HU7GIGD.js → openai-completions-Q2LEWXEC.js} +1 -1
- package/dist/bundle/chunks/{openai-responses-S4QYZXF3.js → openai-responses-DHGWSTZM.js} +1 -1
- package/dist/bundle/chunks/{package-manager-cli-OAFUQY7E.js → package-manager-cli-NWNECBF7.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-ZJQ6I2RZ.js → rotation-stream-C5BM7TMM.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-XVRJJG3K.js → rpc-mode-JQFUDLNB.js} +1 -1
- package/dist/bundle/chunks/session-control-endpoint-HMB5T5VD.js +3 -0
- package/dist/bundle/chunks/{session-picker-R5N2EKUS.js → session-picker-O7OESJWU.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +67 -65
- 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.d.ts +2 -0
- package/dist/core/agent-session.js +18 -4
- package/dist/core/extensions/builtin/goal/lifecycle-helpers.js +1 -1
- package/dist/core/extensions/builtin/goal/monitor-continuation.js +3 -1
- package/dist/core/extensions/builtin/goal/prompt.d.ts +6 -1
- package/dist/core/extensions/builtin/goal/prompt.js +32 -1
- package/dist/core/extensions/builtin/websearch/websearch/config.js +2 -0
- package/dist/core/extensions/builtin/websearch/websearch/provider-endpoints.js +1 -0
- package/dist/core/extensions/builtin/websearch/websearch/providers/keenable.d.ts +3 -0
- package/dist/core/extensions/builtin/websearch/websearch/providers/keenable.js +55 -0
- package/dist/core/extensions/builtin/websearch/websearch/providers.js +2 -0
- package/dist/core/extensions/builtin/websearch/websearch/types.d.ts +1 -1
- 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/model-config-schema.d.ts +5 -0
- package/dist/core/model-config-schema.js +1 -0
- package/dist/core/provider-composer.js +5 -1
- package/dist/core/provider-timeout-retry.d.ts +7 -1
- package/dist/core/provider-timeout-retry.js +23 -12
- package/dist/core/reload-veto.d.ts +1 -1
- package/dist/core/reload-veto.js +7 -1
- package/dist/core/resource-loader.js +63 -2
- package/dist/core/session-rebind.js +16 -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 +58 -29
- 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/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/docs/extensions.md +1 -1
- package/docs/models.md +34 -0
- package/docs/rpc.md +38 -4
- package/docs/web-search.md +3 -1
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-ULYOMGXX.js +0 -14
- package/dist/bundle/chunks/chunk-YSPHACIN.js +0 -2
- package/dist/bundle/chunks/session-control-endpoint-FEGOYWHN.js +0 -3
|
@@ -42,23 +42,34 @@ export function createProviderTimeoutRetryPlan({ message, streamRetryTimeoutMs,
|
|
|
42
42
|
watchdogTimeoutMs: reconcileWatchdogTimeoutMs(streamRetryTimeoutMs, streamStartTimeoutMs),
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
export async function runBoundedRetryContinuation({ continueRun, getActiveSignal, abortActive, timeoutMs, }) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
}, timeoutMs);
|
|
45
|
+
export async function runBoundedRetryContinuation({ continueRun, getActiveSignal, abortActive, timeoutMs, onStreamStarted, }) {
|
|
46
|
+
// The watchdog bounds only the wait for the retried request to start streaming: a retry that streams
|
|
47
|
+
// and then works for a long time (a long answer, tool calls, further turns) is not a wedged retry.
|
|
48
|
+
let streamStarted = false;
|
|
49
|
+
let timer;
|
|
50
|
+
const unsubscribe = timeoutMs === undefined
|
|
51
|
+
? undefined
|
|
52
|
+
: onStreamStarted?.(() => {
|
|
53
|
+
streamStarted = true;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
});
|
|
57
56
|
try {
|
|
57
|
+
const continuation = continueRun();
|
|
58
|
+
const ownedSignal = getActiveSignal();
|
|
59
|
+
if (timeoutMs === undefined || ownedSignal === undefined || streamStarted) {
|
|
60
|
+
await continuation;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
if (!streamStarted && getActiveSignal() === ownedSignal) {
|
|
65
|
+
abortActive();
|
|
66
|
+
}
|
|
67
|
+
}, timeoutMs);
|
|
58
68
|
await continuation;
|
|
59
69
|
}
|
|
60
70
|
finally {
|
|
61
71
|
clearTimeout(timer);
|
|
72
|
+
unsubscribe?.();
|
|
62
73
|
}
|
|
63
74
|
}
|
|
64
75
|
//# sourceMappingURL=provider-timeout-retry.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ExtensionRunner } from "./extensions/runner.ts";
|
|
2
2
|
import type { ReloadVetoDecision } from "./extensions/types.ts";
|
|
3
|
-
export declare function checkSessionReloadVeto(runner: Pick<ExtensionRunner, "hasHandlers" | "emit">, isPromptStartPending: () => boolean): Promise<ReloadVetoDecision>;
|
|
3
|
+
export declare function checkSessionReloadVeto(runner: Pick<ExtensionRunner, "hasHandlers" | "emit">, isPromptStartPending: () => boolean, isSessionStartDispatching?: () => boolean): Promise<ReloadVetoDecision>;
|
|
4
4
|
//# sourceMappingURL=reload-veto.d.ts.map
|
package/dist/core/reload-veto.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
export async function checkSessionReloadVeto(runner, isPromptStartPending) {
|
|
1
|
+
export async function checkSessionReloadVeto(runner, isPromptStartPending, isSessionStartDispatching = () => false) {
|
|
2
2
|
if (isPromptStartPending()) {
|
|
3
3
|
return { cancelled: true, reason: "A prompt is being admitted." };
|
|
4
4
|
}
|
|
5
|
+
if (isSessionStartDispatching()) {
|
|
6
|
+
return { cancelled: true, reason: "A session is starting." };
|
|
7
|
+
}
|
|
5
8
|
const result = runner.hasHandlers("session_before_reload")
|
|
6
9
|
? await runner.emit({ type: "session_before_reload" })
|
|
7
10
|
: undefined;
|
|
8
11
|
if (isPromptStartPending()) {
|
|
9
12
|
return { cancelled: true, reason: "A prompt is being admitted." };
|
|
10
13
|
}
|
|
14
|
+
if (isSessionStartDispatching()) {
|
|
15
|
+
return { cancelled: true, reason: "A session is starting." };
|
|
16
|
+
}
|
|
11
17
|
if (result?.cancel !== true)
|
|
12
18
|
return { cancelled: false };
|
|
13
19
|
return result.reason === undefined ? { cancelled: true } : { cancelled: true, reason: result.reason };
|
|
@@ -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;
|
|
@@ -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;
|
|
@@ -1564,10 +1564,10 @@ export class InteractiveMode {
|
|
|
1564
1564
|
return;
|
|
1565
1565
|
}
|
|
1566
1566
|
const seeded = seedKeybindingsFile(configPath, this.keybindings);
|
|
1567
|
-
const edit = await editFileInExternalEditor({
|
|
1567
|
+
const edit = await this.withTerminalHandedOver(() => editFileInExternalEditor({
|
|
1568
1568
|
command: editorCommand,
|
|
1569
1569
|
path: configPath,
|
|
1570
|
-
});
|
|
1570
|
+
}));
|
|
1571
1571
|
if (edit.status === "launch-failed") {
|
|
1572
1572
|
// The editor never ran, so a file we just seeded carries no user content.
|
|
1573
1573
|
if (seeded)
|
|
@@ -3123,6 +3123,11 @@ export class InteractiveMode {
|
|
|
3123
3123
|
return state !== undefined;
|
|
3124
3124
|
},
|
|
3125
3125
|
notice: (line) => this.showWarning(line),
|
|
3126
|
+
selectModel: (model) => InteractiveMode.applyModelSelection(this, model),
|
|
3127
|
+
selectThinkingLevel: (level, remember) => InteractiveMode.applyThinkingLevel(this, level, remember),
|
|
3128
|
+
interruptTurn: async () => {
|
|
3129
|
+
await this.abortAndFireQueuedMessages();
|
|
3130
|
+
},
|
|
3126
3131
|
},
|
|
3127
3132
|
};
|
|
3128
3133
|
}
|
|
@@ -5820,17 +5825,25 @@ export class InteractiveMode {
|
|
|
5820
5825
|
async handleOpenExternalEditor() {
|
|
5821
5826
|
const editorCmd = this.settingsManager.getExternalEditorCommand();
|
|
5822
5827
|
const content = this.getExpandedEditorText();
|
|
5828
|
+
const result = await this.withTerminalHandedOver(() => editInExternalEditor({
|
|
5829
|
+
command: editorCmd,
|
|
5830
|
+
content,
|
|
5831
|
+
}));
|
|
5832
|
+
if (result.status === "complete") {
|
|
5833
|
+
this.editor.setText(result.content);
|
|
5834
|
+
}
|
|
5835
|
+
}
|
|
5836
|
+
/**
|
|
5837
|
+
* Runs `work` (an external program that draws on the terminal itself) with the terminal handed
|
|
5838
|
+
* back: the TUI stops, fd 1 and fd 2 point at the terminal again, and both are taken over again
|
|
5839
|
+
* afterwards. An editor launched without this would draw into the debug log (#2815).
|
|
5840
|
+
*/
|
|
5841
|
+
async withTerminalHandedOver(work) {
|
|
5823
5842
|
this.pauseQuestionMouseCapture();
|
|
5824
5843
|
this.ui.stop();
|
|
5825
5844
|
restoreInteractiveStderr();
|
|
5826
5845
|
try {
|
|
5827
|
-
|
|
5828
|
-
command: editorCmd,
|
|
5829
|
-
content,
|
|
5830
|
-
});
|
|
5831
|
-
if (result.status === "complete") {
|
|
5832
|
-
this.editor.setText(result.content);
|
|
5833
|
-
}
|
|
5846
|
+
return await work();
|
|
5834
5847
|
}
|
|
5835
5848
|
finally {
|
|
5836
5849
|
takeOverInteractiveStderr();
|
|
@@ -6574,18 +6587,26 @@ export class InteractiveMode {
|
|
|
6574
6587
|
*/
|
|
6575
6588
|
selectThinkingLevel(level, persist) {
|
|
6576
6589
|
try {
|
|
6577
|
-
|
|
6578
|
-
this.session.setThinkingLevel(level);
|
|
6579
|
-
else
|
|
6580
|
-
this.session.setSessionThinkingLevel(level);
|
|
6581
|
-
this.footer.invalidate();
|
|
6582
|
-
this.updateEditorBorderColor();
|
|
6583
|
-
this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`);
|
|
6590
|
+
InteractiveMode.applyThinkingLevel(this, level, persist);
|
|
6584
6591
|
}
|
|
6585
6592
|
catch (error) {
|
|
6586
6593
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
6587
6594
|
}
|
|
6588
6595
|
}
|
|
6596
|
+
/**
|
|
6597
|
+
* The level switch itself; the control endpoint's `set_thinking_level` runs it too.
|
|
6598
|
+
* Static and called through the class, so a handler invoked on a partial `this`
|
|
6599
|
+
* reaches exactly the members the switch uses.
|
|
6600
|
+
*/
|
|
6601
|
+
static applyThinkingLevel(mode, level, persist) {
|
|
6602
|
+
if (persist)
|
|
6603
|
+
mode.session.setThinkingLevel(level);
|
|
6604
|
+
else
|
|
6605
|
+
mode.session.setSessionThinkingLevel(level);
|
|
6606
|
+
mode.footer.invalidate();
|
|
6607
|
+
mode.updateEditorBorderColor();
|
|
6608
|
+
mode.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`);
|
|
6609
|
+
}
|
|
6589
6610
|
async showThinkingSelector() {
|
|
6590
6611
|
const availableLevels = this.session.getAvailableThinkingLevels();
|
|
6591
6612
|
this.showSelector((done) => {
|
|
@@ -6652,24 +6673,32 @@ export class InteractiveMode {
|
|
|
6652
6673
|
done?.();
|
|
6653
6674
|
this.ui?.requestRender();
|
|
6654
6675
|
try {
|
|
6655
|
-
|
|
6656
|
-
this.footer.invalidate();
|
|
6657
|
-
// A model switch ends any external-owner delegation episode.
|
|
6658
|
-
this.externalOwnerCompactionNoticeShown = false;
|
|
6659
|
-
this.footer?.setCompactionDelegated?.(false);
|
|
6660
|
-
this.updateEditorBorderColor();
|
|
6661
|
-
const systemPromptStr = systemPromptChange?.systemPromptName
|
|
6662
|
-
? ` (optimized system prompt applied: ${systemPromptChange.systemPromptName})`
|
|
6663
|
-
: "";
|
|
6664
|
-
this.showStatus(`Model: ${model.id}${systemPromptStr}`);
|
|
6665
|
-
this.showRiskyMainModelWarning(model);
|
|
6666
|
-
void this.maybeWarnAboutAnthropicSubscriptionAuth(model);
|
|
6667
|
-
this.checkDaxnutsEasterEgg(model);
|
|
6676
|
+
await InteractiveMode.applyModelSelection(this, model);
|
|
6668
6677
|
}
|
|
6669
6678
|
catch (error) {
|
|
6670
6679
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
6671
6680
|
}
|
|
6672
6681
|
}
|
|
6682
|
+
/**
|
|
6683
|
+
* The `/model` switch itself; the control endpoint's `set_model` runs it too, and reports a throw.
|
|
6684
|
+
* Static and called through the class for the same reason as `applyThinkingLevel`.
|
|
6685
|
+
*/
|
|
6686
|
+
static async applyModelSelection(mode, model) {
|
|
6687
|
+
const systemPromptChange = await mode.session.setModel(model);
|
|
6688
|
+
mode.footer.invalidate();
|
|
6689
|
+
// A model switch ends any external-owner delegation episode.
|
|
6690
|
+
mode.externalOwnerCompactionNoticeShown = false;
|
|
6691
|
+
mode.footer?.setCompactionDelegated?.(false);
|
|
6692
|
+
mode.updateEditorBorderColor();
|
|
6693
|
+
const systemPromptStr = systemPromptChange?.systemPromptName
|
|
6694
|
+
? ` (optimized system prompt applied: ${systemPromptChange.systemPromptName})`
|
|
6695
|
+
: "";
|
|
6696
|
+
mode.showStatus(`Model: ${model.id}${systemPromptStr}`);
|
|
6697
|
+
mode.showRiskyMainModelWarning(model);
|
|
6698
|
+
void mode.maybeWarnAboutAnthropicSubscriptionAuth(model);
|
|
6699
|
+
mode.checkDaxnutsEasterEgg(model);
|
|
6700
|
+
return systemPromptChange;
|
|
6701
|
+
}
|
|
6673
6702
|
async resolveFavoriteModelsForUi(patterns, candidateModels) {
|
|
6674
6703
|
const candidateIds = new Set(candidateModels.map(getModelFullId));
|
|
6675
6704
|
const warnings = [];
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** Hidden fd 1/fd 2 output above this size is dropped, so a child printing forever cannot fill the disk (#2815). */
|
|
2
|
+
export declare const HIDDEN_OUTPUT_LOG_MAX_BYTES: number;
|
|
3
|
+
/**
|
|
4
|
+
* Keeps the debug log under `maxBytes` while fd 1 and fd 2 are redirected into it: past the cap the
|
|
5
|
+
* file is cut back to its most recent tail behind one marker line. The redirected descriptors are
|
|
6
|
+
* opened for append, so writers keep appending at the new end. Returns whether it cut.
|
|
7
|
+
*/
|
|
8
|
+
export declare function capHiddenOutputLog(logPath: string, maxBytes?: number): boolean;
|
|
1
9
|
/** Load the fd-level capture once, before the first takeover; a no-op where it is unsupported. */
|
|
2
10
|
export declare function prepareInteractiveStderrCapture(): Promise<void>;
|
|
3
11
|
export declare function takeOverInteractiveStderr(): void;
|
|
@@ -5,10 +5,48 @@ import { getDebugLogPath } from "../../config.js";
|
|
|
5
5
|
import { restoreStderr, takeOverStderr } from "../../core/output-guard.js";
|
|
6
6
|
import { redactSensitiveOutput } from "../../core/sensitive-output.js";
|
|
7
7
|
import { loadStderrFdSyscalls, redirectStderrFd, } from "./stderr-fd-redirect.js";
|
|
8
|
+
import { redirectStdoutFd } from "./stdout-fd-redirect.js";
|
|
8
9
|
const consoleLevels = ["error", "info", "warn"];
|
|
9
10
|
let interactiveConsoleState;
|
|
10
11
|
let stderrFdSyscalls;
|
|
11
12
|
let stderrFdRedirect;
|
|
13
|
+
let stdoutFdRedirect;
|
|
14
|
+
let hiddenOutputCapTimer;
|
|
15
|
+
/** Hidden fd 1/fd 2 output above this size is dropped, so a child printing forever cannot fill the disk (#2815). */
|
|
16
|
+
export const HIDDEN_OUTPUT_LOG_MAX_BYTES = 32 * 1024 * 1024;
|
|
17
|
+
/** The most recent hidden output kept when the cap is hit. */
|
|
18
|
+
const HIDDEN_OUTPUT_LOG_KEPT_TAIL_BYTES = 256 * 1024;
|
|
19
|
+
const HIDDEN_OUTPUT_CAP_CHECK_MS = 5_000;
|
|
20
|
+
/**
|
|
21
|
+
* Keeps the debug log under `maxBytes` while fd 1 and fd 2 are redirected into it: past the cap the
|
|
22
|
+
* file is cut back to its most recent tail behind one marker line. The redirected descriptors are
|
|
23
|
+
* opened for append, so writers keep appending at the new end. Returns whether it cut.
|
|
24
|
+
*/
|
|
25
|
+
export function capHiddenOutputLog(logPath, maxBytes = HIDDEN_OUTPUT_LOG_MAX_BYTES) {
|
|
26
|
+
let size;
|
|
27
|
+
try {
|
|
28
|
+
size = fs.statSync(logPath).size;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (size <= maxBytes)
|
|
34
|
+
return false;
|
|
35
|
+
const keep = Math.min(HIDDEN_OUTPUT_LOG_KEPT_TAIL_BYTES, maxBytes);
|
|
36
|
+
const tail = Buffer.alloc(keep);
|
|
37
|
+
const fd = fs.openSync(logPath, "r");
|
|
38
|
+
let read;
|
|
39
|
+
try {
|
|
40
|
+
read = fs.readSync(fd, tail, 0, keep, size - keep);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
fs.closeSync(fd);
|
|
44
|
+
}
|
|
45
|
+
fs.truncateSync(logPath, 0);
|
|
46
|
+
fs.appendFileSync(logPath, `[${new Date().toISOString()}] debug log cut at ${size} bytes (cap ${maxBytes}); the last ${keep} bytes follow\n`);
|
|
47
|
+
fs.appendFileSync(logPath, tail.subarray(0, read));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
12
50
|
function appendHiddenInteractiveStderr(text) {
|
|
13
51
|
if (text.length === 0) {
|
|
14
52
|
return;
|
|
@@ -68,7 +106,37 @@ function takeOverStderrFd() {
|
|
|
68
106
|
stderrFdRedirect = undefined;
|
|
69
107
|
}
|
|
70
108
|
}
|
|
109
|
+
// Only a terminal stdout is the TUI's screen; a piped stdout is some caller's protocol and stays as it is.
|
|
110
|
+
function takeOverStdoutFd() {
|
|
111
|
+
if (!stderrFdSyscalls || stdoutFdRedirect || !process.stdout.isTTY)
|
|
112
|
+
return;
|
|
113
|
+
try {
|
|
114
|
+
appendHiddenInteractiveStderr("native stdout (fd 1) writes outside the TUI captured below until it releases the terminal");
|
|
115
|
+
stdoutFdRedirect = redirectStdoutFd(stderrFdSyscalls, getDebugLogPath());
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
stdoutFdRedirect = undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function startHiddenOutputCap() {
|
|
122
|
+
if (hiddenOutputCapTimer || (!stderrFdRedirect && !stdoutFdRedirect))
|
|
123
|
+
return;
|
|
124
|
+
hiddenOutputCapTimer = setInterval(() => {
|
|
125
|
+
try {
|
|
126
|
+
capHiddenOutputLog(getDebugLogPath());
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// The cap is best effort; a failed check retries on the next tick.
|
|
130
|
+
}
|
|
131
|
+
}, HIDDEN_OUTPUT_CAP_CHECK_MS);
|
|
132
|
+
hiddenOutputCapTimer.unref?.();
|
|
133
|
+
}
|
|
71
134
|
function restoreStderrFd() {
|
|
135
|
+
if (hiddenOutputCapTimer)
|
|
136
|
+
clearInterval(hiddenOutputCapTimer);
|
|
137
|
+
hiddenOutputCapTimer = undefined;
|
|
138
|
+
stdoutFdRedirect?.restore();
|
|
139
|
+
stdoutFdRedirect = undefined;
|
|
72
140
|
stderrFdRedirect?.restore();
|
|
73
141
|
stderrFdRedirect = undefined;
|
|
74
142
|
}
|
|
@@ -76,6 +144,8 @@ export function takeOverInteractiveStderr() {
|
|
|
76
144
|
takeOverStderr(appendHiddenInteractiveStderr, redactSensitiveOutput);
|
|
77
145
|
takeOverInteractiveConsole();
|
|
78
146
|
takeOverStderrFd();
|
|
147
|
+
takeOverStdoutFd();
|
|
148
|
+
startHiddenOutputCap();
|
|
79
149
|
}
|
|
80
150
|
export function restoreInteractiveStderr() {
|
|
81
151
|
restoreStderrFd();
|
|
@@ -2,7 +2,8 @@ import type { AgentSession } from "../../core/agent-session.ts";
|
|
|
2
2
|
import type { AdmissionHoldReason, QuestionRequest, QuestionResponse, SessionControlDrainResult } from "../../core/extensions/types.ts";
|
|
3
3
|
import type { ControlFeed } from "./session-control-feed.ts";
|
|
4
4
|
import { type ControlCommand, type ControlConnection } from "./session-control-server.ts";
|
|
5
|
-
|
|
5
|
+
import { type TuiSessionControls } from "./session-control-session-commands.ts";
|
|
6
|
+
export interface TuiControlSurface extends TuiSessionControls {
|
|
6
7
|
draftHold(): AdmissionHoldReason | undefined;
|
|
7
8
|
blockingQuestion(): boolean;
|
|
8
9
|
pendingQuestionIds(): readonly string[];
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The terminal control endpoint's command surface
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The terminal control endpoint's command surface. It answers who it is, which one session it holds
|
|
3
|
+
* and that session's state and messages, renames it, streams its feed, wakes its inbox drain, relays
|
|
4
|
+
* an answer to a question the session itself asked, and applies the pane's own session controls (a
|
|
5
|
+
* model, a thinking level, an interrupt - `session-control-session-commands.ts`). Nothing here can
|
|
6
|
+
* prompt, steer, queue a follow-up, open a session or run a command - one delivery authority (the
|
|
7
|
+
* registrant's drain) applies messages, through admission, and only there. Every other command is
|
|
8
|
+
* answered `unsupported` as data; `get_protocol_info` lists the accepted ones in `commands`.
|
|
8
9
|
*/
|
|
9
10
|
import { VERSION } from "../../config.js";
|
|
10
11
|
import { engineBuildIdentity } from "../../core/engine-build-identity.js";
|
|
11
12
|
import { answeredUiRequestId, settledQuestionStatus, unansweredQuestionIds } from "../rpc/extension-ui-response.js";
|
|
12
13
|
import { buildRpcSessionState } from "../rpc/rpc-session-state.js";
|
|
13
14
|
import { failure, success } from "./session-control-server.js";
|
|
15
|
+
import { runSessionControlCommand, SESSION_CONTROL_COMMANDS, } from "./session-control-session-commands.js";
|
|
14
16
|
export const TUI_CONTROL_CAPABILITY = "tui_control";
|
|
17
|
+
const TUI_CONTROL_COMMANDS = [
|
|
18
|
+
"get_protocol_info",
|
|
19
|
+
"list_sessions",
|
|
20
|
+
"get_state",
|
|
21
|
+
"get_messages",
|
|
22
|
+
"set_session_name",
|
|
23
|
+
"subscribe",
|
|
24
|
+
"wake",
|
|
25
|
+
"extension_ui_response",
|
|
26
|
+
...SESSION_CONTROL_COMMANDS,
|
|
27
|
+
];
|
|
15
28
|
export async function runControlCommand(context, connection, command) {
|
|
16
29
|
const { id, type } = command;
|
|
17
30
|
const { session } = context;
|
|
@@ -27,6 +40,7 @@ export async function runControlCommand(context, connection, command) {
|
|
|
27
40
|
generation: 0,
|
|
28
41
|
engineVersion: build.text,
|
|
29
42
|
engineOrdinal: build.ordinal,
|
|
43
|
+
commands: TUI_CONTROL_COMMANDS,
|
|
30
44
|
});
|
|
31
45
|
}
|
|
32
46
|
case "list_sessions":
|
|
@@ -63,7 +77,7 @@ export async function runControlCommand(context, connection, command) {
|
|
|
63
77
|
case "extension_ui_response":
|
|
64
78
|
return answerQuestion(context.surface, command);
|
|
65
79
|
default:
|
|
66
|
-
return failure(id, type, "unsupported");
|
|
80
|
+
return (await runSessionControlCommand(session, context.surface, command)) ?? failure(id, type, "unsupported");
|
|
67
81
|
}
|
|
68
82
|
}
|
|
69
83
|
function sessionRow(session) {
|