@sayknow-cli/coding-agent 0.3.5 → 0.3.7

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 (238) hide show
  1. package/README.md +76 -0
  2. package/dist/types/capability/mcp.d.ts +2 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/telegram-cli.d.ts +13 -0
  6. package/dist/types/cli/update-cli.d.ts +24 -1
  7. package/dist/types/commands/local-provider.d.ts +34 -0
  8. package/dist/types/commands/telegram.d.ts +22 -0
  9. package/dist/types/config/keybindings.d.ts +12 -6
  10. package/dist/types/config/model-registry.d.ts +5 -0
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +284 -4
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/config/telegram-autostart.d.ts +14 -0
  15. package/dist/types/config/telegram-env-bridge.d.ts +18 -0
  16. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  17. package/dist/types/defaults/skc-defaults.d.ts +7 -0
  18. package/dist/types/discovery/cursor.d.ts +4 -1
  19. package/dist/types/discovery/index.d.ts +0 -1
  20. package/dist/types/discovery/windsurf.d.ts +3 -1
  21. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  22. package/dist/types/i18n/messages/en.d.ts +1 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +6 -1
  24. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  25. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  26. package/dist/types/modes/components/model-selector.d.ts +2 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +5 -1
  28. package/dist/types/modes/components/skill-message.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  30. package/dist/types/modes/components/status-line.d.ts +15 -3
  31. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  32. package/dist/types/modes/components/welcome.d.ts +9 -1
  33. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  34. package/dist/types/modes/controllers/input-controller.d.ts +6 -1
  35. package/dist/types/modes/controllers/selector-controller.d.ts +10 -1
  36. package/dist/types/modes/interactive-mode.d.ts +2 -0
  37. package/dist/types/modes/prompt-action-autocomplete.d.ts +3 -0
  38. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +41 -7
  40. package/dist/types/modes/slash-command-visibility.d.ts +1 -0
  41. package/dist/types/modes/theme/theme.d.ts +1 -1
  42. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  43. package/dist/types/modes/types.d.ts +5 -1
  44. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  45. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  46. package/dist/types/notifications/html-format.d.ts +2 -0
  47. package/dist/types/notifications/recent-activity.d.ts +5 -1
  48. package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
  49. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  50. package/dist/types/notifications/telegram-daemon.d.ts +4 -10
  51. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  52. package/dist/types/notifications/threaded-render.d.ts +1 -0
  53. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  54. package/dist/types/runtime-mcp/config.d.ts +2 -0
  55. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  56. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  57. package/dist/types/runtime-mcp/types.d.ts +6 -0
  58. package/dist/types/sdk.d.ts +1 -1
  59. package/dist/types/skc-runtime/launch-tmux.d.ts +2 -2
  60. package/dist/types/skc-runtime/ralplan-runtime.d.ts +3 -2
  61. package/dist/types/skc-runtime/restricted-role-agent-bash.d.ts +1 -0
  62. package/dist/types/skc-runtime/session-state-sidecar.d.ts +5 -0
  63. package/dist/types/skc-runtime/state-writer.d.ts +1 -0
  64. package/dist/types/skc-runtime/team-runtime.d.ts +1 -1
  65. package/dist/types/skc-runtime/tmux-common.d.ts +12 -0
  66. package/dist/types/skc-runtime/ultragoal-guard.d.ts +7 -1
  67. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +0 -4
  68. package/dist/types/slash-commands/types.d.ts +2 -0
  69. package/dist/types/system-prompt.d.ts +7 -0
  70. package/dist/types/tools/skill.d.ts +1 -1
  71. package/dist/types/utils/cmux-workspace.d.ts +52 -0
  72. package/dist/types/web/search/index.d.ts +4 -2
  73. package/dist/types/web/search/provider.d.ts +14 -0
  74. package/dist/types/web/search/providers/utils.d.ts +42 -13
  75. package/dist/types/web/search/render.d.ts +1 -0
  76. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  77. package/package.json +8 -8
  78. package/scripts/build-binary.ts +3 -0
  79. package/scripts/verify-insane-vendor.ts +0 -5
  80. package/src/capability/mcp.ts +2 -0
  81. package/src/cli/local-provider-smoke.ts +632 -0
  82. package/src/cli/notify-cli.ts +4 -2
  83. package/src/cli/telegram-cli.ts +156 -0
  84. package/src/cli/update-cli.ts +179 -13
  85. package/src/cli/web-search-cli.ts +5 -7
  86. package/src/cli.ts +20 -1
  87. package/src/commands/launch.ts +47 -2
  88. package/src/commands/local-provider.ts +62 -0
  89. package/src/commands/notify.ts +1 -1
  90. package/src/commands/ralplan.ts +1 -0
  91. package/src/commands/telegram.ts +47 -0
  92. package/src/config/keybindings.ts +15 -6
  93. package/src/config/mcp-schema.json +4 -0
  94. package/src/config/model-registry.ts +55 -1
  95. package/src/config/models-config-schema.ts +9 -0
  96. package/src/config/settings-schema.ts +256 -4
  97. package/src/config/settings.ts +11 -0
  98. package/src/config/telegram-autostart.ts +107 -0
  99. package/src/config/telegram-env-bridge.ts +92 -0
  100. package/src/coordinator-mcp/server.ts +276 -34
  101. package/src/defaults/skc/skills/ralplan/SKILL.md +29 -20
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +37 -112
  103. package/src/defaults/skc-defaults.ts +16 -0
  104. package/src/discovery/builtin.ts +12 -0
  105. package/src/discovery/cursor.ts +5 -84
  106. package/src/discovery/gemini.ts +3 -89
  107. package/src/discovery/index.ts +4 -1
  108. package/src/discovery/mcp-json.ts +11 -0
  109. package/src/discovery/opencode.ts +5 -122
  110. package/src/discovery/windsurf.ts +4 -82
  111. package/src/edit/read-file.ts +9 -1
  112. package/src/extensibility/extensions/runner.ts +2 -1
  113. package/src/extensibility/extensions/types.ts +11 -0
  114. package/src/extensibility/skills.ts +30 -2
  115. package/src/hooks/skill-state.ts +33 -73
  116. package/src/i18n/messages/de.ts +1 -0
  117. package/src/i18n/messages/en.ts +1 -0
  118. package/src/i18n/messages/es.ts +1 -0
  119. package/src/i18n/messages/fr.ts +1 -0
  120. package/src/i18n/messages/ja.settings.ts +28 -0
  121. package/src/i18n/messages/ja.ts +1 -0
  122. package/src/i18n/messages/ko.settings.ts +28 -0
  123. package/src/i18n/messages/ko.ts +1 -0
  124. package/src/i18n/messages/zh.settings.ts +28 -0
  125. package/src/i18n/messages/zh.ts +1 -0
  126. package/src/internal-urls/docs-index.generated.ts +12 -7
  127. package/src/main.ts +5 -2
  128. package/src/migrate/executor.ts +23 -6
  129. package/src/modes/acp/acp-agent.ts +7 -6
  130. package/src/modes/components/agent-dashboard.ts +3 -35
  131. package/src/modes/components/custom-editor.ts +26 -0
  132. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  133. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  134. package/src/modes/components/hook-selector.ts +21 -3
  135. package/src/modes/components/model-selector.ts +246 -11
  136. package/src/modes/components/session-observer-overlay.ts +3 -3
  137. package/src/modes/components/session-selector.ts +19 -9
  138. package/src/modes/components/settings-selector.ts +551 -11
  139. package/src/modes/components/skill-message.ts +18 -10
  140. package/src/modes/components/status-line/presets.ts +11 -0
  141. package/src/modes/components/status-line/segments.ts +16 -19
  142. package/src/modes/components/status-line/types.ts +6 -2
  143. package/src/modes/components/status-line.ts +281 -117
  144. package/src/modes/components/tree-selector.ts +28 -8
  145. package/src/modes/components/two-column-body.ts +36 -0
  146. package/src/modes/components/welcome.ts +165 -30
  147. package/src/modes/controllers/command-controller.ts +26 -2
  148. package/src/modes/controllers/event-controller.ts +102 -4
  149. package/src/modes/controllers/extension-ui-controller.ts +21 -2
  150. package/src/modes/controllers/input-controller.ts +214 -47
  151. package/src/modes/controllers/selector-controller.ts +35 -33
  152. package/src/modes/interactive-mode.ts +36 -30
  153. package/src/modes/prompt-action-autocomplete.ts +48 -8
  154. package/src/modes/rpc/rpc-types.ts +1 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +72 -15
  156. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  157. package/src/modes/slash-command-visibility.ts +9 -0
  158. package/src/modes/theme/theme.ts +5 -1
  159. package/src/modes/tmux-scroll.ts +92 -0
  160. package/src/modes/types.ts +8 -1
  161. package/src/modes/utils/hotkeys-markdown.ts +5 -2
  162. package/src/modes/utils/terminal-bell.ts +48 -0
  163. package/src/modes/utils/ui-helpers.ts +76 -3
  164. package/src/notifications/daemon-paths.ts +22 -0
  165. package/src/notifications/html-format.ts +75 -1
  166. package/src/notifications/index.ts +32 -2
  167. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  168. package/src/notifications/recent-activity.ts +32 -8
  169. package/src/notifications/telegram-daemon-cli.ts +125 -13
  170. package/src/notifications/telegram-daemon-control.ts +9 -0
  171. package/src/notifications/telegram-daemon.ts +88 -46
  172. package/src/notifications/telegram-reference.ts +36 -14
  173. package/src/notifications/threaded-render.ts +14 -6
  174. package/src/prompts/agents/architect.md +7 -4
  175. package/src/prompts/agents/critic.md +15 -6
  176. package/src/prompts/agents/planner.md +8 -3
  177. package/src/prompts/system/system-prompt.md +20 -2
  178. package/src/prompts/tools/bash.md +1 -1
  179. package/src/runtime-mcp/config-writer.ts +28 -0
  180. package/src/runtime-mcp/config.ts +7 -0
  181. package/src/runtime-mcp/loader.ts +3 -0
  182. package/src/runtime-mcp/manager.ts +3 -0
  183. package/src/runtime-mcp/types.ts +6 -0
  184. package/src/sdk.ts +10 -11
  185. package/src/session/agent-session.ts +103 -13
  186. package/src/session/session-manager.ts +58 -18
  187. package/src/skc-runtime/launch-tmux.ts +34 -8
  188. package/src/skc-runtime/launch-worktree.ts +16 -2
  189. package/src/skc-runtime/ralplan-runtime.ts +22 -6
  190. package/src/skc-runtime/restricted-role-agent-bash.ts +1 -0
  191. package/src/skc-runtime/session-state-sidecar.ts +167 -24
  192. package/src/skc-runtime/state-runtime.ts +8 -5
  193. package/src/skc-runtime/state-writer.ts +5 -5
  194. package/src/skc-runtime/team-runtime.ts +21 -6
  195. package/src/skc-runtime/tmux-common.ts +16 -0
  196. package/src/skc-runtime/tmux-sessions.ts +13 -9
  197. package/src/skc-runtime/ultragoal-guard.ts +150 -24
  198. package/src/skc-runtime/ultragoal-runtime.ts +16 -124
  199. package/src/skc-runtime/workflow-manifest.generated.json +0 -8
  200. package/src/skc-runtime/workflow-manifest.ts +0 -1
  201. package/src/slash-commands/builtin-registry.ts +136 -23
  202. package/src/slash-commands/types.ts +2 -0
  203. package/src/system-prompt.ts +8 -0
  204. package/src/task/executor.ts +5 -0
  205. package/src/tools/ast-grep.ts +10 -7
  206. package/src/tools/bash.ts +39 -14
  207. package/src/tools/index.ts +8 -1
  208. package/src/tools/skill.ts +1 -1
  209. package/src/utils/cmux-workspace.ts +233 -0
  210. package/src/utils/title-generator.ts +2 -0
  211. package/src/web/kagi.ts +1 -1
  212. package/src/web/parallel.ts +2 -2
  213. package/src/web/search/index.ts +137 -52
  214. package/src/web/search/provider.ts +80 -0
  215. package/src/web/search/providers/anthropic.ts +1 -1
  216. package/src/web/search/providers/brave.ts +1 -1
  217. package/src/web/search/providers/codex.ts +1 -1
  218. package/src/web/search/providers/duckduckgo.ts +1 -1
  219. package/src/web/search/providers/exa.ts +1 -1
  220. package/src/web/search/providers/gemini.ts +7 -3
  221. package/src/web/search/providers/jina.ts +1 -1
  222. package/src/web/search/providers/kimi.ts +6 -1
  223. package/src/web/search/providers/openai-compatible.ts +1 -1
  224. package/src/web/search/providers/parallel.ts +1 -1
  225. package/src/web/search/providers/perplexity.ts +2 -2
  226. package/src/web/search/providers/searxng.ts +1 -1
  227. package/src/web/search/providers/synthetic.ts +1 -1
  228. package/src/web/search/providers/tavily.ts +1 -1
  229. package/src/web/search/providers/utils.ts +67 -17
  230. package/src/web/search/providers/xai.ts +1 -1
  231. package/src/web/search/providers/zai.ts +1 -1
  232. package/src/web/search/render.ts +3 -0
  233. package/src/workflow/workflow-intent-diff.ts +199 -0
  234. package/vendor/insane-search/MANIFEST.json +1 -3
  235. package/vendor/insane-search/engine/tests/test_u1.py +0 -28
  236. package/vendor/insane-search/engine/transport.py +2 -3
  237. package/vendor/insane-search/engine/validators.py +0 -14
  238. package/src/defaults/skc/rules/ponytail.md +0 -68
