@code-yeongyu/senpi 2026.10.9 → 2026.10.10-3

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 (113) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/bundle/chunks/{anthropic-messages-CVXI6CLT.js → anthropic-messages-OE3QBX4P.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-2HNDZGTR.js → app-server-command-DVWM37GF.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-MJGHZT4N.js → chunk-3A3C5VVO.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-Q2ZTPMWD.js → chunk-4TK7VCZZ.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-CB77NQMN.js → chunk-7GVD54RM.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-6EH7QXOG.js → chunk-CTERKXVZ.js} +2 -2
  8. package/dist/bundle/chunks/{chunk-SB22X6ZR.js → chunk-H4UIUFU5.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-CM5QTRC2.js → chunk-H67EVDWG.js} +1 -1
  10. package/dist/bundle/chunks/chunk-I5HLTDT4.js +2 -0
  11. package/dist/bundle/chunks/chunk-IZOTKUWG.js +2 -0
  12. package/dist/bundle/chunks/{chunk-DERNLWDS.js → chunk-MT2C4I5R.js} +151 -148
  13. package/dist/bundle/chunks/{chunk-LSE7KE2C.js → chunk-ODYQK5XJ.js} +1 -1
  14. package/dist/bundle/chunks/chunk-PDSG6VER.js +14 -0
  15. package/dist/bundle/chunks/{chunk-LUWWM7GA.js → chunk-RADHRQ4K.js} +3 -3
  16. package/dist/bundle/chunks/chunk-RRU5LXMS.js +3 -0
  17. package/dist/bundle/chunks/{chunk-7Q3EY6HH.js → chunk-UMJL74VN.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-3YXB7Z7O.js → chunk-Z7MMKKIY.js} +1 -1
  19. package/dist/bundle/chunks/{cli-main-VMDAGZCA.js → cli-main-B4S4DGBX.js} +1 -1
  20. package/dist/bundle/chunks/{help-fast-path-JBQLYO7W.js → help-fast-path-EHRZEBRO.js} +1 -1
  21. package/dist/bundle/chunks/{host-command-HTYYQBSG.js → host-command-VATE7HRG.js} +1 -1
  22. package/dist/bundle/chunks/{host-lifecycle-XZF6IKKO.js → host-lifecycle-GC3GIR2N.js} +1 -1
  23. package/dist/bundle/chunks/{interactive-mode-U7IH4VMJ.js → interactive-mode-QOFA64JL.js} +1 -1
  24. package/dist/bundle/chunks/{multi-session-host-HHIC6N7C.js → multi-session-host-E5RDPWQN.js} +1 -1
  25. package/dist/bundle/chunks/{openai-completions-6HU7GIGD.js → openai-completions-Q2LEWXEC.js} +1 -1
  26. package/dist/bundle/chunks/{openai-responses-S4QYZXF3.js → openai-responses-DHGWSTZM.js} +1 -1
  27. package/dist/bundle/chunks/{package-manager-cli-2FXWPM2U.js → package-manager-cli-YGTWXRG5.js} +1 -1
  28. package/dist/bundle/chunks/{rotation-stream-ZJQ6I2RZ.js → rotation-stream-C5BM7TMM.js} +1 -1
  29. package/dist/bundle/chunks/{rpc-mode-K5YGBGQO.js → rpc-mode-HDAGQ4X3.js} +1 -1
  30. package/dist/bundle/chunks/{schedule-command-CVAPK2WQ.js → schedule-command-EQ4PQCRP.js} +1 -1
  31. package/dist/bundle/chunks/{session-control-endpoint-THEF3K7M.js → session-control-endpoint-NABTZHCZ.js} +2 -2
  32. package/dist/bundle/chunks/{session-picker-R5N2EKUS.js → session-picker-O7OESJWU.js} +1 -1
  33. package/dist/bundle/chunks/session-worker.js +70 -67
  34. package/dist/bundle/cli.js +1 -1
  35. package/dist/bundle/index.js +1 -1
  36. package/dist/bundle/rpc-entry.js +1 -1
  37. package/dist/bundle/runtime-manifest.json +1 -1
  38. package/dist/core/agent-session-runtime.d.ts +9 -0
  39. package/dist/core/agent-session-runtime.js +22 -0
  40. package/dist/core/agent-session.d.ts +14 -1
  41. package/dist/core/agent-session.js +56 -8
  42. package/dist/core/extensions/builtin/btw/panel.d.ts +3 -1
  43. package/dist/core/extensions/builtin/btw/panel.js +14 -9
  44. package/dist/core/extensions/builtin/compaction/context-pipeline.d.ts +5 -0
  45. package/dist/core/extensions/builtin/compaction/context-pipeline.js +1 -0
  46. package/dist/core/extensions/builtin/compaction/index.js +2 -1
  47. package/dist/core/extensions/builtin/compaction/lane-policy.d.ts +7 -0
  48. package/dist/core/extensions/builtin/compaction/lane-policy.js +28 -15
  49. package/dist/core/extensions/builtin/compaction/log.d.ts +2 -0
  50. package/dist/core/extensions/builtin/compaction/log.js +2 -1
  51. package/dist/core/extensions/builtin/goal/cache-warm-renderer.js +14 -8
  52. package/dist/core/extensions/builtin/goal/cache-warm.d.ts +4 -2
  53. package/dist/core/extensions/builtin/goal/lifecycle-helpers.js +1 -1
  54. package/dist/core/extensions/builtin/goal/monitor-continuation.js +9 -1
  55. package/dist/core/extensions/builtin/goal/prompt.d.ts +6 -1
  56. package/dist/core/extensions/builtin/goal/prompt.js +32 -1
  57. package/dist/core/extensions/builtin/mcp/commands.js +21 -26
  58. package/dist/core/extensions/builtin/mcp/config-edit.d.ts +2 -0
  59. package/dist/core/extensions/builtin/mcp/config-edit.js +5 -2
  60. package/dist/core/extensions/builtin/mcp/manager-view.d.ts +34 -0
  61. package/dist/core/extensions/builtin/mcp/manager-view.js +136 -0
  62. package/dist/core/extensions/builtin/mcp/manager.d.ts +8 -0
  63. package/dist/core/extensions/builtin/mcp/manager.js +193 -0
  64. package/dist/core/extensions/builtin/mcp/service.d.ts +3 -1
  65. package/dist/core/extensions/builtin/mcp/service.js +4 -0
  66. package/dist/core/extensions/builtin/rules/rules/finder.js +8 -3
  67. package/dist/core/extensions/builtin/websearch/websearch/config.js +2 -0
  68. package/dist/core/extensions/builtin/websearch/websearch/provider-endpoints.js +1 -0
  69. package/dist/core/extensions/builtin/websearch/websearch/providers/keenable.d.ts +3 -0
  70. package/dist/core/extensions/builtin/websearch/websearch/providers/keenable.js +55 -0
  71. package/dist/core/extensions/builtin/websearch/websearch/providers.js +2 -0
  72. package/dist/core/extensions/builtin/websearch/websearch/types.d.ts +1 -1
  73. package/dist/core/model-config-schema.d.ts +5 -0
  74. package/dist/core/model-config-schema.js +1 -0
  75. package/dist/core/provider-composer.js +5 -1
  76. package/dist/core/provider-timeout-retry.d.ts +7 -1
  77. package/dist/core/provider-timeout-retry.js +23 -12
  78. package/dist/core/reload-veto.d.ts +1 -1
  79. package/dist/core/reload-veto.js +7 -1
  80. package/dist/core/retry-fallback/expansion.js +9 -3
  81. package/dist/core/sdk.js +7 -3
  82. package/dist/core/thinking-levels.d.ts +23 -1
  83. package/dist/core/thinking-levels.js +37 -0
  84. package/dist/core/trust-manager.js +50 -0
  85. package/dist/main.js +12 -13
  86. package/dist/modes/interactive/interactive-mode.js +8 -0
  87. package/dist/modes/interactive/session-control-endpoint.js +33 -8
  88. package/dist/modes/interactive/session-control-registry.js +3 -1
  89. package/dist/modes/interactive/session-control-wake.d.ts +11 -5
  90. package/dist/modes/interactive/session-control-wake.js +38 -16
  91. package/dist/modes/rpc/connection-handler.d.ts +5 -0
  92. package/dist/modes/rpc/connection-handler.js +30 -3
  93. package/dist/modes/rpc/custom-capability.d.ts +6 -0
  94. package/dist/modes/rpc/custom-capability.js +6 -0
  95. package/dist/modes/rpc/host-daemon-paths.d.ts +5 -4
  96. package/dist/modes/rpc/host-daemon-paths.js +17 -9
  97. package/dist/modes/rpc/host-daemon-registration.d.ts +6 -1
  98. package/dist/modes/rpc/host-daemon-registration.js +6 -2
  99. package/dist/modes/rpc/host-session-control.js +6 -4
  100. package/dist/modes/rpc/rpc-client.d.ts +5 -0
  101. package/dist/modes/rpc/rpc-client.js +7 -0
  102. package/dist/modes/rpc/rpc-mode.js +1 -1
  103. package/dist/modes/rpc/rpc-types.d.ts +9 -0
  104. package/docs/json.md +3 -0
  105. package/docs/mcp.md +16 -1
  106. package/docs/models.md +34 -0
  107. package/docs/rpc.md +36 -1
  108. package/docs/web-search.md +3 -1
  109. package/package.json +6 -6
  110. package/dist/bundle/chunks/chunk-6SMU7ANE.js +0 -3
  111. package/dist/bundle/chunks/chunk-M3BQ2PAQ.js +0 -14
  112. package/dist/bundle/chunks/chunk-VJJ53ORU.js +0 -2
  113. package/dist/bundle/chunks/chunk-YSPHACIN.js +0 -2
