@gajae-code/coding-agent 0.7.9 → 0.7.10

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 (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +2 -0
  3. package/dist/types/config/settings-schema.d.ts +10 -1
  4. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +7 -0
  6. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -0
  7. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -4
  8. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  9. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +20 -6
  10. package/dist/types/notifications/html-format.d.ts +2 -0
  11. package/dist/types/notifications/recent-activity.d.ts +5 -1
  12. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  13. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  14. package/dist/types/notifications/threaded-render.d.ts +1 -0
  15. package/dist/types/tools/ask.d.ts +2 -0
  16. package/dist/types/utils/cmux-workspace.d.ts +23 -0
  17. package/dist/types/web/search/index.d.ts +1 -1
  18. package/dist/types/web/search/provider.d.ts +5 -0
  19. package/dist/types/web/search/render.d.ts +1 -0
  20. package/package.json +7 -7
  21. package/src/cli/web-search-cli.ts +3 -2
  22. package/src/config/settings-schema.ts +11 -1
  23. package/src/coordinator-mcp/server.ts +1 -1
  24. package/src/defaults/gjc/skills/deep-interview/SKILL.md +26 -2
  25. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -112
  26. package/src/extensibility/extensions/types.ts +11 -0
  27. package/src/gjc-runtime/launch-tmux.ts +63 -2
  28. package/src/gjc-runtime/ultragoal-guard.ts +139 -19
  29. package/src/gjc-runtime/ultragoal-runtime.ts +16 -124
  30. package/src/gjc-runtime/workflow-manifest.generated.json +0 -8
  31. package/src/gjc-runtime/workflow-manifest.ts +0 -1
  32. package/src/hooks/skill-state.ts +33 -73
  33. package/src/internal-urls/docs-index.generated.ts +2 -1
  34. package/src/modes/components/hook-selector.ts +21 -3
  35. package/src/modes/components/settings-selector.ts +47 -3
  36. package/src/modes/components/status-line/presets.ts +11 -0
  37. package/src/modes/controllers/extension-ui-controller.ts +18 -2
  38. package/src/modes/shared/agent-wire/deep-interview-gate.ts +53 -9
  39. package/src/notifications/html-format.ts +75 -1
  40. package/src/notifications/index.ts +12 -1
  41. package/src/notifications/recent-activity.ts +32 -8
  42. package/src/notifications/telegram-daemon-control.ts +9 -0
  43. package/src/notifications/telegram-daemon.ts +67 -24
  44. package/src/notifications/telegram-reference.ts +36 -14
  45. package/src/notifications/threaded-render.ts +14 -6
  46. package/src/prompts/system/system-prompt.md +5 -1
  47. package/src/sdk.ts +3 -5
  48. package/src/tools/ask.ts +136 -16
  49. package/src/utils/cmux-workspace.ts +106 -0
  50. package/src/utils/title-generator.ts +2 -0
  51. package/src/web/search/index.ts +22 -10
  52. package/src/web/search/provider.ts +15 -0
  53. package/src/web/search/render.ts +3 -0
package/src/tools/ask.ts CHANGED
@@ -91,6 +91,7 @@ export interface QuestionResult {
91
91
  multi: boolean;
92
92
  selectedOptions: string[];
93
93
  customInput?: string;
94
+ clarificationQuestion?: string;
94
95
  }
95
96
 
96
97
  export interface AskToolDetails {
@@ -99,6 +100,7 @@ export interface AskToolDetails {
99
100
  multi?: boolean;
100
101
  selectedOptions?: string[];
101
102
  customInput?: string;
103
+ clarificationQuestion?: string;
102
104
  /** Multi-part question mode */
103
105
  results?: QuestionResult[];
104
106
  }
@@ -108,6 +110,7 @@ export interface AskToolDetails {
108
110
  // =============================================================================
109
111
 
110
112
  const OTHER_OPTION = "Other (type your own)";
113
+ const ASK_CLARIFICATION_OPTION = "Ask about these choices";
111
114
  const RECOMMENDED_SUFFIX = " (Recommended)";
112
115
  const DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS = Number.MAX_SAFE_INTEGER;
113
116
  const DEEP_INTERVIEW_RECORDER_AWAIT_TIMEOUT_MS = 250;
@@ -183,6 +186,7 @@ function numberOptionLabels(labels: string[]): string[] {
183
186
  interface SelectionResult {
184
187
  selectedOptions: string[];
185
188
  customInput?: string;
189
+ clarificationQuestion?: string;
186
190
  timedOut: boolean;
187
191
  navigation?: "back" | "forward";
188
192
  cancelled?: boolean;
@@ -201,6 +205,7 @@ interface AskSingleQuestionOptions {
201
205
  navigation?: NavigationControls;
202
206
  scrollTitleRows?: number;
203
207
  otherOptionLabel?: string;
208
+ clarificationOptionLabel?: string;
204
209
  }
205
210
 
206
211
  interface UIContext {
@@ -219,6 +224,7 @@ interface UIContext {
219
224
  onRight?: () => void;
220
225
  helpText?: string;
221
226
  customInput?: { optionLabel: string; onSubmit: (text: string) => void };
227
+ clarificationInput?: { optionLabel: string; onSubmit: (text: string) => void; allowEmpty?: boolean };
222
228
  },
223
229
  ): Promise<string | undefined>;
224
230
  editor(
@@ -239,6 +245,7 @@ async function askSingleQuestion(
239
245
  const { recommended, timeout, signal, initialSelection, navigation, scrollTitleRows } = options;
240
246
  const doneLabel = getDoneOptionLabel();
241
247
  const otherOptionLabel = options.otherOptionLabel ?? OTHER_OPTION;
248
+ const clarificationOptionLabel = options.clarificationOptionLabel;
242
249
  let selectedOptions = [...(initialSelection?.selectedOptions ?? [])];
243
250
  let customInput = initialSelection?.customInput;
244
251
  let timedOut = false;
@@ -252,15 +259,17 @@ async function askSingleQuestion(
252
259
  timedOut: boolean;
253
260
  navigation?: "back" | "forward";
254
261
  inlineInput?: string;
262
+ inlineClarification?: string;
255
263
  }> => {
256
264
  let timeoutTriggered = false;
257
265
  const onTimeout = () => {
258
266
  timeoutTriggered = true;
259
267
  };
260
- // Inline custom input: the TUI selector keeps the question and option
261
- // list on screen and collects the "Other" text below the list, instead
262
- // of swapping to a separate editor screen that hides the question.
268
+ // Inline custom/clarification input: the TUI selector keeps the question
269
+ // and option list on screen and collects text below the list, instead of
270
+ // swapping to a separate editor screen that hides the question.
263
271
  let inlineInput: string | undefined;
272
+ let inlineClarification: string | undefined;
264
273
  let navigationAction: "back" | "forward" | undefined;
265
274
  const baseHelpText = navigation
266
275
  ? "up/down navigate enter select ←/→ question esc cancel"
@@ -286,6 +295,15 @@ async function askSingleQuestion(
286
295
  inlineInput = text;
287
296
  },
288
297
  },
298
+ clarificationInput: clarificationOptionLabel
299
+ ? {
300
+ optionLabel: clarificationOptionLabel,
301
+ allowEmpty: false,
302
+ onSubmit: (text: string) => {
303
+ inlineClarification = text;
304
+ },
305
+ }
306
+ : undefined,
289
307
  onLeft: navigation?.allowBack
290
308
  ? () => {
291
309
  navigationAction = "back";
@@ -304,7 +322,7 @@ async function askSingleQuestion(
304
322
  if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
305
323
  timeoutTriggered = Date.now() - startMs >= timeout;
306
324
  }
307
- return { choice, timedOut: timeoutTriggered, navigation: navigationAction, inlineInput };
325
+ return { choice, timedOut: timeoutTriggered, navigation: navigationAction, inlineInput, inlineClarification };
308
326
  };
309
327
 
310
328
  // Fallback for UI contexts that don't support inline custom input (they
@@ -315,6 +333,13 @@ async function askSingleQuestion(
315
333
  const input = signal ? await untilAborted(signal, showCustomInput) : await showCustomInput();
316
334
  return { input };
317
335
  };
336
+ const promptForClarificationInput = async (): Promise<{ input: string | undefined }> => {
337
+ const dialogOptions = signal ? { signal } : undefined;
338
+ const showClarificationInput = () =>
339
+ ui.editor("Ask a clarification question:", undefined, dialogOptions, { promptStyle: true });
340
+ const input = signal ? await untilAborted(signal, showClarificationInput) : await showClarificationInput();
341
+ return { input: input !== undefined && input.trim() === "" ? undefined : input };
342
+ };
318
343
 
319
344
  const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question;
320
345
  if (multi) {
@@ -337,6 +362,9 @@ async function askSingleQuestion(
337
362
  opts.push(doneLabel);
338
363
  }
339
364
  opts.push(otherOptionLabel);
365
+ if (clarificationOptionLabel) {
366
+ opts.push(clarificationOptionLabel);
367
+ }
340
368
 
341
369
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
342
370
  const {
@@ -344,6 +372,7 @@ async function askSingleQuestion(
344
372
  timedOut: selectTimedOut,
345
373
  navigation: arrowNavigation,
346
374
  inlineInput,
375
+ inlineClarification,
347
376
  } = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex);
348
377
 
349
378
  if (arrowNavigation) {
@@ -370,6 +399,18 @@ async function askSingleQuestion(
370
399
  customInput = input;
371
400
  break;
372
401
  }
402
+ if (clarificationOptionLabel && choice === clarificationOptionLabel) {
403
+ if (selectTimedOut) {
404
+ timedOut = true;
405
+ break;
406
+ }
407
+ const input =
408
+ inlineClarification !== undefined ? inlineClarification : (await promptForClarificationInput()).input;
409
+ if (input === undefined) {
410
+ break;
411
+ }
412
+ return { selectedOptions: [], clarificationQuestion: input, timedOut };
413
+ }
373
414
 
374
415
  const selectedIdx = opts.indexOf(choice);
375
416
  if (selectedIdx >= 0) {
@@ -400,7 +441,9 @@ async function askSingleQuestion(
400
441
  selectedOptions = Array.from(selected);
401
442
  } else {
402
443
  const displayLabels = addRecommendedSuffix(optionLabels, recommended);
403
- const optionsWithNavigation = [...displayLabels, otherOptionLabel];
444
+ const optionsWithNavigation = clarificationOptionLabel
445
+ ? [...displayLabels, otherOptionLabel, clarificationOptionLabel]
446
+ : [...displayLabels, otherOptionLabel];
404
447
 
405
448
  let initialIndex = recommended;
406
449
  const previouslySelected = selectedOptions[0];
@@ -420,6 +463,7 @@ async function askSingleQuestion(
420
463
  timedOut: selectTimedOut,
421
464
  navigation: arrowNavigation,
422
465
  inlineInput,
466
+ inlineClarification,
423
467
  } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex);
424
468
  timedOut = selectTimedOut;
425
469
 
@@ -439,6 +483,15 @@ async function askSingleQuestion(
439
483
  }
440
484
  // If input was dismissed (undefined), keep prior selectedOptions/customInput intact
441
485
  }
486
+ } else if (clarificationOptionLabel && choice === clarificationOptionLabel) {
487
+ if (!selectTimedOut) {
488
+ const input =
489
+ inlineClarification !== undefined ? inlineClarification : (await promptForClarificationInput()).input;
490
+ if (input !== undefined) {
491
+ return { selectedOptions: [], clarificationQuestion: input, timedOut };
492
+ }
493
+ // If input was dismissed (undefined), keep prior selectedOptions/customInput intact
494
+ }
442
495
  } else {
443
496
  const stripped = stripRecommendedSuffix(choice);
444
497
  if (optionLabels.includes(stripped)) {
@@ -466,6 +519,9 @@ async function askSingleQuestion(
466
519
  }
467
520
 
468
521
  function formatQuestionResult(result: QuestionResult): string {
522
+ if (result.clarificationQuestion !== undefined) {
523
+ return `${result.id}: clarification requested: ${result.clarificationQuestion}`;
524
+ }
469
525
  if (result.customInput !== undefined) {
470
526
  return `${result.id}: "${result.customInput}"`;
471
527
  }
@@ -682,6 +738,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
682
738
  optionLabels: rawOptionLabels,
683
739
  selectedOptions: decoded.selectedOptions,
684
740
  customInput: decoded.customInput,
741
+ clarificationQuestion: decoded.clarificationQuestion,
685
742
  navigation: undefined as NavigationControls | undefined,
686
743
  cancelled: false,
687
744
  timedOut: false,
@@ -693,6 +750,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
693
750
  const displayQuestion = deepInterviewPrompt ?? q.question;
694
751
  const shouldNumberOptions = isDeepInterviewQuestion || isDeepInterviewAskQuestion(q.question);
695
752
  const optionLabels = shouldNumberOptions ? numberOptionLabels(rawOptionLabels) : rawOptionLabels;
753
+ const clarificationOptionLabel = shouldNumberOptions
754
+ ? formatNumberedOptionLabel(ASK_CLARIFICATION_OPTION, optionLabels.length + 1)
755
+ : undefined;
696
756
  const initialSelection =
697
757
  shouldNumberOptions && options?.previous
698
758
  ? {
@@ -706,6 +766,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
706
766
  const {
707
767
  selectedOptions: displaySelectedOptions,
708
768
  customInput,
769
+ clarificationQuestion,
709
770
  navigation,
710
771
  cancelled,
711
772
  timedOut,
@@ -719,6 +780,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
719
780
  otherOptionLabel: shouldNumberOptions
720
781
  ? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
721
782
  : undefined,
783
+ clarificationOptionLabel,
722
784
  });
723
785
  const selectedOptions = shouldNumberOptions
724
786
  ? displaySelectedOptions.map(selected => {
@@ -726,7 +788,15 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
726
788
  return displayIndex >= 0 ? (rawOptionLabels[displayIndex] ?? selected) : selected;
727
789
  })
728
790
  : displaySelectedOptions;
729
- return { optionLabels: rawOptionLabels, selectedOptions, customInput, navigation, cancelled, timedOut };
791
+ return {
792
+ optionLabels: rawOptionLabels,
793
+ selectedOptions,
794
+ customInput,
795
+ clarificationQuestion,
796
+ navigation,
797
+ cancelled,
798
+ timedOut,
799
+ };
730
800
  } catch (error) {
731
801
  if (error instanceof Error && error.name === "AbortError") {
732
802
  throw new ToolAbortError("Ask input was cancelled");
@@ -737,22 +807,40 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
737
807
 
738
808
  if (params.questions.length === 1) {
739
809
  const [q] = params.questions;
740
- const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
741
-
742
- if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
810
+ const { optionLabels, selectedOptions, customInput, clarificationQuestion, cancelled, timedOut } =
811
+ await askQuestion(q);
812
+
813
+ if (
814
+ !timedOut &&
815
+ (cancelled ||
816
+ (selectedOptions.length === 0 && customInput === undefined && clarificationQuestion === undefined))
817
+ ) {
743
818
  context?.abort();
744
819
  throw new ToolAbortError("Ask tool was cancelled by the user");
745
820
  }
746
- await this.#recordDeepInterviewRound(q, selectedOptions, customInput);
821
+ if (clarificationQuestion === undefined) {
822
+ await this.#recordDeepInterviewRound(q, selectedOptions, customInput);
823
+ }
747
824
  const details: AskToolDetails = {
748
825
  question: q.question,
749
826
  options: optionLabels,
750
827
  multi: q.multi ?? false,
751
828
  selectedOptions,
752
829
  customInput,
830
+ clarificationQuestion,
753
831
  };
754
832
 
755
833
  const responseParts: string[] = [];
834
+ if (clarificationQuestion !== undefined) {
835
+ responseParts.push(
836
+ clarificationQuestion.includes("\n")
837
+ ? `User asked a clarification question about the choices:\n${clarificationQuestion
838
+ .split("\n")
839
+ .map(line => ` ${line}`)
840
+ .join("\n")}`
841
+ : `User asked a clarification question about the choices: ${clarificationQuestion}`,
842
+ );
843
+ }
756
844
  if (selectedOptions.length > 0) {
757
845
  responseParts.push(
758
846
  q.multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`,
@@ -787,6 +875,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
787
875
  optionLabels,
788
876
  selectedOptions,
789
877
  customInput,
878
+ clarificationQuestion,
790
879
  navigation: navAction,
791
880
  cancelled,
792
881
  timedOut,
@@ -804,9 +893,12 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
804
893
  multi: q.multi ?? false,
805
894
  selectedOptions,
806
895
  customInput,
896
+ clarificationQuestion,
807
897
  };
808
898
 
809
- await this.#recordDeepInterviewRound(q, selectedOptions, customInput);
899
+ if (clarificationQuestion === undefined) {
900
+ await this.#recordDeepInterviewRound(q, selectedOptions, customInput);
901
+ }
810
902
 
811
903
  if (navAction === "back") {
812
904
  questionIndex = Math.max(0, questionIndex - 1);
@@ -995,7 +1087,10 @@ export const askToolRenderer = {
995
1087
  // Multi-part results
996
1088
  if (details.results && details.results.length > 0) {
997
1089
  const hasAnySelection = details.results.some(
998
- r => r.customInput !== undefined || (r.selectedOptions && r.selectedOptions.length > 0),
1090
+ r =>
1091
+ r.clarificationQuestion !== undefined ||
1092
+ r.customInput !== undefined ||
1093
+ (r.selectedOptions && r.selectedOptions.length > 0),
999
1094
  );
1000
1095
  const header = renderStatusLine(
1001
1096
  {
@@ -1013,7 +1108,8 @@ export const askToolRenderer = {
1013
1108
  const isLastQuestion = i === details.results.length - 1;
1014
1109
  const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch;
1015
1110
  const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
1016
- const hasSelection = r.customInput !== undefined || r.selectedOptions.length > 0;
1111
+ const hasSelection =
1112
+ r.clarificationQuestion !== undefined || r.customInput !== undefined || r.selectedOptions.length > 0;
1017
1113
  const statusIcon = hasSelection
1018
1114
  ? uiTheme.styledSymbol("status.success", "success")
1019
1115
  : uiTheme.styledSymbol("status.warning", "warning");
@@ -1028,7 +1124,10 @@ export const askToolRenderer = {
1028
1124
 
1029
1125
  const answerLines: string[] = [];
1030
1126
  for (let j = 0; j < r.selectedOptions.length; j++) {
1031
- const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
1127
+ const isLast =
1128
+ j === r.selectedOptions.length - 1 &&
1129
+ r.customInput === undefined &&
1130
+ r.clarificationQuestion === undefined;
1032
1131
  const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
1033
1132
  const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
1034
1133
  uiTheme.fg("toolOutput", t),
@@ -1042,6 +1141,14 @@ export const askToolRenderer = {
1042
1141
  }
1043
1142
  if (r.customInput !== undefined) {
1044
1143
  container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0));
1144
+ } else if (r.clarificationQuestion !== undefined) {
1145
+ container.addChild(
1146
+ new Text(
1147
+ renderCustomInput(uiTheme, continuation, `Clarification: ${r.clarificationQuestion}`, true, false),
1148
+ 0,
1149
+ 0,
1150
+ ),
1151
+ );
1045
1152
  } else if (r.selectedOptions.length === 0) {
1046
1153
  container.addChild(
1047
1154
  new Text(
@@ -1063,7 +1170,9 @@ export const askToolRenderer = {
1063
1170
  }
1064
1171
 
1065
1172
  const hasSelection =
1066
- details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0);
1173
+ details.clarificationQuestion !== undefined ||
1174
+ details.customInput !== undefined ||
1175
+ (details.selectedOptions && details.selectedOptions.length > 0);
1067
1176
  const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
1068
1177
  const container = new Container();
1069
1178
  container.addChild(new Text(header, 0, 0));
@@ -1075,7 +1184,10 @@ export const askToolRenderer = {
1075
1184
  const answerLines: string[] = [];
1076
1185
  if (details.selectedOptions && details.selectedOptions.length > 0) {
1077
1186
  for (let i = 0; i < details.selectedOptions.length; i++) {
1078
- const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined;
1187
+ const isLast =
1188
+ i === details.selectedOptions.length - 1 &&
1189
+ details.customInput === undefined &&
1190
+ details.clarificationQuestion === undefined;
1079
1191
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
1080
1192
  const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
1081
1193
  uiTheme.fg("toolOutput", t),
@@ -1090,6 +1202,14 @@ export const askToolRenderer = {
1090
1202
  }
1091
1203
  if (details.customInput !== undefined) {
1092
1204
  container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0));
1205
+ } else if (details.clarificationQuestion !== undefined) {
1206
+ container.addChild(
1207
+ new Text(
1208
+ renderCustomInput(uiTheme, " ", `Clarification: ${details.clarificationQuestion}`, true, false),
1209
+ 0,
1210
+ 0,
1211
+ ),
1212
+ );
1093
1213
  } else if (!details.selectedOptions || details.selectedOptions.length === 0) {
1094
1214
  container.addChild(
1095
1215
  new Text(
@@ -0,0 +1,106 @@
1
+ import { logger } from "@gajae-code/utils";
2
+
3
+ const CMUX_COMMAND = "cmux";
4
+ const CMUX_WORKSPACE_ID_ENV = "CMUX_WORKSPACE_ID";
5
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
6
+ const CMUX_WORKSPACE_RENAME_TIMEOUT_MS = 1500;
7
+
8
+ export interface CmuxWorkspaceRenameCommand {
9
+ command: string;
10
+ args: string[];
11
+ }
12
+
13
+ export interface CmuxWorkspaceRenameProcess {
14
+ exited: Promise<number>;
15
+ kill(): void;
16
+ unref(): void;
17
+ }
18
+
19
+ export interface CmuxWorkspaceTitleSyncOptions {
20
+ env?: NodeJS.ProcessEnv;
21
+ isTty?: boolean;
22
+ which?: (command: string) => string | null;
23
+ spawn?: (
24
+ command: string[],
25
+ options: { env: NodeJS.ProcessEnv; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" },
26
+ ) => CmuxWorkspaceRenameProcess;
27
+ }
28
+
29
+ function defaultSpawn(
30
+ command: string[],
31
+ options: { env: NodeJS.ProcessEnv; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" },
32
+ ): CmuxWorkspaceRenameProcess {
33
+ return Bun.spawn(command, options);
34
+ }
35
+
36
+ export function sanitizeCmuxWorkspaceTitle(title: string | undefined): string | undefined {
37
+ if (!title) return undefined;
38
+ const sanitized = title.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
39
+ return sanitized || undefined;
40
+ }
41
+
42
+ export function buildCmuxWorkspaceRenameCommand(
43
+ sessionName: string | undefined,
44
+ env: NodeJS.ProcessEnv = process.env,
45
+ ): CmuxWorkspaceRenameCommand | null {
46
+ const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
47
+ if (!workspaceId) return null;
48
+
49
+ const title = sanitizeCmuxWorkspaceTitle(sessionName);
50
+ if (!title) return null;
51
+
52
+ return {
53
+ command: CMUX_COMMAND,
54
+ args: ["workspace", "rename", workspaceId, "--title", title],
55
+ };
56
+ }
57
+
58
+ export function syncCmuxWorkspaceTitle(
59
+ sessionName: string | undefined,
60
+ options: CmuxWorkspaceTitleSyncOptions = {},
61
+ ): void {
62
+ const isTty = options.isTty ?? process.stdout.isTTY === true;
63
+ if (!isTty) return;
64
+
65
+ const env = options.env ?? process.env;
66
+ const plan = buildCmuxWorkspaceRenameCommand(sessionName, env);
67
+ if (!plan) return;
68
+
69
+ const which = options.which ?? Bun.which;
70
+ let resolvedCommand: string | null;
71
+ try {
72
+ resolvedCommand = which(plan.command);
73
+ } catch (error) {
74
+ logger.debug("cmux workspace rename command lookup failed", { error: String(error) });
75
+ return;
76
+ }
77
+ if (!resolvedCommand) return;
78
+
79
+ const spawn = options.spawn ?? defaultSpawn;
80
+ try {
81
+ const proc = spawn([resolvedCommand, ...plan.args], {
82
+ env,
83
+ stdin: "ignore",
84
+ stdout: "ignore",
85
+ stderr: "ignore",
86
+ });
87
+ proc.unref();
88
+ const timer = setTimeout(() => {
89
+ try {
90
+ proc.kill();
91
+ } catch {}
92
+ }, CMUX_WORKSPACE_RENAME_TIMEOUT_MS);
93
+ timer.unref?.();
94
+ void proc.exited
95
+ .then(exitCode => {
96
+ clearTimeout(timer);
97
+ if (exitCode !== 0) logger.debug("cmux workspace rename exited non-zero", { exitCode });
98
+ })
99
+ .catch(error => {
100
+ clearTimeout(timer);
101
+ logger.debug("cmux workspace rename failed", { error: String(error) });
102
+ });
103
+ } catch (error) {
104
+ logger.debug("cmux workspace rename failed to start", { error: String(error) });
105
+ }
106
+ }
@@ -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
+ syncCmuxWorkspaceTitle(sessionName);
221
223
  }
222
224
 
223
225
  /**
@@ -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;
@@ -185,10 +197,11 @@ async function executeSearch(
185
197
  });
186
198
 
187
199
  const text = formatForLLM(response);
200
+ const warning = failures.length > 0 ? formatFallbackWarning(failures, provider) : undefined;
188
201
 
189
202
  return {
190
- content: [{ type: "text" as const, text }],
191
- details: { response },
203
+ content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
204
+ details: { response, ...(warning ? { warning } : {}) },
192
205
  };
193
206
  } catch (error) {
194
207
  // Surface user-initiated cancellation immediately so the session sees
@@ -207,13 +220,7 @@ async function executeSearch(
207
220
  : `Unknown error from ${lastProvider.label}`;
208
221
  const message =
209
222
  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("; ")}`
223
+ ? `All web search providers failed: ${failures.map(f => formatProviderFailure(f.error, f.provider)).join("; ")}`
217
224
  : baseMessage;
218
225
 
219
226
  return {
@@ -321,7 +328,12 @@ export function getSearchTools(): CustomTool<any, any>[] {
321
328
  return [webSearchCustomTool];
322
329
  }
323
330
 
324
- export { getSearchProvider, setPreferredSearchProvider, setSearchFallbackProviders } from "./provider";
331
+ export {
332
+ getConfiguredSearchProviderPreference,
333
+ getSearchProvider,
334
+ setPreferredSearchProvider,
335
+ setSearchFallbackProviders,
336
+ } from "./provider";
325
337
  export { setSearchHardTimeoutMs } from "./providers/utils";
326
338
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
327
339
  export { isConfigurableSearchProviderId, isSearchProviderPreference } from "./types";
@@ -149,6 +149,21 @@ export function setSearchFallbackProviders(ids: readonly string[]): void {
149
149
  fallbackProvIds = ids.filter(isConfigurableSearchProviderId);
150
150
  }
151
151
 
152
+ export interface SearchProviderPreferenceSource {
153
+ get(path: "providers.webSearch" | "web_search.provider"): SearchProviderId | "auto";
154
+ has(path: "providers.webSearch" | "web_search.provider"): boolean;
155
+ }
156
+
157
+ export function getConfiguredSearchProviderPreference(
158
+ settings: SearchProviderPreferenceSource,
159
+ ): SearchProviderId | "auto" {
160
+ const providerPreference = settings.get("providers.webSearch");
161
+ if (settings.has("providers.webSearch") || providerPreference !== "auto") return providerPreference;
162
+
163
+ const legacyProviderPreference = settings.get("web_search.provider");
164
+ return legacyProviderPreference;
165
+ }
166
+
152
167
  export interface ResolveProviderChainOptions {
153
168
  authStorage: AuthStorage;
154
169
  sessionId?: string;
@@ -66,6 +66,7 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme
66
66
  export interface SearchRenderDetails {
67
67
  response: SearchResponse;
68
68
  error?: string;
69
+ warning?: string;
69
70
  }
70
71
 
71
72
  /** Render web search result with tree-based layout */
@@ -130,6 +131,8 @@ export function renderSearchResult(
130
131
 
131
132
  const metaLines: string[] = [];
132
133
  metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`);
134
+ if (details.warning)
135
+ metaLines.push(`${theme.fg("muted", "Fallback:")} ${theme.fg("warning", truncateToWidth(details.warning, 110))}`);
133
136
  if (response.authMode)
134
137
  metaLines.push(
135
138
  `${theme.fg("muted", "Auth:")} ${theme.fg("text", response.authMode === "oauth" ? "OAuth" : response.authMode === "api_key" ? "API key" : response.authMode)}`,