@gajae-code/coding-agent 0.9.0 → 0.9.2

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 (98) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +7 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +15 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  15. package/dist/types/modes/components/thinking-selector.d.ts +7 -2
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  19. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  20. package/dist/types/modes/types.d.ts +3 -0
  21. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  22. package/dist/types/notifications/config-commands.d.ts +38 -0
  23. package/dist/types/notifications/config.d.ts +16 -0
  24. package/dist/types/notifications/index.d.ts +14 -1
  25. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  26. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  27. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  28. package/dist/types/notifications/rich-draft.d.ts +68 -0
  29. package/dist/types/notifications/rich-render.d.ts +90 -0
  30. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  31. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  32. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  33. package/dist/types/notifications/threaded-render.d.ts +10 -0
  34. package/dist/types/notifications/topic-registry.d.ts +4 -5
  35. package/dist/types/runtime-credential-selector.d.ts +7 -0
  36. package/dist/types/sdk.d.ts +7 -1
  37. package/dist/types/session/agent-session.d.ts +6 -3
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/skill-discovery.d.ts +40 -0
  40. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  41. package/package.json +8 -11
  42. package/src/cli/args.ts +8 -0
  43. package/src/cli/notify-cli.ts +38 -10
  44. package/src/cli.ts +5 -0
  45. package/src/commands/launch.ts +5 -0
  46. package/src/commands/notify.ts +2 -2
  47. package/src/config/keybindings.ts +1 -1
  48. package/src/config/model-profile-activation.ts +23 -7
  49. package/src/config/model-registry.ts +21 -4
  50. package/src/config/model-resolver.ts +5 -16
  51. package/src/config/settings-schema.ts +11 -2
  52. package/src/config/settings.ts +24 -1
  53. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  54. package/src/gjc-runtime/launch-tmux.ts +78 -12
  55. package/src/gjc-runtime/psmux-detect.ts +17 -2
  56. package/src/gjc-runtime/team-runtime.ts +46 -7
  57. package/src/gjc-runtime/tmux-common.ts +1 -0
  58. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  59. package/src/internal-urls/docs-index.generated.ts +6 -6
  60. package/src/lsp/client.ts +1 -0
  61. package/src/main.ts +26 -3
  62. package/src/modes/components/footer.ts +7 -2
  63. package/src/modes/components/settings-selector.ts +3 -3
  64. package/src/modes/components/thinking-selector.ts +83 -20
  65. package/src/modes/controllers/event-controller.ts +11 -2
  66. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  67. package/src/modes/controllers/input-controller.ts +62 -26
  68. package/src/modes/controllers/selector-controller.ts +43 -0
  69. package/src/modes/interactive-mode.ts +5 -0
  70. package/src/modes/rpc/rpc-mode.ts +3 -9
  71. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  72. package/src/modes/types.ts +3 -0
  73. package/src/modes/utils/injected-user-submission.ts +94 -0
  74. package/src/modes/utils/ui-helpers.ts +48 -15
  75. package/src/notifications/config-commands.ts +90 -0
  76. package/src/notifications/config.ts +27 -0
  77. package/src/notifications/index.ts +184 -14
  78. package/src/notifications/lifecycle-commands.ts +37 -16
  79. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  80. package/src/notifications/reply-sent-store.ts +134 -0
  81. package/src/notifications/rich-draft.ts +107 -0
  82. package/src/notifications/rich-render.ts +143 -0
  83. package/src/notifications/telegram-daemon-cli.ts +60 -5
  84. package/src/notifications/telegram-daemon.ts +431 -91
  85. package/src/notifications/telegram-reference.ts +17 -0
  86. package/src/notifications/threaded-render.ts +42 -1
  87. package/src/notifications/topic-registry.ts +9 -8
  88. package/src/prompts/tools/search-tool-bm25.md +5 -0
  89. package/src/prompts/tools/skill-discovery.md +13 -0
  90. package/src/runtime-credential-selector.ts +35 -0
  91. package/src/sdk.ts +104 -13
  92. package/src/session/agent-session.ts +48 -22
  93. package/src/slash-commands/builtin-registry.ts +78 -1
  94. package/src/system-prompt.ts +4 -6
  95. package/src/tools/index.ts +4 -0
  96. package/src/tools/skill-discovery.ts +73 -0
  97. package/src/tools/skill.ts +15 -2
  98. package/src/utils/pasted-image-path.ts +170 -0