@@ -0,0 +1,233 @@
1
+ import { logger } from "@sayknow-cli/utils";
2
+
3
+ const CMUX_COMMAND = "cmux";
4
+ const CMUX_WORKSPACE_ID_ENV = "CMUX_WORKSPACE_ID";
5
+ const CMUX_NO_RENAME_ENV = "SKC_NO_CMUX_RENAME";
6
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
7
+ const CMUX_WORKSPACE_TITLE_PREFIX = "SKC: ";
8
+ const CMUX_WORKSPACE_RENAME_TIMEOUT_MS = 1500;
9
+ const CMUX_WORKSPACE_LIST_TIMEOUT_MS = 1500;
10
+
11
+ export interface CmuxWorkspaceRenameCommand {
12
+ command: string;
13
+ args: string[];
14
+ }
15
+
16
+ /** Current ownership state of a cmux workspace, read back from `cmux workspace list`. */
17
+ export interface CmuxWorkspaceOwnership {
18
+ /** cmux marks a workspace `has_custom_title` once an explicit title is set (by the user or by SKC). */
19
+ hasCustomTitle: boolean;
20
+ /** The workspace's current display title. */
21
+ title: string;
22
+ }
23
+
24
+ export interface CmuxWorkspaceRenameProcess {
25
+ exited: Promise<number>;
26
+ kill(): void;
27
+ unref(): void;
28
+ }
29
+
30
+ export interface CmuxWorkspaceTitleSyncOptions {
31
+ env?: NodeJS.ProcessEnv;
32
+ isTty?: boolean;
33
+ which?: (command: string) => string | null;
34
+ spawn?: (
35
+ command: string[],
36
+ options: { env: NodeJS.ProcessEnv; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" },
37
+ ) => CmuxWorkspaceRenameProcess;
38
+ /** Reads the current ownership state of `workspaceId`. Injectable for tests. */
39
+ readOwnership?: (
40
+ cmuxCommand: string,
41
+ workspaceId: string,
42
+ env: NodeJS.ProcessEnv,
43
+ ) => Promise<CmuxWorkspaceOwnership | null>;
44
+ }
45
+
46
+ function defaultSpawn(
47
+ command: string[],
48
+ options: { env: NodeJS.ProcessEnv; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" },
49
+ ): CmuxWorkspaceRenameProcess {
50
+ return Bun.spawn(command, options);
51
+ }
52
+
53
+ function isEnvSet(value: string | undefined): boolean {
54
+ if (!value) return false;
55
+ const normalized = value.trim().toLowerCase();
56
+ return normalized !== "" && normalized !== "0" && normalized !== "false";
57
+ }
58
+
59
+ export function sanitizeCmuxWorkspaceTitle(title: string | undefined): string | undefined {
60
+ if (!title) return undefined;
61
+ const sanitized = title.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
62
+ return sanitized || undefined;
63
+ }
64
+
65
+ export function formatCmuxWorkspaceTitle(title: string | undefined): string | undefined {
66
+ const sanitized = sanitizeCmuxWorkspaceTitle(title);
67
+ if (!sanitized) return undefined;
68
+ return sanitized.startsWith(CMUX_WORKSPACE_TITLE_PREFIX) ? sanitized : `${CMUX_WORKSPACE_TITLE_PREFIX}${sanitized}`;
69
+ }
70
+
71
+ export function buildCmuxWorkspaceRenameCommand(
72
+ sessionName: string | undefined,
73
+ env: NodeJS.ProcessEnv = process.env,
74
+ ): CmuxWorkspaceRenameCommand | null {
75
+ const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
76
+ if (!workspaceId) return null;
77
+
78
+ const title = formatCmuxWorkspaceTitle(sessionName);
79
+ if (!title) return null;
80
+
81
+ return {
82
+ command: CMUX_COMMAND,
83
+ args: ["workspace", "rename", workspaceId, "--title", title],
84
+ };
85
+ }
86
+
87
+ /** Parse `cmux workspace list --json --id-format both` output and return the
88
+ * ownership state of the workspace matching `workspaceId` (by UUID `id` or `ref`). */
89
+ export function parseCmuxWorkspaceOwnership(jsonText: string, workspaceId: string): CmuxWorkspaceOwnership | null {
90
+ const target = workspaceId.trim().toLowerCase();
91
+ if (!target) return null;
92
+
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(jsonText);
96
+ } catch {
97
+ return null;
98
+ }
99
+
100
+ const workspaces = (parsed as { workspaces?: unknown }).workspaces;
101
+ if (!Array.isArray(workspaces)) return null;
102
+
103
+ for (const entry of workspaces) {
104
+ if (!entry || typeof entry !== "object") continue;
105
+ const record = entry as Record<string, unknown>;
106
+ const id = typeof record.id === "string" ? record.id.toLowerCase() : "";
107
+ const ref = typeof record.ref === "string" ? record.ref.toLowerCase() : "";
108
+ if (id !== target && ref !== target) continue;
109
+ return {
110
+ hasCustomTitle: record.has_custom_title === true,
111
+ title: typeof record.title === "string" ? record.title : "",
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Only rename when SKC owns the name:
118
+ * - unknown ownership (read failed) → skip (fail safe, never clobber)
119
+ * - already the desired title → skip (no-op)
120
+ * - workspace still on its default title → rename
121
+ * - any custom title (user- or peer-set) → skip
122
+ * This makes SKC name a fresh workspace once and then leave it alone, so it
123
+ * never overwrites a user-pinned name and multiple sessions sharing one
124
+ * CMUX_WORKSPACE_ID do not thrash the workspace title. */
125
+ export function shouldRenameCmuxWorkspace(ownership: CmuxWorkspaceOwnership | null, desiredTitle: string): boolean {
126
+ if (!ownership) return false;
127
+ if ((sanitizeCmuxWorkspaceTitle(ownership.title) ?? "") === desiredTitle) return false;
128
+ return !ownership.hasCustomTitle;
129
+ }
130
+
131
+ async function defaultReadOwnership(
132
+ cmuxCommand: string,
133
+ workspaceId: string,
134
+ env: NodeJS.ProcessEnv,
135
+ ): Promise<CmuxWorkspaceOwnership | null> {
136
+ try {
137
+ const proc = Bun.spawn([cmuxCommand, "workspace", "list", "--json", "--id-format", "both"], {
138
+ env: { ...env, CMUX_QUIET: "1" },
139
+ stdin: "ignore",
140
+ stdout: "pipe",
141
+ stderr: "ignore",
142
+ });
143
+ const timer = setTimeout(() => {
144
+ try {
145
+ proc.kill();
146
+ } catch {}
147
+ }, CMUX_WORKSPACE_LIST_TIMEOUT_MS);
148
+ timer.unref?.();
149
+ const text = await new Response(proc.stdout).text();
150
+ await proc.exited;
151
+ clearTimeout(timer);
152
+ return parseCmuxWorkspaceOwnership(text, workspaceId);
153
+ } catch (error) {
154
+ logger.debug("cmux workspace list failed", { error: String(error) });
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Best-effort sync of the containing cmux workspace title to the current SKC
161
+ * session name. Ownership-guarded: SKC reads the current workspace title and
162
+ * only renames a workspace that still has its default title, so it never
163
+ * overwrites a name the user pinned or a name a peer session (sharing the same
164
+ * CMUX_WORKSPACE_ID) set. Opt out with SKC_NO_CMUX_RENAME.
165
+ */
166
+ export async function syncCmuxWorkspaceTitle(
167
+ sessionName: string | undefined,
168
+ options: CmuxWorkspaceTitleSyncOptions = {},
169
+ ): Promise<void> {
170
+ const env = options.env ?? process.env;
171
+ if (isEnvSet(env[CMUX_NO_RENAME_ENV])) return;
172
+
173
+ const isTty = options.isTty ?? process.stdout.isTTY === true;
174
+ if (!isTty) return;
175
+
176
+ const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
177
+ if (!workspaceId) return;
178
+
179
+ const desired = formatCmuxWorkspaceTitle(sessionName);
180
+ if (!desired) return;
181
+
182
+ const which = options.which ?? Bun.which;
183
+ let resolvedCommand: string | null;
184
+ try {
185
+ resolvedCommand = which(CMUX_COMMAND);
186
+ } catch (error) {
187
+ logger.debug("cmux workspace rename command lookup failed", { error: String(error) });
188
+ return;
189
+ }
190
+ if (!resolvedCommand) return;
191
+
192
+ let ownership: CmuxWorkspaceOwnership | null;
193
+ try {
194
+ const readOwnership = options.readOwnership ?? defaultReadOwnership;
195
+ ownership = await readOwnership(resolvedCommand, workspaceId, env);
196
+ } catch (error) {
197
+ logger.debug("cmux workspace ownership read failed", { error: String(error) });
198
+ return;
199
+ }
200
+
201
+ if (!shouldRenameCmuxWorkspace(ownership, desired)) return;
202
+
203
+ const plan = buildCmuxWorkspaceRenameCommand(sessionName, env);
204
+ if (!plan) return;
205
+
206
+ const spawn = options.spawn ?? defaultSpawn;
207
+ try {
208
+ const proc = spawn([resolvedCommand, ...plan.args], {
209
+ env,
210
+ stdin: "ignore",
211
+ stdout: "ignore",
212
+ stderr: "ignore",
213
+ });
214
+ proc.unref();
215
+ const timer = setTimeout(() => {
216
+ try {
217
+ proc.kill();
218
+ } catch {}
219
+ }, CMUX_WORKSPACE_RENAME_TIMEOUT_MS);
220
+ timer.unref?.();
221
+ void proc.exited
222
+ .then(exitCode => {
223
+ clearTimeout(timer);
224
+ if (exitCode !== 0) logger.debug("cmux workspace rename exited non-zero", { exitCode });
225
+ })
226
+ .catch(error => {
227
+ clearTimeout(timer);
228
+ logger.debug("cmux workspace rename failed", { error: String(error) });
229
+ });
230
+ } catch (error) {
231
+ logger.debug("cmux workspace rename failed to start", { error: String(error) });
232
+ }
233
+ }
@@ -9,6 +9,7 @@ import type { ModelRegistry } from "../config/model-registry";
9
9
  import { resolveRoleSelection } from "../config/model-resolver";
10
10
  import type { Settings } from "../config/settings";
11
11
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
12
+ import { syncCmuxWorkspaceTitle } from "./cmux-workspace";
12
13
 
13
14
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
14
15
 
@@ -218,6 +219,7 @@ export function setTerminalTitle(title: string): void {
218
219
 
219
220
  export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
220
221
  setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
222
+ void syncCmuxWorkspaceTitle(sessionName);
221
223
  }
222
224
 
223
225
  /**
package/src/web/kagi.ts CHANGED
@@ -147,7 +147,7 @@ export async function searchWithKagi(
147
147
 
148
148
  const response = await fetch(requestUrl, {
149
149
  headers: getAuthHeaders(apiKey),
150
- signal: withHardTimeout(options.signal),
150
+ signal: withHardTimeout(options.signal, "api"),
151
151
  });
152
152
  if (!response.ok) {
153
153
  throw parseKagiErrorResponse(response.status, await response.text());
@@ -306,7 +306,7 @@ export async function searchWithParallel(
306
306
  max_chars_per_result: options.maxCharsPerResult ?? 10_000,
307
307
  },
308
308
  }),
309
- signal: withHardTimeout(options.signal),
309
+ signal: withHardTimeout(options.signal, "api"),
310
310
  });
311
311
  if (!response.ok) {
312
312
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -338,7 +338,7 @@ export async function extractWithParallel(
338
338
  excerpts: options.excerpts ?? true,
339
339
  full_content: options.fullContent ?? false,
340
340
  }),
341
- signal: withHardTimeout(options.signal),
341
+ signal: withHardTimeout(options.signal, "api"),
342
342
  });
343
343
  if (!response.ok) {
344
344
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -16,7 +16,7 @@ import { discoverAuthStorage } from "../../sdk";
16
16
  import type { ToolSession } from "../../tools";
17
17
  import { formatAge } from "../../tools/render-utils";
18
18
  import { throwIfAborted } from "../../tools/tool-errors";
19
- import { getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
19
+ import { getSearchProviderLabel, prewarmSearchProviders, resolveProviderChain, type SearchProvider } from "./provider";
20
20
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
21
21
  import type { ActiveSearchModelContext, SearchProviderId, SearchResponse } from "./types";
22
22
  import { SearchProviderError } from "./types";
@@ -68,6 +68,18 @@ function formatProviderError(error: unknown, provider: SearchProvider): string {
68
68
  return `Unknown error from ${provider.label}`;
69
69
  }
70
70
 
71
+ function formatProviderFailure(error: unknown, provider: SearchProvider): string {
72
+ if (error instanceof SearchProviderError) return error.message;
73
+ return `${provider.id}: ${formatProviderError(error, provider)}`;
74
+ }
75
+
76
+ function formatFallbackWarning(
77
+ failures: Array<{ provider: SearchProvider; error: unknown }>,
78
+ provider: SearchProvider,
79
+ ): string {
80
+ return `Web search provider fallback: ${failures.map(f => formatProviderFailure(f.error, f.provider)).join("; ")}; using ${provider.label}.`;
81
+ }
82
+
71
83
  /** Truncate text for tool output */
72
84
  function truncateText(text: string, maxLen: number): string {
73
85
  if (text.length <= maxLen) return text;
@@ -136,6 +148,22 @@ interface ExecuteSearchOptions {
136
148
  activeModelContext?: ActiveSearchModelContext;
137
149
  }
138
150
 
151
+ /**
152
+ * Delay before the keyless DuckDuckGo hedge fires while a slower primary is
153
+ * still in flight. Chosen to be comfortably above DDG's typical ~1-2s
154
+ * round-trip cost budget yet far below LLM-mediated primary latency, so a
155
+ * failing primary can fall back to an already-completed hedge result instead
156
+ * of paying primary-failure time plus DDG time sequentially.
157
+ */
158
+ const DDG_HEDGE_DELAY_MS = 3_000;
159
+
160
+ let ddgHedgeDelayMs = DDG_HEDGE_DELAY_MS;
161
+
162
+ /** Override the hedge delay (tests). Non-finite/non-positive resets to default. */
163
+ export function setDdgHedgeDelayMs(ms: number | undefined): void {
164
+ ddgHedgeDelayMs = typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : DDG_HEDGE_DELAY_MS;
165
+ }
166
+
139
167
  /** Execute web search */
140
168
  async function executeSearch(
141
169
  _toolCallId: string,
@@ -154,51 +182,91 @@ async function executeSearch(
154
182
  activeModelContext,
155
183
  });
156
184
 
185
+ const baseSearchParams = {
186
+ query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
187
+ limit: params.limit,
188
+ recency: params.recency,
189
+ systemPrompt: webSearchSystemPrompt,
190
+ maxOutputTokens: params.max_tokens,
191
+ numSearchResults: params.num_search_results,
192
+ temperature: params.temperature,
193
+ xaiSearchMode: params.xai_search_mode,
194
+ allowedDomains: params.allowed_domains,
195
+ excludedDomains: params.excluded_domains,
196
+ allowedXHandles: params.allowed_x_handles,
197
+ excludedXHandles: params.excluded_x_handles,
198
+ fromDate: params.from_date,
199
+ toDate: params.to_date,
200
+ enableImageUnderstanding: params.enable_image_understanding,
201
+ enableImageSearch: params.enable_image_search,
202
+ enableVideoUnderstanding: params.enable_video_understanding,
203
+ noInlineCitations: params.no_inline_citations,
204
+ authStorage,
205
+ sessionId,
206
+ activeModelContext,
207
+ };
208
+
209
+ // Hedged fallback: when DuckDuckGo (keyless, cheap) is a non-primary member
210
+ // of the chain, start it in the background after a short delay. If the
211
+ // primary succeeds first the hedge is aborted; if the primary fails, the
212
+ // hedge result is typically already available, collapsing the fallback
213
+ // latency from `t(primary failure) + t(ddg)` to `max(t(primary failure), t(ddg))`.
214
+ const ddgIndex = providers.findIndex(p => p.id === "duckduckgo");
215
+ const hedgeCtl = new AbortController();
216
+ const onUpstreamAbort = () => hedgeCtl.abort();
217
+ signal?.addEventListener("abort", onUpstreamAbort, { once: true });
218
+ let hedgePromise: Promise<SearchResponse> | undefined;
219
+ let hedgeTimer: NodeJS.Timeout | undefined;
220
+ if (ddgIndex > 0) {
221
+ const ddg = providers[ddgIndex]!;
222
+ hedgeTimer = setTimeout(() => {
223
+ hedgePromise = ddg.search({ ...baseSearchParams, signal: hedgeCtl.signal });
224
+ // Failures are consumed when (and if) the loop reaches DuckDuckGo;
225
+ // never let a losing hedge become an unhandled rejection.
226
+ hedgePromise.catch(() => {});
227
+ }, ddgHedgeDelayMs);
228
+ }
229
+ const cancelHedge = () => {
230
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
231
+ hedgeCtl.abort();
232
+ signal?.removeEventListener("abort", onUpstreamAbort);
233
+ };
234
+
157
235
  const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
158
236
  let lastProvider = providers[0];
159
- for (const provider of providers) {
160
- lastProvider = provider;
161
- try {
162
- const response = await provider.search({
163
- query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
164
- limit: params.limit,
165
- recency: params.recency,
166
- systemPrompt: webSearchSystemPrompt,
167
- maxOutputTokens: params.max_tokens,
168
- numSearchResults: params.num_search_results,
169
- temperature: params.temperature,
170
- xaiSearchMode: params.xai_search_mode,
171
- allowedDomains: params.allowed_domains,
172
- excludedDomains: params.excluded_domains,
173
- allowedXHandles: params.allowed_x_handles,
174
- excludedXHandles: params.excluded_x_handles,
175
- fromDate: params.from_date,
176
- toDate: params.to_date,
177
- enableImageUnderstanding: params.enable_image_understanding,
178
- enableImageSearch: params.enable_image_search,
179
- enableVideoUnderstanding: params.enable_video_understanding,
180
- noInlineCitations: params.no_inline_citations,
181
- signal,
182
- authStorage,
183
- sessionId,
184
- activeModelContext,
185
- });
186
-
187
- const text = formatForLLM(response);
188
-
189
- return {
190
- content: [{ type: "text" as const, text }],
191
- details: { response },
192
- };
193
- } catch (error) {
194
- // Surface user-initiated cancellation immediately so the session sees
195
- // a clean abort instead of a generic "all providers failed" message.
196
- // Without this, an AbortError from `fetch()` is treated as a provider
197
- // failure and the loop falls through to the next provider (or to the
198
- // summary error), masking the cancellation.
199
- throwIfAborted(signal);
200
- failures.push({ provider, error });
237
+ try {
238
+ for (const provider of providers) {
239
+ lastProvider = provider;
240
+ try {
241
+ let response: SearchResponse;
242
+ if (provider.id === "duckduckgo" && hedgePromise) {
243
+ // Reuse the in-flight (often already-settled) hedge request.
244
+ if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
245
+ response = await hedgePromise;
246
+ } else {
247
+ if (provider.id === "duckduckgo" && hedgeTimer !== undefined) clearTimeout(hedgeTimer);
248
+ response = await provider.search({ ...baseSearchParams, signal });
249
+ }
250
+
251
+ const text = formatForLLM(response);
252
+ const warning = failures.length > 0 ? formatFallbackWarning(failures, provider) : undefined;
253
+
254
+ return {
255
+ content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
256
+ details: { response, ...(warning ? { warning } : {}) },
257
+ };
258
+ } catch (error) {
259
+ // Surface user-initiated cancellation immediately so the session sees
260
+ // a clean abort instead of a generic "all providers failed" message.
261
+ // Without this, an AbortError from `fetch()` is treated as a provider
262
+ // failure and the loop falls through to the next provider (or to the
263
+ // summary error), masking the cancellation.
264
+ throwIfAborted(signal);
265
+ failures.push({ provider, error });
266
+ }
201
267
  }
268
+ } finally {
269
+ cancelHedge();
202
270
  }
203
271
 
204
272
  const lastFailure = failures[failures.length - 1];
@@ -207,13 +275,7 @@ async function executeSearch(
207
275
  : `Unknown error from ${lastProvider.label}`;
208
276
  const message =
209
277
  providers.length > 1
210
- ? `All web search providers failed: ${failures
211
- .map(f =>
212
- f.error instanceof SearchProviderError
213
- ? f.error.message
214
- : `${f.provider.id}: ${formatProviderError(f.error, f.provider)}`,
215
- )
216
- .join("; ")}`
278
+ ? `All web search providers failed: ${failures.map(f => formatProviderFailure(f.error, f.provider)).join("; ")}`
217
279
  : baseMessage;
218
280
 
219
281
  return {
@@ -266,6 +328,24 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
266
328
  constructor(session: ToolSession) {
267
329
  this.#session = session;
268
330
  this.description = prompt.render(webSearchDescription);
331
+ // Prewarm: resolve the provider chain once in the background so the
332
+ // provider modules are imported and the chain cache is primed before
333
+ // the first user-visible search. Best-effort only.
334
+ const authStorage = session.authStorage;
335
+ if (authStorage) {
336
+ try {
337
+ const activeModelContext = session.model
338
+ ? session.modelRegistry?.getActiveSearchModelContext(session.model)
339
+ : undefined;
340
+ prewarmSearchProviders({
341
+ authStorage,
342
+ sessionId: session.getSessionId?.() ?? undefined,
343
+ activeModelContext,
344
+ });
345
+ } catch {
346
+ // Never let prewarming break tool construction.
347
+ }
348
+ }
269
349
  }
270
350
 
271
351
  async execute(
@@ -321,7 +401,12 @@ export function getSearchTools(): CustomTool<any, any>[] {
321
401
  return [webSearchCustomTool];
322
402
  }
323
403
 
324
- export { getSearchProvider, setPreferredSearchProvider, setSearchFallbackProviders } from "./provider";
325
- export { setSearchHardTimeoutMs } from "./providers/utils";
404
+ export {
405
+ getConfiguredSearchProviderPreference,
406
+ getSearchProvider,
407
+ setPreferredSearchProvider,
408
+ setSearchFallbackProviders,
409
+ } from "./provider";
410
+ export { applyConfiguredSearchTimeout, setSearchHardTimeoutMs } from "./providers/utils";
326
411
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
327
412
  export { isConfigurableSearchProviderId, isSearchProviderPreference } from "./types";
@@ -141,12 +141,78 @@ const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
141
141
  let preferredProvId: SearchProviderId | "auto" = "auto";
142
142
  let fallbackProvIds: SearchProviderId[] = [];
143
143
 
144
+ /**
145
+ * Resolved-chain cache. Chain resolution probes provider availability
146
+ * (credential lookups, potentially an OAuth refresh through the broker) on
147
+ * every web_search call even though the result only changes when the active
148
+ * model, provider preference, or credentials change. Cache the resolved
149
+ * provider-id list per AuthStorage instance for a short TTL.
150
+ *
151
+ * Keyed by AuthStorage identity (WeakMap) so tests and short-lived CLI calls
152
+ * with fresh storage handles never see another handle's chain. The TTL keeps
153
+ * a login/logout mid-session from being masked for more than a minute.
154
+ */
155
+ const CHAIN_CACHE_TTL_MS = 60_000;
156
+ let chainCache = new WeakMap<AuthStorage, Map<string, { ids: SearchProviderId[]; expires: number }>>();
157
+
158
+ /** Drop every cached resolved chain (settings changed, tests). */
159
+ export function clearResolvedChainCache(): void {
160
+ chainCache = new WeakMap();
161
+ }
162
+
163
+ function chainCacheKey(
164
+ preferred: SearchProviderId | "auto" | undefined,
165
+ fallbacks: readonly SearchProviderId[],
166
+ ctx: ActiveSearchModelContext | undefined,
167
+ authStorage: AuthStorage,
168
+ ): string {
169
+ // Include the AuthStorage generation so credential changes (login/logout,
170
+ // key rotation) invalidate cached chains immediately instead of being
171
+ // masked for up to the TTL. Stubs without getGeneration() key as 0.
172
+ const generation = (authStorage as { getGeneration?: () => number }).getGeneration?.() ?? 0;
173
+ return JSON.stringify([
174
+ generation,
175
+ preferred ?? null,
176
+ fallbacks,
177
+ ctx
178
+ ? [ctx.provider, ctx.modelId, ctx.wireModelId ?? null, ctx.api, ctx.baseUrl ?? null, ctx.webSearch ?? null]
179
+ : null,
180
+ ]);
181
+ }
182
+
144
183
  export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void {
145
184
  preferredProvId = provider;
185
+ clearResolvedChainCache();
146
186
  }
147
187
 
148
188
  export function setSearchFallbackProviders(ids: readonly string[]): void {
149
189
  fallbackProvIds = ids.filter(isConfigurableSearchProviderId);
190
+ clearResolvedChainCache();
191
+ }
192
+
193
+ /**
194
+ * Fire-and-forget warm-up: resolve the provider chain once so the provider
195
+ * modules are imported and the chain cache is primed before the first
196
+ * user-visible search. Errors are intentionally swallowed — prewarming must
197
+ * never surface a failure the real search path would handle itself.
198
+ */
199
+ export function prewarmSearchProviders(options: ResolveProviderChainOptions): void {
200
+ void resolveProviderChain(options).catch(() => {});
201
+ }
202
+
203
+ export interface SearchProviderPreferenceSource {
204
+ get(path: "providers.webSearch" | "web_search.provider"): SearchProviderId | "auto";
205
+ has(path: "providers.webSearch" | "web_search.provider"): boolean;
206
+ }
207
+
208
+ export function getConfiguredSearchProviderPreference(
209
+ settings: SearchProviderPreferenceSource,
210
+ ): SearchProviderId | "auto" {
211
+ const providerPreference = settings.get("providers.webSearch");
212
+ if (settings.has("providers.webSearch") || providerPreference !== "auto") return providerPreference;
213
+
214
+ const legacyProviderPreference = settings.get("web_search.provider");
215
+ return legacyProviderPreference;
150
216
  }
151
217
 
152
218
  export interface ResolveProviderChainOptions {
@@ -342,6 +408,16 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
342
408
  activeModelContext,
343
409
  fallbackProviders = fallbackProvIds,
344
410
  } = options;
411
+
412
+ const cacheKey = chainCacheKey(preferredProvider, fallbackProviders, activeModelContext, authStorage);
413
+ const perStorage = chainCache.get(authStorage);
414
+ const cached = perStorage?.get(cacheKey);
415
+ if (cached && cached.expires > Date.now()) {
416
+ const cachedProviders: SearchProvider[] = [];
417
+ for (const id of cached.ids) cachedProviders.push(await getSearchProvider(id));
418
+ return cachedProviders;
419
+ }
420
+
345
421
  const chain: SearchProviderId[] = [];
346
422
 
347
423
  // A forced primary is honored only when it is a user-configurable provider.
@@ -377,6 +453,10 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
377
453
  }
378
454
  appendDeduped(chain, "duckduckgo");
379
455
 
456
+ const storageEntry = chainCache.get(authStorage) ?? new Map<string, { ids: SearchProviderId[]; expires: number }>();
457
+ storageEntry.set(cacheKey, { ids: [...chain], expires: Date.now() + CHAIN_CACHE_TTL_MS });
458
+ chainCache.set(authStorage, storageEntry);
459
+
380
460
  const providers: SearchProvider[] = [];
381
461
  for (const id of chain) providers.push(await getSearchProvider(id));
382
462
  return providers;
@@ -119,7 +119,7 @@ async function callSearch(
119
119
  method: "POST",
120
120
  headers,
121
121
  body: JSON.stringify(body),
122
- signal: withHardTimeout(signal),
122
+ signal: withHardTimeout(signal, "llm"),
123
123
  });
124
124
 
125
125
  if (!response.ok) {
@@ -85,7 +85,7 @@ async function callBraveSearch(
85
85
  Accept: "application/json",
86
86
  "X-Subscription-Token": apiKey,
87
87
  },
88
- signal: withHardTimeout(params.signal),
88
+ signal: withHardTimeout(params.signal, "api"),
89
89
  });
90
90
 
91
91
  if (!response.ok) {
@@ -216,7 +216,7 @@ async function callCodexSearch(
216
216
  method: "POST",
217
217
  headers,
218
218
  body: JSON.stringify(body),
219
- signal: withHardTimeout(options.signal),
219
+ signal: withHardTimeout(options.signal, "llm"),
220
220
  });
221
221
 
222
222
  if (!response.ok) {
@@ -196,7 +196,7 @@ async function fetchAndParse(
196
196
  "Accept-Language": "en-US,en;q=0.9",
197
197
  },
198
198
  body,
199
- signal: withHardTimeout(signal),
199
+ signal: withHardTimeout(signal, "api"),
200
200
  });
201
201
 
202
202
  // DuckDuckGo signals soft blocks with 202 (which is still response.ok).
@@ -115,7 +115,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
115
115
  "x-api-key": apiKey,
116
116
  },
117
117
  body: JSON.stringify(body),
118
- signal: withHardTimeout(params.signal),
118
+ signal: withHardTimeout(params.signal, "api"),
119
119
  });
120
120
 
121
121
  if (!response.ok) {