@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/bundle/chunks/{anthropic-messages-CVXI6CLT.js → anthropic-messages-OE3QBX4P.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-WZG7WYMD.js → app-server-command-N3L2LNP7.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-Q2ZTPMWD.js → chunk-4TK7VCZZ.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-SB22X6ZR.js → chunk-H4UIUFU5.js} +1 -1
  6. package/dist/bundle/chunks/chunk-I5HLTDT4.js +2 -0
  7. package/dist/bundle/chunks/{chunk-W6B7SEAK.js → chunk-MP2DMTI7.js} +48 -46
  8. package/dist/bundle/chunks/{chunk-LSE7KE2C.js → chunk-ODYQK5XJ.js} +1 -1
  9. package/dist/bundle/chunks/chunk-USRAHAW4.js +14 -0
  10. package/dist/bundle/chunks/{chunk-3YXB7Z7O.js → chunk-Z7MMKKIY.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-CTERKXVZ.js → chunk-ZSYRWQWG.js} +2 -2
  12. package/dist/bundle/chunks/{cli-main-T4T5UNRJ.js → cli-main-CHDR33FZ.js} +1 -1
  13. package/dist/bundle/chunks/{help-fast-path-44VUGB7H.js → help-fast-path-EHRZEBRO.js} +1 -1
  14. package/dist/bundle/chunks/{host-command-X64D7PT3.js → host-command-VATE7HRG.js} +1 -1
  15. package/dist/bundle/chunks/{interactive-mode-ULFSI46Z.js → interactive-mode-RVEM62WX.js} +1 -1
  16. package/dist/bundle/chunks/{multi-session-host-VM7WJBC5.js → multi-session-host-CFND7CXH.js} +1 -1
  17. package/dist/bundle/chunks/{openai-completions-6HU7GIGD.js → openai-completions-Q2LEWXEC.js} +1 -1
  18. package/dist/bundle/chunks/{openai-responses-S4QYZXF3.js → openai-responses-DHGWSTZM.js} +1 -1
  19. package/dist/bundle/chunks/{package-manager-cli-OAFUQY7E.js → package-manager-cli-NWNECBF7.js} +1 -1
  20. package/dist/bundle/chunks/{rotation-stream-ZJQ6I2RZ.js → rotation-stream-C5BM7TMM.js} +1 -1
  21. package/dist/bundle/chunks/{rpc-mode-XVRJJG3K.js → rpc-mode-JQFUDLNB.js} +1 -1
  22. package/dist/bundle/chunks/session-control-endpoint-HMB5T5VD.js +3 -0
  23. package/dist/bundle/chunks/{session-picker-R5N2EKUS.js → session-picker-O7OESJWU.js} +1 -1
  24. package/dist/bundle/chunks/session-worker.js +67 -65
  25. package/dist/bundle/cli.js +1 -1
  26. package/dist/bundle/index.js +1 -1
  27. package/dist/bundle/rpc-entry.js +1 -1
  28. package/dist/bundle/runtime-manifest.json +1 -1
  29. package/dist/core/agent-session.d.ts +2 -0
  30. package/dist/core/agent-session.js +18 -4
  31. package/dist/core/extensions/builtin/goal/lifecycle-helpers.js +1 -1
  32. package/dist/core/extensions/builtin/goal/monitor-continuation.js +3 -1
  33. package/dist/core/extensions/builtin/goal/prompt.d.ts +6 -1
  34. package/dist/core/extensions/builtin/goal/prompt.js +32 -1
  35. package/dist/core/extensions/builtin/websearch/websearch/config.js +2 -0
  36. package/dist/core/extensions/builtin/websearch/websearch/provider-endpoints.js +1 -0
  37. package/dist/core/extensions/builtin/websearch/websearch/providers/keenable.d.ts +3 -0
  38. package/dist/core/extensions/builtin/websearch/websearch/providers/keenable.js +55 -0
  39. package/dist/core/extensions/builtin/websearch/websearch/providers.js +2 -0
  40. package/dist/core/extensions/builtin/websearch/websearch/types.d.ts +1 -1
  41. package/dist/core/extensions/session-control-types.d.ts +25 -0
  42. package/dist/core/extensions/session-control-types.js +16 -0
  43. package/dist/core/external-admission.js +7 -1
  44. package/dist/core/model-config-schema.d.ts +5 -0
  45. package/dist/core/model-config-schema.js +1 -0
  46. package/dist/core/provider-composer.js +5 -1
  47. package/dist/core/provider-timeout-retry.d.ts +7 -1
  48. package/dist/core/provider-timeout-retry.js +23 -12
  49. package/dist/core/reload-veto.d.ts +1 -1
  50. package/dist/core/reload-veto.js +7 -1
  51. package/dist/core/resource-loader.js +63 -2
  52. package/dist/core/session-rebind.js +16 -1
  53. package/dist/modes/interactive/components/extension-editor.js +4 -0
  54. package/dist/modes/interactive/components/remote-delivery-message.d.ts +6 -3
  55. package/dist/modes/interactive/components/remote-delivery-message.js +32 -3
  56. package/dist/modes/interactive/interactive-mode.d.ts +17 -0
  57. package/dist/modes/interactive/interactive-mode.js +58 -29
  58. package/dist/modes/interactive/interactive-stderr-guard.d.ts +8 -0
  59. package/dist/modes/interactive/interactive-stderr-guard.js +70 -0
  60. package/dist/modes/interactive/session-control-commands.d.ts +2 -1
  61. package/dist/modes/interactive/session-control-commands.js +21 -7
  62. package/dist/modes/interactive/session-control-session-commands.d.ts +22 -0
  63. package/dist/modes/interactive/session-control-session-commands.js +47 -0
  64. package/dist/modes/interactive/stdout-fd-redirect.d.ts +12 -0
  65. package/dist/modes/interactive/stdout-fd-redirect.js +114 -0
  66. package/dist/modes/rpc/connection-handler.js +6 -15
  67. package/dist/modes/rpc/rpc-types.d.ts +13 -0
  68. package/dist/modes/rpc/session-control-actions-data.d.ts +17 -0
  69. package/dist/modes/rpc/session-control-actions-data.js +38 -0
  70. package/docs/extensions.md +1 -1
  71. package/docs/models.md +34 -0
  72. package/docs/rpc.md +38 -4
  73. package/docs/web-search.md +3 -1
  74. package/package.json +6 -6
  75. package/dist/bundle/chunks/chunk-ULYOMGXX.js +0 -14
  76. package/dist/bundle/chunks/chunk-YSPHACIN.js +0 -2
  77. package/dist/bundle/chunks/session-control-endpoint-FEGOYWHN.js +0 -3