package/src/lsp/client.ts CHANGED
@@ -835,6 +835,7 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
835
835
  await Promise.race([shutdown, Bun.sleep(5_000)]);
836
836
  await client.owner?.dispose();
837
837
  await client.owner?.awaitExit({ timeoutMs: 1_000 });
838
+ await Promise.race([client.proc.exited.catch(() => undefined), Bun.sleep(1_000)]);
838
839
  }
839
840
 
840
841
  export async function shutdownClient(key: string): Promise<void> {
package/src/main.ts CHANGED
@@ -38,6 +38,7 @@ import type { InteractiveMode } from "./modes/interactive-mode";
38
38
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
39
39
  import type { SubmittedUserInput } from "./modes/types";
40
40
  import { applyCliRuntimeApiKeyOverride } from "./runtime-api-key";
41
+ import { parseCliCredentialSelector } from "./runtime-credential-selector";
41
42
  import type { MCPManager } from "./runtime-mcp";
42
43
  import {
43
44
  type CreateAgentSessionOptions,
@@ -217,10 +218,14 @@ export async function applyStartupModelProfiles(args: {
217
218
  startupModel?: CreateAgentSessionOptions["model"];
218
219
  startupThinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
219
220
  }): Promise<void> {
220
- const applyProfile = async (profileName: string, persistDefault: boolean): Promise<void> => {
221
+ const applyProfile = async (
222
+ profileName: string,
223
+ persistDefault: boolean,
224
+ options: { thinkingLevelOverride?: CreateAgentSessionOptions["thinkingLevel"] } = {},
225
+ ): Promise<void> => {
221
226
  await activateModelProfile(
222
227
  { session: args.session, modelRegistry: args.modelRegistry, settings: args.settings, profileName },
223
- { persistDefault },
228
+ { persistDefault, thinkingLevelOverride: options.thinkingLevelOverride },
224
229
  );
225
230
  };
226
231
 
@@ -234,7 +239,11 @@ export async function applyStartupModelProfiles(args: {
234
239
  }
235
240
 
236
241
  if (defaultProfile) {
237
- await applyProfile(defaultProfile, false);
242
+ await applyProfile(defaultProfile, false, {
243
+ thinkingLevelOverride: args.settings.has("defaultThinkingLevel")
244
+ ? args.settings.get("defaultThinkingLevel")
245
+ : undefined,
246
+ });
238
247
  }
239
248
  if (args.parsedArgs.mpreset) {
240
249
  await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true);
@@ -1002,6 +1011,20 @@ export async function runRootCommand(
1002
1011
  deps.rlmPreset?.applyOptions(sessionOptions, settingsInstance);
1003
1012
 
1004
1013
  // Handle CLI --api-key as runtime override (not persisted)
1014
+ if (parsedArgs.apiKey && parsedArgs.credential) {
1015
+ process.stderr.write(`${chalk.red("--api-key and --credential cannot be used together")}\n`);
1016
+ process.exit(1);
1017
+ }
1018
+
1019
+ if (parsedArgs.credential) {
1020
+ try {
1021
+ sessionOptions.credentialSelector = parseCliCredentialSelector(parsedArgs.credential);
1022
+ } catch (error) {
1023
+ const message = error instanceof Error ? error.message : String(error);
1024
+ process.stderr.write(`${chalk.red(message)}\n`);
1025
+ process.exit(1);
1026
+ }
1027
+ }
1005
1028
  if (parsedArgs.apiKey) {
1006
1029
  if (!sessionOptions.model && !sessionOptions.modelPattern) {
1007
1030
  process.stderr.write(
@@ -16,6 +16,7 @@ import { resolveCurrentBranch } from "./status-line/git-utils";
16
16
  export class FooterComponent implements Component {
17
17
  #cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
18
18
  #gitWatcher: fs.FSWatcher | null = null;
19
+ #gitWatcherGeneration = 0;
19
20
  #onBranchChange: (() => void) | null = null;
20
21
  #autoCompactEnabled: boolean = true;
21
22
  #extensionStatuses: Map<string, string> = new Map();
@@ -51,7 +52,10 @@ export class FooterComponent implements Component {
51
52
  }
52
53
 
53
54
  #setupGitWatcher(): void {
54
- // Clean up existing watcher
55
+ // Clean up existing watcher. The generation guard below keeps a stale
56
+ // in-flight resolve — one that dispose() or a newer setup already
57
+ // superseded — from installing a watcher nothing would ever close.
58
+ const generation = ++this.#gitWatcherGeneration;
55
59
  if (this.#gitWatcher) {
56
60
  this.#gitWatcher.close();
57
61
  this.#gitWatcher = null;
@@ -60,7 +64,7 @@ export class FooterComponent implements Component {
60
64
  void git.head
61
65
  .resolve(getProjectDir())
62
66
  .then(head => {
63
- if (!head) {
67
+ if (!head || generation !== this.#gitWatcherGeneration) {
64
68
  return;
65
69
  }
66
70
 
@@ -84,6 +88,7 @@ export class FooterComponent implements Component {
84
88
  * Clean up the file watcher
85
89
  */
86
90
  dispose(): void {
91
+ this.#gitWatcherGeneration++;
87
92
  if (this.#gitWatcher) {
88
93
  this.#gitWatcher.close();
89
94
  this.#gitWatcher = null;
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel } from "@gajae-code/agent-core";
1
+ import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@gajae-code/agent-core";
2
2
  import type { Effort } from "@gajae-code/ai";
3
3
  import {
4
4
  Container,
@@ -654,7 +654,7 @@ export interface SettingsRuntimeContext {
654
654
  /** Available thinking levels (from session) */
655
655
  availableThinkingLevels: Effort[];
656
656
  /** Current thinking level (from session) */
657
- thinkingLevel: ThinkingLevel | undefined;
657
+ thinkingLevel: ThinkingLevelValue | undefined;
658
658
  /** Available themes */
659
659
  availableThemes: string[];
660
660
  /** Available model profile names (from the model registry) */
@@ -843,7 +843,7 @@ export class SettingsSelectorComponent extends Container {
843
843
 
844
844
  // Special case: inject runtime options for thinking level
845
845
  if (def.path === "defaultThinkingLevel") {
846
- options = this.context.availableThinkingLevels.map(level => {
846
+ options = [ThinkingLevel.Off, ...this.context.availableThinkingLevels].map(level => {
847
847
  const baseOpt = options.find(o => o.value === level);
848
848
  return baseOpt || { value: level, label: level };
849
849
  });
@@ -1,51 +1,114 @@
1
- import type { Effort } from "@gajae-code/ai";
2
- import { Container, type SelectItem, SelectList } from "@gajae-code/tui";
3
- import { getSelectListTheme } from "../../modes/theme/theme";
4
- import { getThinkingLevelMetadata } from "../../thinking";
1
+ import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@gajae-code/agent-core";
2
+ import { Container, type SelectItem, SelectList, Text } from "@gajae-code/tui";
3
+ import { getSelectListTheme, theme } from "../../modes/theme/theme";
4
+ import { getThinkingLevelMetadata, type ThinkingLevelValue as ThinkingMetadataValue } from "../../thinking-metadata";
5
5
  import { DynamicBorder } from "./dynamic-border";
6
6
 
7
+ const SCOPE_ITEMS = [
8
+ {
9
+ value: "session",
10
+ label: "Apply for this session",
11
+ description: "Use this effort until the session changes it again",
12
+ },
13
+ {
14
+ value: "default",
15
+ label: "Set as default",
16
+ description: "Save this effort for future sessions",
17
+ },
18
+ ] as const satisfies readonly SelectItem[];
19
+
20
+ export interface ThinkingSelectorSelection {
21
+ level: ThinkingLevelValue;
22
+ persistDefault: boolean;
23
+ }
24
+
7
25
  /**
8
26
  * Component that renders a thinking level selector with borders
9
27
  */
10
28
  export class ThinkingSelectorComponent extends Container {
11
29
  #selectList: SelectList;
30
+ #levelList: SelectList;
31
+ #selectedLevel: ThinkingLevelValue | undefined;
32
+ readonly #onSelect: (selection: ThinkingSelectorSelection) => void;
33
+ readonly #onCancel: () => void;
12
34
 
13
35
  constructor(
14
- currentLevel: Effort,
15
- availableLevels: Effort[],
16
- onSelect: (level: Effort) => void,
36
+ currentLevel: ThinkingLevelValue | undefined,
37
+ availableLevels: ThinkingLevelValue[],
38
+ onSelect: (selection: ThinkingSelectorSelection) => void,
17
39
  onCancel: () => void,
18
40
  ) {
19
41
  super();
20
42
 
21
- const thinkingLevels: SelectItem[] = availableLevels.map(getThinkingLevelMetadata);
43
+ this.#onSelect = onSelect;
44
+ this.#onCancel = onCancel;
22
45
 
23
- // Add top border
24
- this.addChild(new DynamicBorder());
46
+ const currentValue = currentLevel ?? ThinkingLevel.Off;
47
+ const thinkingLevels: SelectItem[] = availableLevels.map(level => {
48
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
49
+ return level === currentValue
50
+ ? { ...metadata, label: metadata.label + theme.fg("muted", " (current)") }
51
+ : metadata;
52
+ });
25
53
 
26
- // Create selector
27
- this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
54
+ this.#levelList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
55
+ this.#selectList = this.#levelList;
28
56
 
29
57
  // Preselect current level
30
- const currentIndex = thinkingLevels.findIndex(item => item.value === currentLevel);
58
+ const currentIndex = thinkingLevels.findIndex(item => item.value === currentValue);
31
59
  if (currentIndex !== -1) {
32
- this.#selectList.setSelectedIndex(currentIndex);
60
+ this.#levelList.setSelectedIndex(currentIndex);
33
61
  }
34
62
 
35
- this.#selectList.onSelect = item => {
36
- onSelect(item.value as Effort);
63
+ this.#levelList.onSelect = item => {
64
+ this.#selectedLevel = item.value as ThinkingLevelValue;
65
+ this.#renderScopeList();
37
66
  };
38
67
 
39
- this.#selectList.onCancel = () => {
40
- onCancel();
68
+ this.#levelList.onCancel = () => {
69
+ this.#onCancel();
41
70
  };
42
71
 
43
- this.addChild(this.#selectList);
72
+ this.#renderLevelList();
73
+ }
74
+
75
+ #renderLevelList(): void {
76
+ this.detachAll();
77
+ this.#selectedLevel = undefined;
78
+ this.#selectList = this.#levelList;
79
+ this.addChild(new DynamicBorder());
80
+ this.addChild(this.#levelList);
81
+ this.addChild(new DynamicBorder());
82
+ }
83
+
84
+ #renderScopeList(): void {
85
+ const level = this.#selectedLevel;
86
+ if (!level) return;
87
+
88
+ const scopeList = new SelectList(SCOPE_ITEMS, SCOPE_ITEMS.length, getSelectListTheme());
89
+ scopeList.onSelect = item => {
90
+ this.#onSelect({
91
+ level,
92
+ persistDefault: item.value === "default",
93
+ });
94
+ };
95
+ scopeList.onCancel = () => {
96
+ this.#renderLevelList();
97
+ };
44
98
 
45
- // Add bottom border
99
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
100
+ this.detachAll();
101
+ this.#selectList = scopeList;
102
+ this.addChild(new DynamicBorder());
103
+ this.addChild(new Text(theme.fg("muted", ` Reasoning effort: ${metadata.label}`), 0, 0));
104
+ this.addChild(scopeList);
46
105
  this.addChild(new DynamicBorder());
47
106
  }
48
107
 
108
+ handleInput(data: string): void {
109
+ this.#selectList.handleInput(data);
110
+ }
111
+
49
112
  getSelectList(): SelectList {
50
113
  return this.#selectList;
51
114
  }
@@ -16,6 +16,7 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
16
16
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
17
17
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
18
18
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
19
+ import { completionNotifyDisabledByEnv } from "../../notifications/config";
19
20
  import { summaryFromMessage } from "../../notifications/helpers";
20
21
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
21
22
  import type { AgentSessionEvent } from "../../session/agent-session";
@@ -23,6 +24,7 @@ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
23
24
  import type { ResolveToolDetails } from "../../tools/resolve";
24
25
  import { interruptHint } from "../shared";
25
26
  import { buildAbortDisplayMessage } from "../utils/abort-message";
27
+ import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
26
28
  import { ringTerminalBell } from "../utils/terminal-bell";
27
29
  import { argsWithPartialJson } from "../utils/ui-helpers";
28
30
 
@@ -291,12 +293,17 @@ export class EventController {
291
293
  const signature = `${textContent}\u0000${imageCount}`;
292
294
 
293
295
  this.#resetReadGroup();
294
- const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
296
+ const wasSingleOptimistic = this.ctx.optimisticUserMessageSignature === signature;
297
+ const wasInjectedOptimistic = !wasSingleOptimistic && consumeInjectedOptimisticSignature(this.ctx, signature);
298
+ const wasOptimistic = wasSingleOptimistic || wasInjectedOptimistic;
295
299
  const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
296
300
  if (!wasOptimistic) {
297
301
  this.ctx.addMessageToChat(event.message);
298
302
  }
299
- if (wasOptimistic) {
303
+ // Only clear the single local slot when IT matched; an injected match is
304
+ // consumed from the counting Map above and must not clobber a coexisting
305
+ // pending local optimistic signature.
306
+ if (wasSingleOptimistic) {
300
307
  this.ctx.optimisticUserMessageSignature = undefined;
301
308
  }
302
309
 
@@ -960,6 +967,8 @@ export class EventController {
960
967
  }
961
968
 
962
969
  sendCompletionNotification(): void {
970
+ // Per-run hard opt-out (env, config-untouched, child-inheritable): GJC_NOTIFY=off.
971
+ if (completionNotifyDisabledByEnv(process.env)) return;
963
972
  const isBackgrounded = this.ctx.isBackgrounded !== false;
964
973
  const notify = settings.get("completion.notify");
965
974
  if (notify === "off") return;
@@ -23,6 +23,7 @@ import { HookSelectorComponent } from "../../modes/components/hook-selector";
23
23
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
24
24
  import type { InteractiveModeContext } from "../../modes/types";
25
25
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
26
+ import { applyInjectedUserSubmission } from "../utils/injected-user-submission";
26
27
  import { classifyHookSelectorBellEvent, ringTerminalBell } from "../utils/terminal-bell";
27
28
 
28
29
  const MAX_WIDGET_LINES = 10;
@@ -954,7 +955,13 @@ export class ExtensionUiController {
954
955
  }
955
956
 
956
957
  #sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
957
- this.ctx.session.sendUserMessage(content, options).catch((err: unknown) => {
958
+ // Compute queued BEFORE send: prompt() may flip session.isStreaming synchronously.
959
+ const queued = Boolean(options?.deliverAs) || this.ctx.session.isStreaming;
960
+ // Call send first so the busy/queued path finds the session queue populated
961
+ // (queueSteer/queueFollowUp push synchronously) before refreshing pending display.
962
+ const send = this.ctx.session.sendUserMessage(content, options);
963
+ applyInjectedUserSubmission(this.ctx, { content, queued });
964
+ send.catch((err: unknown) => {
958
965
  this.ctx.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
959
966
  });
960
967
  };
@@ -18,6 +18,7 @@ import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
18
18
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
19
19
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
20
20
  import { resizeImage } from "../../utils/image-resize";
21
+ import { resolvePastedImagePath } from "../../utils/pasted-image-path";
21
22
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
22
23
  import { type QueuedMessageMoveDirection, QueuedMessageSelectorComponent } from "../components/queued-message-selector";
23
24
 
@@ -26,8 +27,8 @@ interface Expandable {
26
27
  }
27
28
 
28
29
  const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
29
- const CLIPBOARD_TEMP_IMAGE_FILE_PATTERN = /^clipboard-\d{4}-\d{2}-\d{2}-\d{6}-[A-Za-z0-9]+\.(?:png|jpe?g|gif|webp)$/i;
30
- const MACOS_CLIPBOARD_TEMP_DIR_PATTERN = /^\/var\/folders\/[^/]+\/[^/]+\/T$/;
30
+ const IMAGE_PLACEHOLDER_PATTERN = /\[image ([1-9]\d*)\]/g;
31
+ const IMAGE_PLACEHOLDER_PRESENT_PATTERN = /\[image [1-9]\d*\]/;
31
32
 
32
33
  function isExpandable(obj: unknown): obj is Expandable {
33
34
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
@@ -384,6 +385,7 @@ export class InputController {
384
385
  this.ctx.isBashMode = trimmed.startsWith("!");
385
386
  this.ctx.isBashNoContext = trimmed.startsWith("!!");
386
387
  this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${");
388
+ this.#clearPendingImagesIfPlaceholdersRemoved(text);
387
389
  if (
388
390
  wasBashMode !== this.ctx.isBashMode ||
389
391
  wasBashNoContext !== this.ctx.isBashNoContext ||
@@ -419,7 +421,7 @@ export class InputController {
419
421
  }
420
422
 
421
423
  const runner = this.ctx.session.extensionRunner;
422
- let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
424
+ let inputImages = this.#visiblePendingImagesForText(text);
423
425
 
424
426
  if (runner?.hasHandlers("input")) {
425
427
  const result = await runner.emitInput(text, inputImages, "interactive");
@@ -499,7 +501,7 @@ export class InputController {
499
501
 
500
502
  // Queue input during compaction
501
503
  if (this.ctx.session.isCompacting) {
502
- if (this.ctx.pendingImages.length > 0) {
504
+ if ((inputImages?.length ?? 0) > 0) {
503
505
  this.ctx.showStatus("Compaction in progress. Retry after it completes to send images.");
504
506
  return;
505
507
  }
@@ -521,11 +523,13 @@ export class InputController {
521
523
  // typed since queuing intact. Same protection as #783, applied to
522
524
  // the streaming/queue path.
523
525
  const streamingBehavior = this.#busyStreamingBehavior();
524
- await this.ctx.withLocalSubmission(
525
- text,
526
- () => this.ctx.session.prompt(text, { streamingBehavior, images }),
527
- { imageCount: images?.length ?? 0 },
528
- );
526
+ const promptOptions =
527
+ streamingBehavior === "followUp"
528
+ ? { streamingBehavior, images, followUpQueuePolicy: "sequential" as const }
529
+ : { streamingBehavior, images };
530
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, promptOptions), {
531
+ imageCount: images?.length ?? 0,
532
+ });
529
533
  this.ctx.updatePendingMessagesDisplay();
530
534
  this.ctx.ui.requestRender();
531
535
  return;
@@ -870,7 +874,9 @@ export class InputController {
870
874
  details,
871
875
  attribution: "user",
872
876
  },
873
- { streamingBehavior },
877
+ streamingBehavior === "followUp"
878
+ ? { streamingBehavior, followUpQueuePolicy: "sequential" }
879
+ : { streamingBehavior },
874
880
  );
875
881
  }
876
882
  if (this.ctx.session.isStreaming) {
@@ -912,7 +918,10 @@ export class InputController {
912
918
  this.ctx.editor.addToHistory(text);
913
919
  this.ctx.editor.setText("");
914
920
  await this.ctx.withLocalSubmission(text, () =>
915
- this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
921
+ this.ctx.session.prompt(text, {
922
+ streamingBehavior: "followUp",
923
+ followUpQueuePolicy: "sequential",
924
+ }),
916
925
  );
917
926
  this.ctx.updatePendingMessagesDisplay();
918
927
  this.ctx.ui.requestRender();
@@ -1061,10 +1070,15 @@ export class InputController {
1061
1070
  }
1062
1071
 
1063
1072
  handleTextPaste(text: string): boolean | Promise<boolean> {
1064
- const imagePath = this.#getPastedImagePathCandidate(text);
1073
+ const imagePath = resolvePastedImagePath(text, { cwd: this.ctx.sessionManager.getCwd() });
1065
1074
  return imagePath ? this.#attachPastedImagePath(imagePath) : false;
1066
1075
  }
1067
1076
 
1077
+ /**
1078
+ * Returns `false` on every failure path so the editor replays the original
1079
+ * bracketed paste — the raw path text must never be lost when attachment
1080
+ * is impossible (unsupported content, oversized image, load error).
1081
+ */
1068
1082
  async #attachPastedImagePath(imagePath: string): Promise<boolean> {
1069
1083
  try {
1070
1084
  const image = await loadImageInput({
@@ -1073,8 +1087,8 @@ export class InputController {
1073
1087
  autoResize: this.ctx.settings.get("images.autoResize"),
1074
1088
  });
1075
1089
  if (!image) {
1076
- this.ctx.showStatus("Unsupported pasted clipboard image file");
1077
- return true;
1090
+ this.ctx.showStatus("Unsupported pasted image file");
1091
+ return false;
1078
1092
  }
1079
1093
 
1080
1094
  this.ctx.pendingImages.push({
@@ -1089,26 +1103,48 @@ export class InputController {
1089
1103
  } catch (error) {
1090
1104
  if (error instanceof ImageInputTooLargeError) {
1091
1105
  this.ctx.showStatus(error.message);
1092
- return true;
1106
+ return false;
1093
1107
  }
1094
- this.ctx.showStatus("Failed to attach pasted clipboard image");
1095
- return true;
1108
+ this.ctx.showStatus("Failed to attach pasted image");
1109
+ return false;
1096
1110
  }
1097
1111
  }
1098
1112
 
1099
- #getPastedImagePathCandidate(text: string): string | undefined {
1100
- const resolvedPath = path.resolve(text.trim());
1101
- const parentDir = path.dirname(resolvedPath);
1102
- const isClipboardTempPath =
1103
- (parentDir === "/tmp" || MACOS_CLIPBOARD_TEMP_DIR_PATTERN.test(parentDir)) &&
1104
- CLIPBOARD_TEMP_IMAGE_FILE_PATTERN.test(path.basename(resolvedPath));
1105
- return isClipboardTempPath ? resolvedPath : undefined;
1106
- }
1107
-
1108
1113
  #nextImagePlaceholder(): string {
1109
1114
  return `[image ${this.ctx.pendingImages.length}]`;
1110
1115
  }
1111
1116
 
1117
+ #visiblePendingImagesForText(text: string): InteractiveModeContext["pendingImages"] | undefined {
1118
+ if (this.ctx.pendingImages.length === 0) {
1119
+ return undefined;
1120
+ }
1121
+
1122
+ const images: InteractiveModeContext["pendingImages"] = [];
1123
+ const seenImageIndexes = new Set<number>();
1124
+ for (const match of text.matchAll(IMAGE_PLACEHOLDER_PATTERN)) {
1125
+ const placeholderNumberText = match[1];
1126
+ if (!placeholderNumberText) continue;
1127
+ const placeholderNumber = Number.parseInt(placeholderNumberText, 10);
1128
+ const imageIndex = placeholderNumber - 1;
1129
+ if (imageIndex < 0 || imageIndex >= this.ctx.pendingImages.length || seenImageIndexes.has(imageIndex)) {
1130
+ continue;
1131
+ }
1132
+ const image = this.ctx.pendingImages[imageIndex];
1133
+ if (!image) continue;
1134
+ images.push(image);
1135
+ seenImageIndexes.add(imageIndex);
1136
+ }
1137
+
1138
+ return images.length > 0 ? images : undefined;
1139
+ }
1140
+
1141
+ #clearPendingImagesIfPlaceholdersRemoved(text: string): void {
1142
+ if (this.ctx.pendingImages.length === 0 || IMAGE_PLACEHOLDER_PRESENT_PATTERN.test(text)) {
1143
+ return;
1144
+ }
1145
+ this.ctx.pendingImages = [];
1146
+ }
1147
+
1112
1148
  async handleImagePaste(): Promise<boolean> {
1113
1149
  try {
1114
1150
  const image = await readImageFromClipboard();
@@ -88,6 +88,7 @@ import { SessionSelectorComponent } from "../components/session-selector";
88
88
  import { SettingsSelectorComponent } from "../components/settings-selector";
89
89
  import type { StatusLineSettings } from "../components/status-line";
90
90
  import { ThemeSelectorComponent } from "../components/theme-selector";
91
+ import { ThinkingSelectorComponent } from "../components/thinking-selector";
91
92
  import { ToolExecutionComponent } from "../components/tool-execution";
92
93
  import { TreeSelectorComponent } from "../components/tree-selector";
93
94
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
@@ -431,6 +432,48 @@ export class SelectorController {
431
432
  });
432
433
  }
433
434
 
435
+ showEffortSelector(): void {
436
+ const availableLevels = [
437
+ ThinkingLevel.Inherit,
438
+ ThinkingLevel.Off,
439
+ ...this.ctx.session.getAvailableThinkingLevels(),
440
+ ];
441
+
442
+ this.showSelector(done => {
443
+ const selector = new ThinkingSelectorComponent(
444
+ this.ctx.session.thinkingLevel,
445
+ availableLevels,
446
+ selection => {
447
+ done();
448
+
449
+ const { level, persistDefault } = selection;
450
+ const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
451
+ const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
452
+ this.ctx.session.setThinkingLevel(levelToApply, persistDefault);
453
+ const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
454
+ const requestedLabel =
455
+ level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
456
+ const clampedSuffix =
457
+ effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
458
+
459
+ this.ctx.statusLine.invalidate();
460
+ this.ctx.updateEditorBorderColor();
461
+ this.ctx.updateEditorTopBorder();
462
+ if (persistDefault) void this.ctx.notifyConfigChanged?.();
463
+ this.ctx.ui.requestRender();
464
+ const scopeLabel = persistDefault ? "Default reasoning effort" : "Reasoning effort";
465
+ this.ctx.showStatus(
466
+ `${scopeLabel} set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
467
+ );
468
+ },
469
+ () => {
470
+ done();
471
+ this.ctx.ui.requestRender();
472
+ },
473
+ );
474
+ return { component: selector, focus: selector };
475
+ });
476
+ }
434
477
  showSettingsSelector(): void {
435
478
  getAvailableThemes().then(availableThemes => {
436
479
  this.showSelector(done => {
@@ -330,6 +330,7 @@ export class InteractiveMode implements InteractiveModeContext {
330
330
  onInputCallback?: (input: SubmittedUserInput) => void;
331
331
  optimisticUserMessageSignature: string | undefined = undefined;
332
332
  locallySubmittedUserSignatures: Set<string> = new Set();
333
+ optimisticInjectedSignatures: Map<string, number> = new Map();
333
334
  #pendingSubmittedInput: SubmittedUserInput | undefined;
334
335
  #pendingSubmissionDispose: (() => void) | undefined;
335
336
  lastSigintTime = 0;
@@ -2560,6 +2561,10 @@ export class InteractiveMode implements InteractiveModeContext {
2560
2561
  this.#selectorController.showModelSelector(options);
2561
2562
  }
2562
2563
 
2564
+ showEffortSelector(): void {
2565
+ this.#selectorController.showEffortSelector();
2566
+ }
2567
+
2563
2568
  showProviderOnboarding(): void {
2564
2569
  this.#selectorController.showProviderOnboarding();
2565
2570
  }
@@ -173,15 +173,9 @@ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "e
173
173
  return "info";
174
174
  }
175
175
 
176
- export class RpcListenRefusedError extends Error {
177
- constructor(socketPath: string) {
178
- super(
179
- `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
180
- "Stop it first or choose a different --listen path.",
181
- );
182
- this.name = "RpcListenRefusedError";
183
- }
184
- }
176
+ // Thrown by prepareRpcSocketPath when the --listen path has a live owner;
177
+ // re-exported here because main.ts catches it at the RPC launch boundary.
178
+ export { RpcListenRefusedError } from "./rpc-socket-security";
185
179
 
186
180
  /**
187
181
  * Probe whether a unix-domain socket path has a live server accepting
@@ -9,6 +9,18 @@ export class RpcSocketSecurityError extends Error {
9
9
  }
10
10
  }
11
11
 
12
+ // The launch boundary in main.ts catches exactly this class to turn a
13
+ // duplicate --listen into a clean stderr diagnostic + non-zero exit (issue 19).
14
+ export class RpcListenRefusedError extends Error {
15
+ constructor(socketPath: string) {
16
+ super(
17
+ `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
18
+ "Stop it first or choose a different --listen path.",
19
+ );
20
+ this.name = "RpcListenRefusedError";
21
+ }
22
+ }
23
+
12
24
  const unsafeBits = 0o077;
13
25
 
14
26
  function currentUid(): number | undefined {
@@ -53,7 +65,7 @@ export async function prepareRpcSocketPath(socketPath: string): Promise<void> {
53
65
  if (!existing.isSocket()) throw new RpcSocketSecurityError(`RPC socket path is not a socket: ${socketPath}`);
54
66
  assertPrivateMode(existing.mode, socketPath);
55
67
  if (await probeUnixSocketAlive(socketPath)) {
56
- throw new RpcSocketSecurityError(`RPC socket path is live: ${socketPath}`);
68
+ throw new RpcListenRefusedError(socketPath);
57
69
  }
58
70
  await fs.unlink(socketPath);
59
71
  }
@@ -34,6 +34,7 @@ import type { Theme } from "./theme/theme";
34
34
  export type CompactionQueuedMessage = {
35
35
  text: string;
36
36
  mode: "steer" | "followUp";
37
+ followUpQueuePolicy?: "sequential";
37
38
  };
38
39
 
39
40
  export type SubmittedUserInput = {
@@ -116,6 +117,7 @@ export interface InteractiveModeContext {
116
117
  onInputCallback?: (input: SubmittedUserInput) => void;
117
118
  optimisticUserMessageSignature: string | undefined;
118
119
  locallySubmittedUserSignatures: Set<string>;
120
+ optimisticInjectedSignatures: Map<string, number>;
119
121
  lastSigintTime: number;
120
122
  lastEscapeTime: number;
121
123
  lastComposerClearEscapeTime: number;
@@ -244,6 +246,7 @@ export interface InteractiveModeContext {
244
246
  showExtensionsDashboard(): void;
245
247
  showAgentsDashboard(): void;
246
248
  showModelSelector(options?: { temporaryOnly?: boolean }): void;
249
+ showEffortSelector(): void;
247
250
  showProviderOnboarding(): void;
248
251
  showPluginSelector(mode?: "install" | "uninstall"): void;
249
252
  showUserMessageSelector(): void;