@@ -14,6 +14,43 @@ export function getSupportedThinkingLevels(model) {
14
14
  const supportedLevels = getSupportedModelThinkingLevels(model);
15
15
  return supportedLevels.length > 0 ? supportedLevels : ["off"];
16
16
  }
17
+ /**
18
+ * Apply the effective `level` to an explicit selection. When it differs from `requested`, the selection keeps
19
+ * the requested level and the clamp reason instead of reading as if `level` had been chosen.
20
+ */
21
+ export function clampThinkingSelection(selection, requested, level, model) {
22
+ if (selection === undefined)
23
+ return undefined;
24
+ const { requested: _staleRequested, clampReason: _staleReason, ...base } = selection;
25
+ if (requested === level || model === undefined)
26
+ return { ...base, level };
27
+ const clamped = {
28
+ ...base,
29
+ level,
30
+ requested,
31
+ clampReason: model.reasoning ? "level-unsupported" : "model-not-reasoning",
32
+ };
33
+ return clamped;
34
+ }
35
+ export function getThinkingClampNotice(selection, model) {
36
+ const clamped = selection;
37
+ if (model === undefined || clamped?.requested === undefined || clamped.clampReason === undefined)
38
+ return undefined;
39
+ return {
40
+ provider: model.provider,
41
+ modelId: model.id,
42
+ requestedLevel: clamped.requested,
43
+ appliedLevel: clamped.level,
44
+ reason: clamped.clampReason,
45
+ };
46
+ }
47
+ export function formatThinkingClampWarning(notice) {
48
+ const model = `${notice.provider}/${notice.modelId}`;
49
+ if (notice.reason === "model-not-reasoning") {
50
+ return `Thinking level "${notice.requestedLevel}" was requested, but ${model} is not marked as a reasoning model, so thinking is "${notice.appliedLevel}". If the model supports reasoning, set "reasoning": true for it in models.json.`;
51
+ }
52
+ return `Thinking level "${notice.requestedLevel}" is not available on ${model}; using "${notice.appliedLevel}".`;
53
+ }
17
54
  /**
18
55
  * Classify a model's reasoning capability purely from `model.reasoning` and its supported thinking
19
56
  * levels — never from the model id or thinkingFormat/compat config.
@@ -5,6 +5,7 @@ import lockfile from "proper-lockfile";
5
5
  import { CONFIG_DIR_NAME } from "../config.js";
6
6
  import { canonicalizePath, canonicalizePathStrict, resolvePath } from "../utils/paths.js";
7
7
  import { stripBom } from "../utils/text.js";
8
+ import { bundledBuiltinExtensions } from "./bundled-resources.js";
8
9
  const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
9
10
  "settings.json",
10
11
  "mcp.json",
@@ -16,6 +17,53 @@ const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
16
17
  "APPEND_SYSTEM.md",
17
18
  ];
18
19
  const LEGACY_PROJECT_CONFIG_DIR_NAME = ".pi";
20
+ /**
21
+ * A project codemode file that sets any setting naming an executable (the codemode package's
22
+ * `executable-settings.json` list, today `languages.pyInterpreter`) makes the session run that executable at start,
23
+ * so it asks for a trust decision. A file the check cannot read is treated like a project `mcp.json`, whose mere
24
+ * presence asks; so is any file when the list itself cannot be read.
25
+ */
26
+ function projectCodemodeNamesExecutable(configDir) {
27
+ const path = join(configDir, "codemode.json");
28
+ if (!existsSync(path))
29
+ return false;
30
+ const executableSettings = codemodeExecutableSettings();
31
+ if (executableSettings === undefined)
32
+ return true;
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(stripBom(readFileSync(path, "utf8")));
36
+ }
37
+ catch {
38
+ return true;
39
+ }
40
+ return executableSettings.some((settingPath) => valueAtPath(parsed, settingPath) !== undefined);
41
+ }
42
+ function codemodeExecutableSettings() {
43
+ const codemode = bundledBuiltinExtensions.find((extension) => extension.id === "codemode");
44
+ try {
45
+ const packageJson = codemode?.resolvePackage();
46
+ if (packageJson === undefined)
47
+ return undefined;
48
+ const list = JSON.parse(readFileSync(join(dirname(packageJson), "src", "config", "executable-settings.json"), "utf8"));
49
+ if (typeof list !== "object" || list === null)
50
+ return undefined;
51
+ const paths = Reflect.get(list, "projectExecutableSettings");
52
+ return Array.isArray(paths) && paths.every((entry) => typeof entry === "string") ? paths : undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ function valueAtPath(value, path) {
59
+ let current = value;
60
+ for (const key of path.split(".")) {
61
+ if (typeof current !== "object" || current === null)
62
+ return undefined;
63
+ current = Reflect.get(current, key);
64
+ }
65
+ return current;
66
+ }
19
67
  function normalizeCwd(cwd) {
20
68
  const resolved = resolvePath(cwd);
21
69
  try {
@@ -169,6 +217,8 @@ export function hasTrustRequiringProjectResources(cwd) {
169
217
  if (configDirs.some((configDir) => TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry))))) {
170
218
  return true;
171
219
  }
220
+ if (projectCodemodeNamesExecutable(join(currentDir, CONFIG_DIR_NAME)))
221
+ return true;
172
222
  while (true) {
173
223
  const agentsSkillsDir = join(currentDir, ".agents", "skills");
174
224
  if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
package/dist/main.js CHANGED
@@ -29,7 +29,7 @@ import { createProjectTrustContext } from "./cli/project-trust.js";
29
29
  import { createStartupLoadingIndicator, pauseIndicatorDuringPrompts, shouldShowStartupLoadingIndicator, } from "./cli/startup-loading-indicator.js";
30
30
  import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.js";
31
31
  import { APP_NAME, DISPLAY_VERSION, ENV_SESSION_DIR, expandTildePath, getAgentDir, getInstallPackageDir, } from "./config.js";
32
- import { createAgentSessionRuntime } from "./core/agent-session-runtime.js";
32
+ import { applyRetryFallbackProfile, createAgentSessionRuntime, } from "./core/agent-session-runtime.js";
33
33
  import { createAgentSessionFromServices, createAgentSessionServices, } from "./core/agent-session-services.js";
34
34
  import { formatNoModelsAvailableMessage } from "./core/auth-guidance.js";
35
35
  import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.js";
@@ -51,6 +51,7 @@ import { assertValidSessionId, SessionManager } from "./core/session-manager.js"
51
51
  import { classifySessionRepository, readSessionCwd, rebindSessionFile } from "./core/session-rebind.js";
52
52
  import { collectSettingsDiagnosticsWithContext } from "./core/settings-diagnostics.js";
53
53
  import { SettingsManager } from "./core/settings-manager.js";
54
+ import { formatThinkingClampWarning } from "./core/thinking-levels.js";
54
55
  import { printTimings, recordTiming, resetTimings, time } from "./core/timings.js";
55
56
  import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.js";
56
57
  import { builtInExtensions } from "./extensions/index.js";
@@ -678,17 +679,8 @@ export function createCliRuntimeFactory(configuration, local = {}) {
678
679
  (!hasTrustRequiringResources || trustStore.get(cwd) === true));
679
680
  const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
680
681
  // The opener's per-session fallback policy is an in-memory override: never saved, never shared.
681
- if (launchProfile?.retryFallback) {
682
- runtimeSettingsManager.applyOverrides({
683
- retry: {
684
- modelFallback: launchProfile.retryFallback.modelFallback,
685
- fallbackChains: Object.fromEntries(Object.entries(launchProfile.retryFallback.fallbackChains).map(([key, entries]) => [
686
- key,
687
- [...entries],
688
- ])),
689
- },
690
- });
691
- }
682
+ if (launchProfile?.retryFallback)
683
+ applyRetryFallbackProfile(runtimeSettingsManager, launchProfile.retryFallback);
692
684
  const services = await createAgentSessionServices({
693
685
  cwd,
694
686
  agentDir,
@@ -830,7 +822,9 @@ export function createCliRuntimeFactory(configuration, local = {}) {
830
822
  markSwitch("createSession");
831
823
  const cliThinkingOverride = runtimeParsed.thinking !== undefined || cliThinkingFromModel;
832
824
  if (created.session.model && cliThinkingOverride) {
833
- created.session.setThinkingLevel(created.session.thinkingLevel);
825
+ // senpi#2395: re-apply the level the CLI asked for, so a clamp keeps its requested level and reason.
826
+ const selection = created.session.thinkingSelection;
827
+ created.session.setThinkingLevel(selection?.requested ?? created.session.thinkingLevel);
834
828
  }
835
829
  return {
836
830
  ...created,
@@ -1184,6 +1178,11 @@ export async function main(args, options) {
1184
1178
  }
1185
1179
  time("resolveModelScope");
1186
1180
  reportDiagnostics(runtime.diagnostics);
1181
+ // senpi#2395: print/json/RPC runs report a startup thinking clamp on stderr; the TUI shows its own warning.
1182
+ const startupThinkingClamp = runtime.session.startupThinkingClamp;
1183
+ if (appMode !== "interactive" && startupThinkingClamp) {
1184
+ reportDiagnostics([{ type: "warning", message: formatThinkingClampWarning(startupThinkingClamp) }]);
1185
+ }
1187
1186
  if (appMode !== "interactive") {
1188
1187
  reportDiagnostics(collectAuthDiagnostics(services.authStorage, "runtime creation"));
1189
1188
  }
@@ -39,6 +39,7 @@ import { createSessionLogger } from "../../core/session-log.js";
39
39
  import { SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.js";
40
40
  import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
41
41
  import { isInstallTelemetryEnabled } from "../../core/telemetry.js";
42
+ import { formatThinkingClampWarning } from "../../core/thinking-levels.js";
42
43
  import { formatTimings, resetTimings, time } from "../../core/timings.js";
43
44
  import { withBuiltInRenderers } from "../../core/tools/renderers/index.js";
44
45
  import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.js";
@@ -1354,6 +1355,10 @@ export class InteractiveMode {
1354
1355
  for (const warning of this.session.fallbackValidationWarnings) {
1355
1356
  this.showWarning(warning);
1356
1357
  }
1358
+ const startupThinkingClamp = this.session.startupThinkingClamp;
1359
+ if (startupThinkingClamp) {
1360
+ this.showWarning(formatThinkingClampWarning(startupThinkingClamp));
1361
+ }
1357
1362
  this.showRiskyMainModelWarning(this.session.model);
1358
1363
  void this.maybeWarnAboutAnthropicSubscriptionAuth();
1359
1364
  maybeShowRuntimeNotice((spec) => this.showNoticeBox(spec));
@@ -4207,6 +4212,9 @@ export class InteractiveMode {
4207
4212
  case "high_reasoning_warning":
4208
4213
  this.showHighReasoningWarning(event);
4209
4214
  break;
4215
+ case "thinking_level_clamped":
4216
+ this.showWarning(formatThinkingClampWarning(event));
4217
+ break;
4210
4218
  case "resume_compaction_required":
4211
4219
  this.showWarning(event.notice);
4212
4220
  break;
@@ -3,12 +3,16 @@
3
3
  * registers one (the interactive host imports it lazily), so a plain TUI pays nothing.
4
4
  *
5
5
  * Order matters and each step is undone on failure, so a failed bind leaves no half-registered
6
- * directory: the session header is made durable, dead `tui` endpoints are reaped, the socket is
7
- * bound (secret 0600, directory 0700), and only then is the endpoint registered - a visible
8
- * endpoint is one that answers. Wakes come from edges only (see `session-control-wake.ts`).
6
+ * directory: the socket is bound (secret 0600, directory 0700) while the session header is made
7
+ * durable, and only once both are done is the endpoint registered - a visible endpoint is one that
8
+ * answers, and its session id is already on disk. Nothing a sender needs waits on work it does not
9
+ * need: the writer stamp's process lookup starts at entry, the inbox watch arms after registration
10
+ * returned, and dead `tui` endpoints of other terminals are reaped after activation. Wakes come
11
+ * from edges only (see `session-control-wake.ts`).
9
12
  */
10
13
  import { randomUUID } from "node:crypto";
11
14
  import { rm } from "node:fs/promises";
15
+ import { thisProcessStartTime } from "../rpc/host-daemon-registration.js";
12
16
  import { gcHostEndpoints } from "../rpc/host-gc.js";
13
17
  import { createSocketSecret, socketSecretPath } from "../rpc/socket-transport.js";
14
18
  import { runControlCommand } from "./session-control-commands.js";
@@ -33,8 +37,12 @@ export async function registerSessionControlEndpoint(context, options) {
33
37
  }
34
38
  async function openEndpoint(context, options, cleanups) {
35
39
  const { session, agentDir, surface } = context;
36
- await session.sessionManager.persistHeaderNow();
37
- await gcHostEndpoints(agentDir, { kinds: ["tui"] });
40
+ // The writer stamp's `ps` lookup is a process constant: started here, it overlaps the header write
41
+ // and the bind instead of running inside the registry lock.
42
+ void thisProcessStartTime();
43
+ // Bound while the header is written, awaited before the endpoint is registered.
44
+ const headerDurable = session.sessionManager.persistHeaderNow();
45
+ headerDurable.catch(() => undefined);
38
46
  const instanceId = randomUUID();
39
47
  const socket = await resolveTuiSocket(agentDir, instanceId);
40
48
  cleanups.push(() => rm(socket, { force: true }), () => rm(socketSecretPath(socket), { force: true }));
@@ -59,12 +67,29 @@ async function openEndpoint(context, options, cleanups) {
59
67
  listenerError: (error) => surface.notice(`control endpoint listener failed: ${error.message}`),
60
68
  });
61
69
  cleanups.push(() => server.close());
70
+ await headerDurable;
62
71
  const entry = await registerTuiEndpoint({ agentDir, socket, instanceId });
63
72
  cleanups.push(() => unregisterTuiEndpoint(entry));
64
- const stopInbox = await watchInbox(options.inboxDir, () => void wake("inbox"), (error) => surface.notice(`control endpoint inbox watch failed: ${errorText(error)}`));
65
- const endpoint = activateControlEndpoint({ context, options, entry, server, scheduler, feed, stopInbox, wake });
66
- // Anything that reached the inbox before the watch was armed is picked up by this first pass.
73
+ const inbox = await watchInbox(options.inboxDir, () => void wake("inbox"), (error) => surface.notice(`control endpoint inbox watch failed: ${errorText(error)}`));
74
+ cleanups.push(() => inbox.stop());
75
+ const endpoint = activateControlEndpoint({
76
+ context,
77
+ options,
78
+ entry,
79
+ server,
80
+ scheduler,
81
+ feed,
82
+ stopInbox: inbox.stop,
83
+ wake,
84
+ });
85
+ // Anything that reached the inbox before this point is picked up by this first pass, and anything
86
+ // written after it but before the watch was armed (no event for it) by the pass once arming settled.
67
87
  endpoint.wake("inbox");
88
+ void inbox.armed.then(() => endpoint.wake("inbox"));
89
+ // Other terminals' dead records are nothing a sender reads: reaped after activation, off the path.
90
+ setImmediate(() => {
91
+ gcHostEndpoints(agentDir, { kinds: ["tui"] }).catch((error) => surface.notice(`control endpoint gc of dead terminals failed: ${errorText(error)}`));
92
+ });
68
93
  return endpoint;
69
94
  }
70
95
  function errorText(error) {
@@ -5,6 +5,8 @@
5
5
  * Registration runs under the endpoint's ensure lock (the lock `host gc` takes) and writes the
6
6
  * generation record BEFORE `endpoint.json`: a reader that can see the endpoint can always see a
7
7
  * live generation behind it, so the endpoint is never judged `dead` while it is being registered.
8
+ * The directory is named by a socket built from a fresh instance id, so it never holds an earlier
9
+ * generation and the registration skips the dead-generation prune a host's directory needs.
8
10
  * A TUI endpoint has nothing to reattach to, so a clean exit removes its whole directory.
9
11
  */
10
12
  import { createHash } from "node:crypto";
@@ -66,7 +68,7 @@ export async function registerTuiEndpoint(options) {
66
68
  generation: 0,
67
69
  launchProfileId: "tui",
68
70
  build: engineBuildIdentity(),
69
- });
71
+ }, { fresh: true });
70
72
  await createDaemonDirectories(paths, { kind: "tui" });
71
73
  }
72
74
  finally {
@@ -14,17 +14,23 @@ export declare class WakeScheduler {
14
14
  }
15
15
  /** Prefix of the engine's own entries in an inbox; a registrant never treats them as deliveries. */
16
16
  export declare const INBOX_ENGINE_ENTRY_PREFIX = ".senpi-";
17
+ /** A live inbox watch. `armed` settles once arming did: the watch confirmed, the bounded retry ran out, or it was stopped. */
18
+ export interface InboxWatch {
19
+ readonly armed: Promise<void>;
20
+ stop(): void;
21
+ }
17
22
  /**
18
23
  * One watch on the inbox directory. Creation and teardown run on the shared watch worker (FSEvents
19
24
  * and inotify setup/teardown block the calling thread), so neither ever stalls the terminal.
20
25
  *
21
26
  * The worker arms the watch asynchronously, and an entry written before it is armed produces no
22
- * event. So registration returns only once a sentinel entry's own event has come back - the
23
- * sentinel is re-touched until then, bounded, which is the only timer here and runs only while
24
- * arming (an unconfirmed arm is reported through `onError`). Anything created before that point is
25
- * picked up by the pass the endpoint requests right after registration.
27
+ * event. Arming is confirmed by a sentinel entry's own event coming back - the sentinel is re-touched
28
+ * until then, bounded, which is the only timer here and runs only while arming (an unconfirmed arm is
29
+ * reported through `onError`). Nothing waits on that confirmation: the watch is returned at once and
30
+ * `armed` settles when arming did, so the caller can request the pass that picks up anything created
31
+ * before the watch could see it.
26
32
  */
27
- export declare function watchInbox(inboxDir: string, onChange: () => void, onError: (error: unknown) => void): Promise<() => void>;
33
+ export declare function watchInbox(inboxDir: string, onChange: () => void, onError: (error: unknown) => void): Promise<InboxWatch>;
28
34
  /**
29
35
  * A continue after any stop (`^Z` or an external SIGSTOP) wakes the drain once the terminal has been
30
36
  * restored: the listener is persistent, so every stop/continue cycle is covered, and it defers past
@@ -87,33 +87,55 @@ const INBOX_ARM_ATTEMPT_MS = 200;
87
87
  * and inotify setup/teardown block the calling thread), so neither ever stalls the terminal.
88
88
  *
89
89
  * The worker arms the watch asynchronously, and an entry written before it is armed produces no
90
- * event. So registration returns only once a sentinel entry's own event has come back - the
91
- * sentinel is re-touched until then, bounded, which is the only timer here and runs only while
92
- * arming (an unconfirmed arm is reported through `onError`). Anything created before that point is
93
- * picked up by the pass the endpoint requests right after registration.
90
+ * event. Arming is confirmed by a sentinel entry's own event coming back - the sentinel is re-touched
91
+ * until then, bounded, which is the only timer here and runs only while arming (an unconfirmed arm is
92
+ * reported through `onError`). Nothing waits on that confirmation: the watch is returned at once and
93
+ * `armed` settles when arming did, so the caller can request the pass that picks up anything created
94
+ * before the watch could see it.
94
95
  */
95
96
  export async function watchInbox(inboxDir, onChange, onError) {
96
97
  await mkdir(inboxDir, { recursive: true, mode: 0o700 });
97
98
  const sentinel = `${INBOX_ENGINE_ENTRY_PREFIX}armed-${randomUUID()}`;
98
- const armed = Promise.withResolvers();
99
+ const confirmation = Promise.withResolvers();
100
+ let stopped = false;
99
101
  const unsubscribe = createFsWatchEventSource((error) => onError(error))(inboxDir, (_eventType, filename) => {
100
102
  if (filename === sentinel)
101
- armed.resolve(true);
103
+ confirmation.resolve(true);
102
104
  else if (!filename?.startsWith(INBOX_ENGINE_ENTRY_PREFIX))
103
105
  onChange();
104
106
  }, { recursive: false });
105
- let confirmed = false;
106
- for (let attempt = 0; attempt < INBOX_ARM_ATTEMPTS && !confirmed; attempt++) {
107
- await writeFile(join(inboxDir, sentinel), String(attempt), { mode: 0o600 });
108
- confirmed = await Promise.race([armed.promise, delay(INBOX_ARM_ATTEMPT_MS, false)]);
109
- }
110
- await rm(join(inboxDir, sentinel), { force: true });
111
- if (!confirmed)
112
- onError(new Error(`inbox watch on ${inboxDir} was not confirmed`));
113
- return () => {
114
- void Promise.resolve(unsubscribe()).catch(onError);
107
+ const arming = confirmArm(join(inboxDir, sentinel), confirmation.promise, () => stopped).then((confirmed) => {
108
+ if (!confirmed && !stopped)
109
+ onError(new Error(`inbox watch on ${inboxDir} was not confirmed`));
110
+ }, (error) => {
111
+ if (!stopped)
112
+ onError(error);
113
+ });
114
+ // Settles on the sentinel's own event, before any later event of the same watch is delivered, so a
115
+ // pass requested on it runs ahead of the passes those events cause; otherwise when arming ended.
116
+ const armed = Promise.race([confirmation.promise.then((confirmed) => (confirmed ? undefined : arming)), arming]);
117
+ return {
118
+ armed,
119
+ stop: () => {
120
+ stopped = true;
121
+ confirmation.resolve(false);
122
+ void Promise.resolve(unsubscribe()).catch(onError);
123
+ },
115
124
  };
116
125
  }
126
+ async function confirmArm(sentinelPath, confirmed, stopped) {
127
+ let seen = false;
128
+ try {
129
+ for (let attempt = 0; attempt < INBOX_ARM_ATTEMPTS && !seen && !stopped(); attempt++) {
130
+ await writeFile(sentinelPath, String(attempt), { mode: 0o600 });
131
+ seen = await Promise.race([confirmed, delay(INBOX_ARM_ATTEMPT_MS, false)]);
132
+ }
133
+ }
134
+ finally {
135
+ await rm(sentinelPath, { force: true });
136
+ }
137
+ return seen;
138
+ }
117
139
  /**
118
140
  * A continue after any stop (`^Z` or an external SIGSTOP) wakes the drain once the terminal has been
119
141
  * restored: the listener is persistent, so every stop/continue cycle is covered, and it defers past
@@ -28,6 +28,11 @@ export interface RpcConnectionOptions {
28
28
  eventFlushScheduler?: (flush: () => void) => void;
29
29
  /** Multi-session routing handle. Absent preserves classic wire output exactly. */
30
30
  sessionId?: string;
31
+ /**
32
+ * A single-session `--mode rpc` process: it accepts `set_retry_fallback` and advertises it. A host's
33
+ * session connection leaves it off; a host session takes its chain from `open_session.retryFallback`.
34
+ */
35
+ retryFallbackCommand?: boolean;
31
36
  /**
32
37
  * Shared-session capability registry. A `set_client_info` carrying `capabilities`
33
38
  * registers them for the connection that sent it; absent on a classic connection.
@@ -34,7 +34,7 @@ import { ClientAdmissions } from "./client-admissions.js";
34
34
  import { handleClientInput } from "./client-input-handler.js";
35
35
  import { ClientMessageEvents } from "./client-message-events.js";
36
36
  import { ConnectionQuestionBridge, degradeQuestion, sessionQuestionBridges } from "./connection-question-bridge.js";
37
- import { AUTO_TITLE_SESSIONS_CAPABILITY, buildCustomUnsupportedRequest, CONTINUE_FROM_LEAF_CAPABILITY, DEFAULT_CUSTOM_EXTENSION_LABEL, EXTENSION_EVENTS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, QUESTION_CAPABILITY, } from "./custom-capability.js";
37
+ import { AUTO_TITLE_SESSIONS_CAPABILITY, buildCustomUnsupportedRequest, CONTINUE_FROM_LEAF_CAPABILITY, DEFAULT_CUSTOM_EXTENSION_LABEL, EXTENSION_EVENTS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, QUESTION_CAPABILITY, RETRY_FALLBACK_COMMAND_CAPABILITY, } from "./custom-capability.js";
38
38
  import { createRpcEventOutputBuffer } from "./event-output-buffer.js";
39
39
  import { settleExtensionUiResponse } from "./extension-ui-response.js";
40
40
  import { HostSessionControl } from "./host-session-control.js";
@@ -42,7 +42,7 @@ import { createRpcLoginPromptCallbacks } from "./login-prompts.js";
42
42
  import { answerMemoryReport } from "./memory-report-command.js";
43
43
  import { protocolIdentity } from "./protocol-identity.js";
44
44
  import { buildRpcCommandsForSession, createCommandsChangedEvent, rpcCommandListDigest } from "./rpc-command-surface.js";
45
- import { rpcCommandPayloadError, rpcCommandShapeError, rpcMessageLengthError } from "./rpc-input-validation.js";
45
+ import { rpcCommandPayloadError, rpcCommandShapeError, rpcMessageLengthError, sessionRetryFallbackError, } from "./rpc-input-validation.js";
46
46
  import { buildRpcSessionState } from "./rpc-session-state.js";
47
47
  import { RPC_ERROR_MEDIA_NOT_FOUND } from "./rpc-types.js";
48
48
  import { SessionExtensionUiRequests } from "./session-extension-ui-requests.js";
@@ -158,6 +158,8 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
158
158
  let session = runtimeHost.session;
159
159
  let sessionControl;
160
160
  const promptCalls = new Set();
161
+ // Set by the first command that asks this session for a turn; `set_retry_fallback` is refused after it.
162
+ let turnRequested = false;
161
163
  let unsubscribe;
162
164
  let unsubscribeBackpressure;
163
165
  let unsubscribeLoadedSurfaces;
@@ -737,6 +739,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
737
739
  MEDIA_PLACEHOLDERS_CAPABILITY,
738
740
  DURABLE_CLIENT_MESSAGE_ID_CAPABILITY,
739
741
  CONTINUE_FROM_LEAF_CAPABILITY,
742
+ ...(options.retryFallbackCommand ? [RETRY_FALLBACK_COMMAND_CAPABILITY] : []),
740
743
  ...(options.capabilities ?? []),
741
744
  ]),
742
745
  ],
@@ -750,6 +753,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
750
753
  // Prompting
751
754
  // =================================================================
752
755
  case "prompt": {
756
+ turnRequested = true;
753
757
  const admission = handleClientInput(session, command, { output, promptCalls });
754
758
  promptCalls.add(admission);
755
759
  void admission.finally(() => promptCalls.delete(admission));
@@ -769,6 +773,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
769
773
  return success(id, "append_session_entry");
770
774
  }
771
775
  case "continue_from_leaf": {
776
+ turnRequested = true;
772
777
  try {
773
778
  await session.continueFromLeaf();
774
779
  return success(id, "continue_from_leaf");
@@ -780,6 +785,9 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
780
785
  }
781
786
  }
782
787
  case "send_custom_message": {
788
+ // A context message that asks for no turn does not block a launch-time chain, like an extension's.
789
+ if (command.triggerTurn === true)
790
+ turnRequested = true;
783
791
  await session.sendCustomMessage({
784
792
  customType: command.customType,
785
793
  content: command.content,
@@ -790,6 +798,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
790
798
  }
791
799
  case "steer":
792
800
  case "follow_up": {
801
+ turnRequested = true;
793
802
  await handleClientInput(session, command, { output, promptCalls });
794
803
  return undefined;
795
804
  }
@@ -883,7 +892,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
883
892
  return success(id, "cycle_model", result);
884
893
  }
885
894
  case "get_available_models": {
886
- const models = await session.modelRegistry.getAvailable();
895
+ const models = await session.modelRegistry.modelRuntime.getAvailable();
887
896
  return success(id, "get_available_models", {
888
897
  models: models.map((model) => ({
889
898
  ...model,
@@ -989,6 +998,24 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
989
998
  session.abortRetry();
990
999
  return success(id, "abort_retry");
991
1000
  }
1001
+ case "set_retry_fallback": {
1002
+ if (!options.retryFallbackCommand) {
1003
+ return error(id, "set_retry_fallback", "set_retry_fallback is for a single-session rpc process; a host session takes open_session.retryFallback.");
1004
+ }
1005
+ const profileError = sessionRetryFallbackError(command.retryFallback);
1006
+ if (profileError !== undefined || command.retryFallback === undefined) {
1007
+ return error(id, "set_retry_fallback", profileError ?? "set_retry_fallback needs retryFallback.");
1008
+ }
1009
+ // A launch-time setting: refused once this connection asked for a turn (even one not started yet), while
1010
+ // any turn runs, or once the session holds turn history, so a chain never changes under a turn or a
1011
+ // retry already in flight. Extension context messages (`custom`, e.g. one a component adds on
1012
+ // session_start) are not a turn and do not count.
1013
+ if (turnRequested || session.isStreaming || session.messages.some((message) => message.role !== "custom")) {
1014
+ return error(id, "set_retry_fallback", "set_retry_fallback must arrive before the session's first turn.");
1015
+ }
1016
+ runtimeHost.setRetryFallback(command.retryFallback);
1017
+ return success(id, "set_retry_fallback");
1018
+ }
992
1019
  // =================================================================
993
1020
  // Bash
994
1021
  // =================================================================
@@ -80,6 +80,12 @@ export declare const BROWSER_ENGINE_CAPABILITY = "browser_engine";
80
80
  * never written to a settings file and never seen by another session on the host.
81
81
  */
82
82
  export declare const RETRY_FALLBACK_PROFILE_CAPABILITY = "retry_fallback_profile";
83
+ /**
84
+ * SINGLE-SESSION capability: this `--mode rpc` process accepts `set_retry_fallback` (the
85
+ * `open_session.retryFallback` shape) before its first turn, so a caller that spawns one process per
86
+ * session can give it its own fallback chain without a settings file.
87
+ */
88
+ export declare const RETRY_FALLBACK_COMMAND_CAPABILITY = "retry_fallback_command";
83
89
  /**
84
90
  * HOST capability: `open_session.promptSurface` also accepts `chat` (a chat bridge: no routing line,
85
91
  * no handoff block, no todo cues). A host without it refuses `chat` with `invalid_launch_profile`,
@@ -80,6 +80,12 @@ export const BROWSER_ENGINE_CAPABILITY = "browser_engine";
80
80
  * never written to a settings file and never seen by another session on the host.
81
81
  */
82
82
  export const RETRY_FALLBACK_PROFILE_CAPABILITY = "retry_fallback_profile";
83
+ /**
84
+ * SINGLE-SESSION capability: this `--mode rpc` process accepts `set_retry_fallback` (the
85
+ * `open_session.retryFallback` shape) before its first turn, so a caller that spawns one process per
86
+ * session can give it its own fallback chain without a settings file.
87
+ */
88
+ export const RETRY_FALLBACK_COMMAND_CAPABILITY = "retry_fallback_command";
83
89
  /**
84
90
  * HOST capability: `open_session.promptSurface` also accepts `chat` (a chat bridge: no routing line,
85
91
  * no handoff block, no todo cues). A host without it refuses `chat` with `invalid_launch_profile`,
@@ -101,10 +101,11 @@ export declare class HostDaemonStateError extends Error {
101
101
  constructor(path: string, cause: unknown);
102
102
  }
103
103
  /**
104
- * Creates this endpoint's directories and publishes the flat marker. The modes are set explicitly
105
- * rather than left to `mkdir`, because a directory that already exists keeps whatever mode it was
106
- * created with - and this one holds the evidence that decides who may signal the daemon. A `tui`
107
- * registrant passes its kind; everything else is an `rpc_host`.
104
+ * Creates this endpoint's directories and publishes the flat marker. A directory that already exists
105
+ * keeps whatever mode it was created with - and this one holds the evidence that decides who may
106
+ * signal the daemon - so an existing one is re-moded explicitly; one `mkdir` just created already
107
+ * has the mode. The siblings inside the endpoint directory and the marker are written concurrently.
108
+ * A `tui` registrant passes its kind; everything else is an `rpc_host`.
108
109
  */
109
110
  export declare function createDaemonDirectories(paths: HostDaemonPaths, identity?: {
110
111
  readonly kind?: EndpointKind;
@@ -159,27 +159,35 @@ export class HostDaemonStateError extends Error {
159
159
  }
160
160
  }
161
161
  /**
162
- * Creates this endpoint's directories and publishes the flat marker. The modes are set explicitly
163
- * rather than left to `mkdir`, because a directory that already exists keeps whatever mode it was
164
- * created with - and this one holds the evidence that decides who may signal the daemon. A `tui`
165
- * registrant passes its kind; everything else is an `rpc_host`.
162
+ * Creates this endpoint's directories and publishes the flat marker. A directory that already exists
163
+ * keeps whatever mode it was created with - and this one holds the evidence that decides who may
164
+ * signal the daemon - so an existing one is re-moded explicitly; one `mkdir` just created already
165
+ * has the mode. The siblings inside the endpoint directory and the marker are written concurrently.
166
+ * A `tui` registrant passes its kind; everything else is an `rpc_host`.
166
167
  */
167
168
  export async function createDaemonDirectories(paths, identity = {}) {
168
169
  try {
169
170
  // The flat directory may predate this layout and may hold a legacy host's files: it is created
170
171
  // when missing and never re-moded, so a legacy host keeps whatever it set up for itself.
171
172
  await mkdir(paths.flatDir, { recursive: true, mode: DIRECTORY_MODE });
172
- for (const directory of [paths.dir, paths.generationsDir, paths.reservationsDir]) {
173
- await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
174
- await chmod(directory, DIRECTORY_MODE);
175
- }
176
- await writeFile(paths.layoutMarker, `${JSON.stringify({ layout: HOST_DAEMON_LAYOUT, dir: basename(paths.dir) })}\n`, { mode: HOST_STATE_FILE_MODE });
173
+ await makePrivateDirectory(paths.dir);
174
+ await Promise.all([
175
+ makePrivateDirectory(paths.generationsDir),
176
+ makePrivateDirectory(paths.reservationsDir),
177
+ writeFile(paths.layoutMarker, `${JSON.stringify({ layout: HOST_DAEMON_LAYOUT, dir: basename(paths.dir) })}\n`, { mode: HOST_STATE_FILE_MODE }),
178
+ ]);
177
179
  }
178
180
  catch (cause) {
179
181
  throw new HostDaemonStateError(paths.dir, cause);
180
182
  }
181
183
  await ensureEndpointIdentity(paths, paths.socket, identity);
182
184
  }
185
+ /** `mkdir` names the directory it created; only one that already existed needs its mode set. */
186
+ async function makePrivateDirectory(directory) {
187
+ const created = await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
188
+ if (created === undefined)
189
+ await chmod(directory, DIRECTORY_MODE);
190
+ }
183
191
  /**
184
192
  * Writes `endpoint.json` when it is absent and never rewrites a valid one: the first writer's
185
193
  * `created_at` is the endpoint's birth. The file is written whole to a temporary name and LINKED
@@ -40,8 +40,13 @@ export declare function readHostRegistration(paths: HostDaemonPaths): Promise<Re
40
40
  * Registers a generation and points the daemon directory at it, under this process's writer stamp.
41
41
  * The stamp is what authorizes a later stop: only the process that wrote a record may signal the
42
42
  * host it names, and the recorded start time keeps a recycled pid from inheriting that right.
43
+ *
44
+ * `fresh` says the directory cannot hold an earlier generation (a terminal's endpoint, named by a
45
+ * socket built from a new instance id), so there is nothing to prune.
43
46
  */
44
- export declare function writeHostRegistration(paths: HostDaemonPaths, registration: HostRegistration): Promise<void>;
47
+ export declare function writeHostRegistration(paths: HostDaemonPaths, registration: HostRegistration, options?: {
48
+ readonly fresh?: boolean;
49
+ }): Promise<void>;
45
50
  /**
46
51
  * Records ONE generation's process in its own directory without moving the pointer. A handoff writes
47
52
  * this the moment its successor is spawned: until the rename lands the pointer must keep naming the