@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
|
@@ -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
|
}
|
|
@@ -4118,6 +4123,11 @@ export class InteractiveMode {
|
|
|
4118
4123
|
switch (event.type) {
|
|
4119
4124
|
case "agent_start":
|
|
4120
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);
|
|
4121
4131
|
this.transcriptWriteNoticeShown = false;
|
|
4122
4132
|
this.clearPendingTools();
|
|
4123
4133
|
this.clearActiveToolExecutionStatus();
|
|
@@ -4406,6 +4416,8 @@ export class InteractiveMode {
|
|
|
4406
4416
|
if (this.settingsManager.getShowTerminalProgress() && this.ui.terminal) {
|
|
4407
4417
|
this.ui.terminal.setProgress(false);
|
|
4408
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");
|
|
4409
4421
|
this.clearActiveToolExecutionStatus();
|
|
4410
4422
|
this.clearToolHookStatuses();
|
|
4411
4423
|
this.streamingReveal.stop();
|
|
@@ -4845,8 +4857,13 @@ export class InteractiveMode {
|
|
|
4845
4857
|
*/
|
|
4846
4858
|
showStatus(message) {
|
|
4847
4859
|
const children = this.chatContainer.children;
|
|
4848
|
-
|
|
4849
|
-
|
|
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;
|
|
4850
4867
|
if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
|
|
4851
4868
|
this.lastStatusMessage = message;
|
|
4852
4869
|
this.lastStatusText.invalidate();
|
|
@@ -4856,8 +4873,13 @@ export class InteractiveMode {
|
|
|
4856
4873
|
const spacer = new Spacer(1);
|
|
4857
4874
|
this.lastStatusMessage = message;
|
|
4858
4875
|
const text = new ThemedText(() => theme.fg("dim", this.lastStatusMessage), 1, 0);
|
|
4859
|
-
|
|
4860
|
-
|
|
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
|
+
}
|
|
4861
4883
|
this.lastStatusSpacer = spacer;
|
|
4862
4884
|
this.lastStatusText = text;
|
|
4863
4885
|
this.ui.requestRender();
|
|
@@ -5820,17 +5842,25 @@ export class InteractiveMode {
|
|
|
5820
5842
|
async handleOpenExternalEditor() {
|
|
5821
5843
|
const editorCmd = this.settingsManager.getExternalEditorCommand();
|
|
5822
5844
|
const content = this.getExpandedEditorText();
|
|
5845
|
+
const result = await this.withTerminalHandedOver(() => editInExternalEditor({
|
|
5846
|
+
command: editorCmd,
|
|
5847
|
+
content,
|
|
5848
|
+
}));
|
|
5849
|
+
if (result.status === "complete") {
|
|
5850
|
+
this.editor.setText(result.content);
|
|
5851
|
+
}
|
|
5852
|
+
}
|
|
5853
|
+
/**
|
|
5854
|
+
* Runs `work` (an external program that draws on the terminal itself) with the terminal handed
|
|
5855
|
+
* back: the TUI stops, fd 1 and fd 2 point at the terminal again, and both are taken over again
|
|
5856
|
+
* afterwards. An editor launched without this would draw into the debug log (#2815).
|
|
5857
|
+
*/
|
|
5858
|
+
async withTerminalHandedOver(work) {
|
|
5823
5859
|
this.pauseQuestionMouseCapture();
|
|
5824
5860
|
this.ui.stop();
|
|
5825
5861
|
restoreInteractiveStderr();
|
|
5826
5862
|
try {
|
|
5827
|
-
|
|
5828
|
-
command: editorCmd,
|
|
5829
|
-
content,
|
|
5830
|
-
});
|
|
5831
|
-
if (result.status === "complete") {
|
|
5832
|
-
this.editor.setText(result.content);
|
|
5833
|
-
}
|
|
5863
|
+
return await work();
|
|
5834
5864
|
}
|
|
5835
5865
|
finally {
|
|
5836
5866
|
takeOverInteractiveStderr();
|
|
@@ -6574,18 +6604,26 @@ export class InteractiveMode {
|
|
|
6574
6604
|
*/
|
|
6575
6605
|
selectThinkingLevel(level, persist) {
|
|
6576
6606
|
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}`);
|
|
6607
|
+
InteractiveMode.applyThinkingLevel(this, level, persist);
|
|
6584
6608
|
}
|
|
6585
6609
|
catch (error) {
|
|
6586
6610
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
6587
6611
|
}
|
|
6588
6612
|
}
|
|
6613
|
+
/**
|
|
6614
|
+
* The level switch itself; the control endpoint's `set_thinking_level` runs it too.
|
|
6615
|
+
* Static and called through the class, so a handler invoked on a partial `this`
|
|
6616
|
+
* reaches exactly the members the switch uses.
|
|
6617
|
+
*/
|
|
6618
|
+
static applyThinkingLevel(mode, level, persist) {
|
|
6619
|
+
if (persist)
|
|
6620
|
+
mode.session.setThinkingLevel(level);
|
|
6621
|
+
else
|
|
6622
|
+
mode.session.setSessionThinkingLevel(level);
|
|
6623
|
+
mode.footer.invalidate();
|
|
6624
|
+
mode.updateEditorBorderColor();
|
|
6625
|
+
mode.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`);
|
|
6626
|
+
}
|
|
6589
6627
|
async showThinkingSelector() {
|
|
6590
6628
|
const availableLevels = this.session.getAvailableThinkingLevels();
|
|
6591
6629
|
this.showSelector((done) => {
|
|
@@ -6652,24 +6690,32 @@ export class InteractiveMode {
|
|
|
6652
6690
|
done?.();
|
|
6653
6691
|
this.ui?.requestRender();
|
|
6654
6692
|
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);
|
|
6693
|
+
await InteractiveMode.applyModelSelection(this, model);
|
|
6668
6694
|
}
|
|
6669
6695
|
catch (error) {
|
|
6670
6696
|
this.showError(error instanceof Error ? error.message : String(error));
|
|
6671
6697
|
}
|
|
6672
6698
|
}
|
|
6699
|
+
/**
|
|
6700
|
+
* The `/model` switch itself; the control endpoint's `set_model` runs it too, and reports a throw.
|
|
6701
|
+
* Static and called through the class for the same reason as `applyThinkingLevel`.
|
|
6702
|
+
*/
|
|
6703
|
+
static async applyModelSelection(mode, model) {
|
|
6704
|
+
const systemPromptChange = await mode.session.setModel(model);
|
|
6705
|
+
mode.footer.invalidate();
|
|
6706
|
+
// A model switch ends any external-owner delegation episode.
|
|
6707
|
+
mode.externalOwnerCompactionNoticeShown = false;
|
|
6708
|
+
mode.footer?.setCompactionDelegated?.(false);
|
|
6709
|
+
mode.updateEditorBorderColor();
|
|
6710
|
+
const systemPromptStr = systemPromptChange?.systemPromptName
|
|
6711
|
+
? ` (optimized system prompt applied: ${systemPromptChange.systemPromptName})`
|
|
6712
|
+
: "";
|
|
6713
|
+
mode.showStatus(`Model: ${model.id}${systemPromptStr}`);
|
|
6714
|
+
mode.showRiskyMainModelWarning(model);
|
|
6715
|
+
void mode.maybeWarnAboutAnthropicSubscriptionAuth(model);
|
|
6716
|
+
mode.checkDaxnutsEasterEgg(model);
|
|
6717
|
+
return systemPromptChange;
|
|
6718
|
+
}
|
|
6673
6719
|
async resolveFavoriteModelsForUi(patterns, candidateModels) {
|
|
6674
6720
|
const candidateIds = new Set(candidateModels.map(getModelFullId));
|
|
6675
6721
|
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) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal control endpoint's session controls: a model switch, a thinking level and an interrupt.
|
|
3
|
+
* Each one runs the pane's own path (`/model`, the thinking-level selector, Esc) through the surface,
|
|
4
|
+
* so validation, the footer and persistence are the ones the user gets typing it there.
|
|
5
|
+
*/
|
|
6
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
7
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { AgentSession } from "../../core/agent-session.ts";
|
|
9
|
+
import { type ControlCommand } from "./session-control-server.ts";
|
|
10
|
+
export interface TuiSessionControls {
|
|
11
|
+
/** The `/model` path: switches, updates the footer and records the default; throws a refusal. */
|
|
12
|
+
selectModel(model: Model<string>): Promise<{
|
|
13
|
+
readonly systemPromptName?: string;
|
|
14
|
+
} | undefined>;
|
|
15
|
+
/** The thinking-level selector: `remember` is its Ctrl+S (the model's remembered level). */
|
|
16
|
+
selectThinkingLevel(level: ThinkingLevel, remember: boolean): void;
|
|
17
|
+
/** Esc on a running turn: queued input returns to the editor, the turn aborts and settles. */
|
|
18
|
+
interruptTurn(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export declare const SESSION_CONTROL_COMMANDS: readonly ["get_available_models", "get_available_thinking_levels", "set_model", "set_thinking_level", "interrupt"];
|
|
21
|
+
export declare function runSessionControlCommand(session: AgentSession, controls: TuiSessionControls, command: ControlCommand): Promise<object | undefined>;
|
|
22
|
+
//# sourceMappingURL=session-control-session-commands.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { availableModelsData, interruptRunningTurn, unsupportedThinkingLevel, } from "../rpc/session-control-actions-data.js";
|
|
2
|
+
import { failure, success } from "./session-control-server.js";
|
|
3
|
+
export const SESSION_CONTROL_COMMANDS = [
|
|
4
|
+
"get_available_models",
|
|
5
|
+
"get_available_thinking_levels",
|
|
6
|
+
"set_model",
|
|
7
|
+
"set_thinking_level",
|
|
8
|
+
"interrupt",
|
|
9
|
+
];
|
|
10
|
+
export async function runSessionControlCommand(session, controls, command) {
|
|
11
|
+
const { id, type } = command;
|
|
12
|
+
switch (type) {
|
|
13
|
+
case "get_available_models":
|
|
14
|
+
return success(id, type, await availableModelsData(session));
|
|
15
|
+
case "get_available_thinking_levels":
|
|
16
|
+
return success(id, type, { levels: session.getAvailableThinkingLevels() });
|
|
17
|
+
case "set_model":
|
|
18
|
+
return await setModel(session, controls, command);
|
|
19
|
+
case "set_thinking_level": {
|
|
20
|
+
const level = session.getAvailableThinkingLevels().find((candidate) => candidate === command.level);
|
|
21
|
+
if (level === undefined)
|
|
22
|
+
return failure(id, type, unsupportedThinkingLevel(command.level));
|
|
23
|
+
controls.selectThinkingLevel(level, command.scope !== "turn");
|
|
24
|
+
return success(id, type);
|
|
25
|
+
}
|
|
26
|
+
case "interrupt":
|
|
27
|
+
return success(id, type, await interruptRunningTurn(session, command.turnId, () => controls.interruptTurn()));
|
|
28
|
+
default:
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function setModel(session, controls, command) {
|
|
33
|
+
const { id, type } = command;
|
|
34
|
+
const models = await session.modelRegistry.getAvailable();
|
|
35
|
+
const model = models.find((candidate) => candidate.provider === command.provider && candidate.id === command.modelId);
|
|
36
|
+
if (model === undefined) {
|
|
37
|
+
return failure(id, type, `Model not found: ${String(command.provider)}/${String(command.modelId)}`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const change = await controls.selectModel(model);
|
|
41
|
+
return success(id, type, { ...model, systemPromptName: change?.systemPromptName });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return failure(id, type, error instanceof Error ? error.message : String(error));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=session-control-session-commands.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StderrFdSyscalls } from "./stderr-fd-redirect.ts";
|
|
2
|
+
type StdoutLike = NodeJS.WriteStream;
|
|
3
|
+
export interface StdoutFdRedirect {
|
|
4
|
+
restore(): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Point fd 1 at `logPath` (append, 0600) and re-point `stdout` at a duplicate of the terminal.
|
|
8
|
+
* Returns `undefined`, leaving fd 1 and `stdout` untouched, when any step fails.
|
|
9
|
+
*/
|
|
10
|
+
export declare function redirectStdoutFd(syscalls: StderrFdSyscalls, logPath: string, stdout?: StdoutLike): StdoutFdRedirect | undefined;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=stdout-fd-redirect.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-descriptor-level stdout capture for the interactive TUI (#2815).
|
|
3
|
+
*
|
|
4
|
+
* The TUI draws on fd 1. Anything else that writes fd 1 directly while the TUI owns the screen (a
|
|
5
|
+
* `writeSync(1, ...)`, a child spawned with `stdio: "inherit"`, Bun's native `console.log`, a Worker's
|
|
6
|
+
* console) scrolls the real terminal behind the renderer's back. The renderer's cursor math then goes
|
|
7
|
+
* stale and the editor is pushed below the visible screen. Mirroring the fd 2 capture
|
|
8
|
+
* (`stderr-fd-redirect.ts`, #2284): while the TUI owns the screen, fd 1 is pointed at the debug log
|
|
9
|
+
* with `dup2`, and `process.stdout` (the renderer's only writer) is re-pointed at a duplicate of the
|
|
10
|
+
* terminal descriptor, so its writes, size and resize events keep coming from the terminal itself.
|
|
11
|
+
* The original fd 1 is put back before the terminal is released.
|
|
12
|
+
*
|
|
13
|
+
* Only the interactive TUI installs this. Print, JSON and RPC modes use stdout as their protocol and
|
|
14
|
+
* never call it. Windows has no `dup`/`dup2` here (`loadStderrFdSyscalls` returns undefined), so
|
|
15
|
+
* the JS-level guard stays the only protection there.
|
|
16
|
+
*/
|
|
17
|
+
import { closeSync, createWriteStream, openSync } from "node:fs";
|
|
18
|
+
import { isatty, WriteStream as TtyWriteStream } from "node:tty";
|
|
19
|
+
const STDOUT_FD = 1;
|
|
20
|
+
// One duplicate of the terminal per process: every takeover after a suspend or an external editor
|
|
21
|
+
// reuses it, so a long session does not accumulate descriptors.
|
|
22
|
+
let terminal;
|
|
23
|
+
// Our `write` wrappers, and the `write` that was in place before the first of them. A release can leave
|
|
24
|
+
// an inactive wrapper installed (the renderer's own guard is removed after it); the next takeover wraps
|
|
25
|
+
// the base write instead, so suspend/resume cycles never stack wrappers.
|
|
26
|
+
const ownWrites = new WeakSet();
|
|
27
|
+
let baseWrite;
|
|
28
|
+
function terminalHandle(syscalls) {
|
|
29
|
+
if (terminal)
|
|
30
|
+
return terminal;
|
|
31
|
+
const fd = syscalls.dup(STDOUT_FD);
|
|
32
|
+
if (fd < 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
const stream = isatty(fd) ? new TtyWriteStream(fd) : createWriteStream("", { fd, autoClose: false });
|
|
35
|
+
terminal = { fd, stream };
|
|
36
|
+
return terminal;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Point fd 1 at `logPath` (append, 0600) and re-point `stdout` at a duplicate of the terminal.
|
|
40
|
+
* Returns `undefined`, leaving fd 1 and `stdout` untouched, when any step fails.
|
|
41
|
+
*/
|
|
42
|
+
export function redirectStdoutFd(syscalls, logPath, stdout = process.stdout) {
|
|
43
|
+
const handle = terminalHandle(syscalls);
|
|
44
|
+
if (!handle)
|
|
45
|
+
return undefined;
|
|
46
|
+
let target;
|
|
47
|
+
try {
|
|
48
|
+
target = openSync(logPath, "a", 0o600);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const moved = syscalls.dup2(target, STDOUT_FD);
|
|
54
|
+
closeSync(target);
|
|
55
|
+
if (moved < 0)
|
|
56
|
+
return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return installTerminalWriter(syscalls, handle, stdout);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Never leave fd 1 pointed at the log without a way back.
|
|
62
|
+
syscalls.dup2(handle.fd, STDOUT_FD);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function installTerminalWriter(syscalls, handle, stdout) {
|
|
67
|
+
let active = true;
|
|
68
|
+
if (!ownWrites.has(stdout.write))
|
|
69
|
+
baseWrite = stdout.write;
|
|
70
|
+
const originalWrite = baseWrite ?? stdout.write;
|
|
71
|
+
const write = function (...args) {
|
|
72
|
+
if (!active)
|
|
73
|
+
return originalWrite.apply(stdout, args);
|
|
74
|
+
return handle.stream.write.apply(handle.stream, args);
|
|
75
|
+
};
|
|
76
|
+
ownWrites.add(write);
|
|
77
|
+
stdout.write = write;
|
|
78
|
+
// fd 1 is a file now, so the stream's own size reads would come back empty: read the terminal's.
|
|
79
|
+
const sizeProperties = ["columns", "rows"];
|
|
80
|
+
const ownDescriptors = new Map(sizeProperties.map((name) => [name, Object.getOwnPropertyDescriptor(stdout, name)]));
|
|
81
|
+
for (const name of sizeProperties) {
|
|
82
|
+
Object.defineProperty(stdout, name, {
|
|
83
|
+
configurable: true,
|
|
84
|
+
enumerable: true,
|
|
85
|
+
get: () => handle.stream[name],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const onWindowChange = () => {
|
|
89
|
+
handle.stream._refreshSize?.();
|
|
90
|
+
stdout.emit("resize");
|
|
91
|
+
};
|
|
92
|
+
process.on("SIGWINCH", onWindowChange);
|
|
93
|
+
return {
|
|
94
|
+
restore() {
|
|
95
|
+
if (!active)
|
|
96
|
+
return;
|
|
97
|
+
active = false;
|
|
98
|
+
process.removeListener("SIGWINCH", onWindowChange);
|
|
99
|
+
syscalls.dup2(handle.fd, STDOUT_FD);
|
|
100
|
+
// A wrapper installed on top of ours (the renderer's own guard) keeps calling `write`, which
|
|
101
|
+
// now forwards to the original stdout on the restored fd 1.
|
|
102
|
+
if (stdout.write === write)
|
|
103
|
+
stdout.write = originalWrite;
|
|
104
|
+
for (const name of sizeProperties) {
|
|
105
|
+
const descriptor = ownDescriptors.get(name);
|
|
106
|
+
if (descriptor)
|
|
107
|
+
Object.defineProperty(stdout, name, descriptor);
|
|
108
|
+
else
|
|
109
|
+
delete stdout[name];
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=stdout-fd-redirect.js.map
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as crypto from "node:crypto";
|
|
18
18
|
import { basename, dirname, extname } from "node:path";
|
|
19
|
-
import { modelSupportsAssistantPrefill } from "@earendil-works/pi-ai";
|
|
20
19
|
import { VERSION } from "../../config.js";
|
|
21
20
|
import { authMethodStatus, buildLoginProviderInfos } from "../../core/auth-providers.js";
|
|
22
21
|
import { ContinueFromLeafError } from "../../core/continue-from-leaf.js";
|
|
@@ -27,7 +26,6 @@ import { emitProviderAccountsChanged, subscribeProviderAccountEvents, } from "..
|
|
|
27
26
|
import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID } from "../../core/extensions/builtin/anthropic-subscription/account-management.js";
|
|
28
27
|
import { isMcpControlInventoryChanged, MCP_CONTROL_INVENTORY_CHANGED_EVENT, MCP_CONTROL_INVENTORY_REQUEST_EVENT, } from "../../core/extensions/builtin/mcp/control-inventory.js";
|
|
29
28
|
import { applyFastMode, resolveServiceTierMemoryModel, } from "../../core/extensions/builtin/service-tier.js";
|
|
30
|
-
import { getSupportedThinkingLevels } from "../../core/thinking-levels.js";
|
|
31
29
|
import { theme } from "../interactive/theme/theme.js";
|
|
32
30
|
import { DURABLE_CLIENT_MESSAGE_ID_CAPABILITY } from "./client-admission-record.js";
|
|
33
31
|
import { ClientAdmissions } from "./client-admissions.js";
|
|
@@ -45,6 +43,7 @@ import { buildRpcCommandsForSession, createCommandsChangedEvent, rpcCommandListD
|
|
|
45
43
|
import { rpcCommandPayloadError, rpcCommandShapeError, rpcMessageLengthError, sessionRetryFallbackError, } from "./rpc-input-validation.js";
|
|
46
44
|
import { buildRpcSessionState } from "./rpc-session-state.js";
|
|
47
45
|
import { RPC_ERROR_MEDIA_NOT_FOUND } from "./rpc-types.js";
|
|
46
|
+
import { availableModelsData, interruptRunningTurn, unsupportedThinkingLevel } from "./session-control-actions-data.js";
|
|
48
47
|
import { SessionExtensionUiRequests } from "./session-extension-ui-requests.js";
|
|
49
48
|
export { buildRpcSessionState } from "./rpc-session-state.js";
|
|
50
49
|
/** The tool call a dialog is about, as wire fields (absent when the dialog is not a permission request). */
|
|
@@ -809,6 +808,8 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
|
|
|
809
808
|
});
|
|
810
809
|
return success(id, "abort");
|
|
811
810
|
}
|
|
811
|
+
case "interrupt":
|
|
812
|
+
return success(id, "interrupt", await interruptRunningTurn(session, command.turnId, () => session.abort()));
|
|
812
813
|
case "abort_compaction": {
|
|
813
814
|
session.abortCompaction();
|
|
814
815
|
return success(id, "abort_compaction");
|
|
@@ -891,18 +892,8 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
|
|
|
891
892
|
}
|
|
892
893
|
return success(id, "cycle_model", result);
|
|
893
894
|
}
|
|
894
|
-
case "get_available_models":
|
|
895
|
-
|
|
896
|
-
return success(id, "get_available_models", {
|
|
897
|
-
models: models.map((model) => ({
|
|
898
|
-
...model,
|
|
899
|
-
supportedThinkingLevels: getSupportedThinkingLevels(model),
|
|
900
|
-
supportsAssistantPrefill: modelSupportsAssistantPrefill(model, {
|
|
901
|
-
thinkingEnabled: session.thinkingLevel !== "off",
|
|
902
|
-
}),
|
|
903
|
-
})),
|
|
904
|
-
});
|
|
905
|
-
}
|
|
895
|
+
case "get_available_models":
|
|
896
|
+
return success(id, "get_available_models", await availableModelsData(session));
|
|
906
897
|
// =================================================================
|
|
907
898
|
// Thinking
|
|
908
899
|
// =================================================================
|
|
@@ -912,7 +903,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
|
|
|
912
903
|
// neighbour, so applying first would leave a REJECTED request's clamped level in
|
|
913
904
|
// place. A failed command must not change session state.
|
|
914
905
|
if (!session.getAvailableThinkingLevels().includes(command.level)) {
|
|
915
|
-
return error(id, "set_thinking_level",
|
|
906
|
+
return error(id, "set_thinking_level", unsupportedThinkingLevel(command.level));
|
|
916
907
|
}
|
|
917
908
|
session.setSessionThinkingLevel(command.level);
|
|
918
909
|
}
|
|
@@ -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
|
}
|