@@ -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
- const models = await session.modelRegistry.modelRuntime.getAvailable();
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", `Thinking level ${command.level} is not supported by the active model.`);
906
+ return error(id, "set_thinking_level", unsupportedThinkingLevel(command.level));
916
907
  }
917
908
  session.setSessionThinkingLevel(command.level);
918
909
  }
@@ -73,6 +73,10 @@ type RpcSessionCommand = {
73
73
  } & ClientMessageIdentity) | {
74
74
  id?: string;
75
75
  type: "abort";
76
+ } | {
77
+ id?: string;
78
+ type: "interrupt";
79
+ turnId?: string;
76
80
  } | {
77
81
  id?: string;
78
82
  type: "abort_compaction";
@@ -832,6 +836,15 @@ export type RpcResponse = {
832
836
  type: "response";
833
837
  command: "abort";
834
838
  success: true;
839
+ } | {
840
+ id?: string;
841
+ type: "response";
842
+ command: "interrupt";
843
+ success: true;
844
+ data: {
845
+ interrupted: boolean;
846
+ turnId?: string;
847
+ };
835
848
  } | {
836
849
  id?: string;
837
850
  type: "response";
@@ -0,0 +1,17 @@
1
+ import type { AgentSession } from "../../core/agent-session.ts";
2
+ export declare function availableModelsData(session: AgentSession): Promise<{
3
+ models: readonly object[];
4
+ }>;
5
+ /** The refusal of a level the active model cannot run; callers match this text. */
6
+ export declare function unsupportedThinkingLevel(level: unknown): string;
7
+ export interface InterruptOutcome {
8
+ readonly interrupted: boolean;
9
+ readonly turnId?: string;
10
+ }
11
+ /**
12
+ * `interrupt { turnId? }`: stops the running turn and only that. An idle session answers
13
+ * `interrupted: false`; so does a `turnId` naming a turn that is no longer the running one, which
14
+ * then answers the running turn's id. `stop` resolves once the turn has stopped.
15
+ */
16
+ export declare function interruptRunningTurn(session: AgentSession, requestedTurnId: unknown, stop: () => Promise<void>): Promise<InterruptOutcome>;
17
+ //# sourceMappingURL=session-control-actions-data.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The session-control answers a multi-session host and a terminal control endpoint share, so a caller
3
+ * reads the same shapes from either: the model catalog, the thinking-level refusal, and the
4
+ * `interrupt` outcome.
5
+ */
6
+ import { modelSupportsAssistantPrefill } from "@earendil-works/pi-ai";
7
+ import { getSupportedThinkingLevels } from "../../core/thinking-levels.js";
8
+ export async function availableModelsData(session) {
9
+ const models = await session.modelRegistry.modelRuntime.getAvailable();
10
+ return {
11
+ models: models.map((model) => ({
12
+ ...model,
13
+ supportedThinkingLevels: getSupportedThinkingLevels(model),
14
+ supportsAssistantPrefill: modelSupportsAssistantPrefill(model, {
15
+ thinkingEnabled: session.thinkingLevel !== "off",
16
+ }),
17
+ })),
18
+ };
19
+ }
20
+ /** The refusal of a level the active model cannot run; callers match this text. */
21
+ export function unsupportedThinkingLevel(level) {
22
+ return `Thinking level ${String(level)} is not supported by the active model.`;
23
+ }
24
+ /**
25
+ * `interrupt { turnId? }`: stops the running turn and only that. An idle session answers
26
+ * `interrupted: false`; so does a `turnId` naming a turn that is no longer the running one, which
27
+ * then answers the running turn's id. `stop` resolves once the turn has stopped.
28
+ */
29
+ export async function interruptRunningTurn(session, requestedTurnId, stop) {
30
+ if (!session.isStreaming && session.retryAttempt === 0)
31
+ return { interrupted: false };
32
+ const turnId = String(session.externalAdmission.turnEpoch);
33
+ if (typeof requestedTurnId === "string" && requestedTurnId !== turnId)
34
+ return { interrupted: false, turnId };
35
+ await stop();
36
+ return { interrupted: true, turnId };
37
+ }
38
+ //# sourceMappingURL=session-control-actions-data.js.map
@@ -1684,7 +1684,7 @@ runtime's steering or follow-up queue like the user's queued input. The protocol
1684
1684
  |---|---|---|
1685
1685
  | `registerControlEndpoint({ inboxDir, drain, isSessionReferenced? })` | `Promise<SessionControlRegistration>` | POSIX interactive session: binds the `tui` control endpoint and answers `{ status: "registered", socket, dispose }`. On a multi-session host with a public socket it binds nothing and `socket` is the host's. Otherwise `{ status: "unsupported", reason: "unsupported_platform" \| "unsupported_mode" }`, or `{ status: "failed", reason }` when a registration step failed. `drain(event)` runs on every [`session_control_wake`](#session_control_wake) edge and answers `{ admitted: [{ delivery_id, kind }] }`. `inboxDir` is created 0700 and its entry creations and deletions wake the drain. At clean exit a header-only session file is removed unless `isSessionReferenced()` answers `true`. |
1686
1686
  | `admissionGate()` | `{ can_admit, hold_reason?, editor_revision, turn_epoch }` | Read-only pre-check; `hold_reason` is `draft` while the user composes or their submission is still on its way into the runtime. |
1687
- | `admitExternalMessage({ delivery_id, text, deliverAs, expected_turn_id? })` | `{ kind, turn_epoch }` | Decides and acts in one synchronous call. `kind` is `started`, `queued`, `steered`, `turn_conflict` (stale epoch, or a steer without one), `held_draft` (nothing enqueued; retry on the next wake) or `already_admitted` (also for a delivery whose entry the file refused, until the run that refused it has settled and the file's last write succeeded). The delivery becomes a `custom` entry `session_control_delivery` whose `details.delivery_id` proves it was applied. Throws once a host started handing the session over (`release_session`). |
1687
+ | `admitExternalMessage({ delivery_id, text, deliverAs, expected_turn_id?, sender?, display_text? })` | `{ kind, turn_epoch }` | Decides and acts in one synchronous call. `kind` is `started`, `queued`, `steered`, `turn_conflict` (stale epoch, or a steer without one), `held_draft` (nothing enqueued; retry on the next wake) or `already_admitted` (also for a delivery whose entry the file refused, until the run that refused it has settled and the file's last write succeeded). The delivery becomes a `custom` entry `session_control_delivery` whose `details.delivery_id` proves it was applied. Throws once a host started handing the session over (`release_session`). `sender` (`{ kind: "agent", session_id, name? }`, `{ kind: "command_line", user? }` or `{ kind: "external", platform, author? }`) and `display_text` (the message as written) go to the entry's `details`: the terminal labels the message by its sender over `display_text`, while the model reads `text`. |
1688
1688
  | `listAdmittedDeliveries()` | `{ pending, emitted, failed? }` | The process-lifetime ledger: `pending` is held by the runtime, `emitted` has its transcript entry written, `failed` (present only when non-empty, `[{ delivery_id, error }]`) had its entry refused by the session file: no longer held, still with its sender, admissible again once the run that refused it has settled and the file's last write succeeded. |
1689
1689
  | `persistHeaderNow()` | `Promise<void>` | Writes the session header now, so the session id is durable before anything exposes it. |
1690
1690
 
package/docs/models.md CHANGED
@@ -142,6 +142,10 @@ Set `api` at provider level (default for all models) or model level (override pe
142
142
  | `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |
143
143
  | `models` | Array of model configurations |
144
144
  | `modelOverrides` | Per-model overrides for built-in or extension-registered models on this provider |
145
+ | `whitelist` | Model ids to keep; every other model of the provider is removed (see [Hiding Providers and Models](#hiding-providers-and-models)) |
146
+ | `blacklist` | Model ids to remove from the provider |
147
+ | `hideFreeModels` | Set `true` to remove every model of the provider whose input and output cost are both `0` |
148
+ | `disabled` | Set `true` to remove the whole provider |
145
149
 
146
150
  For providers with `models`, non-built-in provider configs need `baseUrl` and an `api` value at either provider or model level. `apiKey` is not required to load the file: models become available when auth is configured through `/login`/`auth.json`, CLI `--api-key`, or provider `apiKey`. If no auth is configured, the models load but stay unavailable in `/model` and `--list-models`.
147
151
 
@@ -510,6 +514,36 @@ Merge semantics:
510
514
  - If a custom model `id` matches a built-in model `id`, the custom model replaces that built-in model.
511
515
  - If a custom model `id` is new, it is added alongside built-in models.
512
516
 
517
+ ## Hiding Providers and Models
518
+
519
+ Hidden models and providers disappear from the catalog everywhere it is read: `--list-models`, `/model`, Ctrl+P cycling, startup selection, and `enabledModels` / `favoriteModels` resolution.
520
+
521
+ ```json
522
+ {
523
+ "disabledProviders": ["openrouter"],
524
+ "providers": {
525
+ "anthropic": { "whitelist": ["claude-sonnet-4-5"] },
526
+ "openai": { "blacklist": ["gpt-5.4"] },
527
+ "opencode": { "hideFreeModels": true }
528
+ }
529
+ }
530
+ ```
531
+
532
+ | Key | Where | Effect |
533
+ |-----|-------|--------|
534
+ | `disabledProviders` | Top level, array of provider ids | Removes each listed provider and all of its models. `providers.<id>.disabled: true` does the same for one provider. |
535
+ | `whitelist` | Provider block, array of model ids | Keeps only the listed ids. |
536
+ | `blacklist` | Provider block, array of model ids | Removes the listed ids. |
537
+ | `hideFreeModels` | Provider block, boolean | Removes every model of that provider whose `cost.input` and `cost.output` are both `0`. |
538
+
539
+ Behavior notes:
540
+ - `whitelist` and `blacklist` match whole model ids exactly and case-sensitively; there are no globs. When both are set, a model must be in `whitelist` and not in `blacklist`.
541
+ - All three provider filters run after custom `models` are merged in, and before `modelOverrides`. They also apply to the provider's custom `models` entries.
542
+ - `hideFreeModels` is per provider. Other providers are unaffected, so a local provider whose models cost `0` (Ollama, LM Studio, vLLM) stays listed unless that provider sets the flag itself.
543
+ - `hideFreeModels` also hides a custom `models` entry that omits `cost`, because an omitted `cost` is `0`. Give such a model a nonzero `cost`, or leave the flag off for that provider.
544
+ - `hideFreeModels` reads the catalog `cost` (or the custom model's own `cost`). A `cost` set in `modelOverrides` is not considered.
545
+ - A provider block that sets only `whitelist`, `blacklist`, or `hideFreeModels` is valid; no `baseUrl` or `models` is needed.
546
+
513
547
  ## Per-model Overrides
514
548
 
515
549
  Use `modelOverrides` to customize built-in models and matching extension-registered models without replacing the provider's full model list.
package/docs/rpc.md CHANGED
@@ -1147,10 +1147,11 @@ registrant opens no socket and writes no registry directory.
1147
1147
  `endpoint_kind: "tui"`, `owner` and `alive`, probing it for at most 1.5 s. `senpi host gc` reaps a dead one on
1148
1148
  the same evidence as a host. `ensure`, `handoff` and `stop` refuse it with `unsupported_endpoint_kind` (exit 3)
1149
1149
  before connecting, because a terminal endpoint is owned by its terminal process.
1150
- - **Commands.** The command set is read-mostly. Anything not listed below is answered `unsupported` as data. No
1151
- command can prompt, steer, queue a follow-up, open a session, run a command or change a model.
1152
- - `get_protocol_info`: `mode: "tui"`, `capabilities: ["tui_control"]`, `generation: 0` and the endpoint's
1153
- `instanceId`.
1150
+ - **Commands.** Anything not listed below is answered `unsupported` as data. No command can prompt, steer, queue a
1151
+ follow-up, open a session or run a command.
1152
+ - `get_protocol_info`: `mode: "tui"`, `capabilities: ["tui_control"]`, `generation: 0`, the endpoint's
1153
+ `instanceId`, and `commands`: every command this endpoint accepts. A terminal from before the session
1154
+ controls below lists no `commands`.
1154
1155
  - `list_sessions`: exactly one row, `kind: "interactive"`, `surface: "tui"`, `attachments: 1`.
1155
1156
  - `get_state`: the RPC session state plus `turn_epoch`, `blocking_question`, `compacting`,
1156
1157
  `editor_has_draft` and `state_version`.
@@ -1166,6 +1167,21 @@ registrant opens no socket and writes no registry directory.
1166
1167
  question (a pending question id from the `question` feed); without it, `id` does. The answer settles by
1167
1168
  the host's rule (see `question`) and the reply carries the frame's `id`, as on a host (see "Extension
1168
1169
  UI Responses").
1170
+ - The session controls run the pane's own paths, so validation, the footer and persistence are the ones the
1171
+ user gets doing it in that terminal:
1172
+ - `get_available_models` and `get_available_thinking_levels`: the same answers as a host's.
1173
+ - `set_model { provider, modelId }`: the `/model` switch (the footer, the remembered default model, the
1174
+ pane's status line). An unknown model is refused `Model not found: <provider>/<modelId>`; a switch the
1175
+ session refuses answers its reason. Success answers the model, as on a host.
1176
+ - `set_thinking_level { level, scope? }`: the thinking-level selector. `scope: "turn"` sets this session's
1177
+ level; otherwise the level is also remembered for the model (the selector's Ctrl+S). A level the active
1178
+ model cannot run is refused `Thinking level <level> is not supported by the active model.` and nothing
1179
+ changes, for either scope. A host refuses it only for `scope: "turn"`; for the default scope a host clamps it
1180
+ to a level the model runs.
1181
+ - `interrupt { turnId? }`: Esc on a running turn - queued input returns to the editor, the turn aborts and
1182
+ settles before the answer. Answers `{ interrupted: true, turnId }` with the `turn_epoch` it stopped. An idle
1183
+ session answers `{ interrupted: false }`; a `turnId` that is not the running turn answers
1184
+ `{ interrupted: false, turnId }` with the running one and leaves it alone. The session stays open.
1169
1185
  - `prompt`, `steer` and `follow_up` are `unsupported`.
1170
1186
  - **Admission.** A message from another session enters only through the registrant's drain, which calls
1171
1187
  `pi.session.admitExternalMessage({ delivery_id, text, deliverAs, expected_turn_id? })`. One synchronous call
@@ -1726,6 +1742,24 @@ Response:
1726
1742
  {"type": "response", "command": "abort", "success": true}
1727
1743
  ```
1728
1744
 
1745
+
1746
+ #### interrupt
1747
+
1748
+ Stop the running turn and only that one, with the terminal control endpoint's contract (see "Interactive sessions
1749
+ expose a control endpoint"). `turnId` (the `turn_epoch` of `get_state`) is optional.
1750
+
1751
+ ```json
1752
+ {"type": "interrupt", "turnId": "3"}
1753
+ ```
1754
+
1755
+ Response: `{ interrupted: true, turnId }` once the turn has stopped; `{ interrupted: false }` when the session is
1756
+ idle; `{ interrupted: false, turnId }` (the running turn) when `turnId` names another turn. Unlike `abort`, it
1757
+ answers after the turn settled.
1758
+
1759
+ ```json
1760
+ {"type": "response", "command": "interrupt", "success": true, "data": {"interrupted": true, "turnId": "3"}}
1761
+ ```
1762
+
1729
1763
  #### clear_queue
1730
1764
 
1731
1765
  Remove queued steering and follow-up messages and return their text.
@@ -75,7 +75,9 @@ A config file replaces the free default completely: senpi uses only the provider
75
75
  | `nativeModel` | The model native search runs on; see [Choosing the model native search runs on](#choosing-the-model-native-search-runs-on). |
76
76
  | `providers[]` | The providers to use. Each entry takes `provider`, and optionally `id`, `apiKey`, `baseUrl`, `maxResults`, `timeoutMs`, `priority`, `weight`, `allowedDomains` or `blockedDomains`. |
77
77
 
78
- Free engines you can list without a key: `duckduckgo-html`, `exa-mcp`, `startpage`, `mojeek`, `ecosia`, `google-html`, and `searxng`. Providers that need `apiKey`: `exa`, `tavily`, `brave`, `serper`, `serpdive`, `kagi`, `perplexity`, `z-ai`, `xai`, `kimi`, `deepseek`, `anthropic`, `openai` (plus `searchEngineId` for `google-cse`). `chatgpt-subscription` and `google` use your senpi login when `apiKey` is omitted; see [Listing login-based routes](#listing-login-based-routes).
78
+ Free engines you can list without a key: `duckduckgo-html`, `exa-mcp`, `startpage`, `mojeek`, `ecosia`, `google-html`, `searxng`, and `keenable`. Providers that need `apiKey`: `exa`, `tavily`, `brave`, `serper`, `serpdive`, `kagi`, `perplexity`, `z-ai`, `xai`, `kimi`, `deepseek`, `anthropic`, `openai` (plus `searchEngineId` for `google-cse`). `chatgpt-subscription` and `google` use your senpi login when `apiKey` is omitted; see [Listing login-based routes](#listing-login-based-routes).
79
+
80
+ `keenable` ([Keenable](https://keenable.ai)) takes an `apiKey` but also works without one: a keyed entry posts to `https://api.keenable.ai/v1/search` with `X-API-Key`, while a keyless entry uses the shared public tier at `/v1/search/public` and identifies itself with an `X-Keenable-Title` app header instead of a credential. Because the public tier is a per-IP pool, `keenable` is never in the no-config default chain — list it explicitly in `websearch.json`. A single `allowedDomains` entry maps to Keenable's native `site` filter; multiple or blocked domains stay `site:`/`-site:` terms in the query.
79
81
 
80
82
  ## Limit or turn off the free engines
81
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.10-2",
3
+ "version": "2026.10.10-4",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@earendil-works/chord": "0.99.1",
60
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-2",
61
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-2",
62
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-2",
60
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-4",
61
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-4",
62
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-4",
63
63
  "@silvia-odwyer/photon-node": "0.3.4",
64
64
  "chalk": "6.0.0",
65
65
  "cross-spawn": "7.0.6",
@@ -78,8 +78,8 @@
78
78
  "yaml": "2.9.1",
79
79
  "@anthropic-ai/claude-agent-sdk": "0.3.289",
80
80
  "@anthropic-ai/sdk": "0.127.0",
81
- "@code-yeongyu/senpi-codemode": "2026.10.10-2",
82
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-2",
81
+ "@code-yeongyu/senpi-codemode": "2026.10.10-4",
82
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-4",
83
83
  "@modelcontextprotocol/sdk": "1.30.0",
84
84
  "@mozilla/readability": "0.6.0",
85
85
  "linkedom": "0.18.13",