@gajae-code/coding-agent 0.11.5 → 0.11.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 (138) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +67 -7
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +35 -4
  40. package/dist/types/session/yield-queue.d.ts +8 -0
  41. package/dist/types/setup/credential-import.d.ts +5 -1
  42. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  43. package/dist/types/ssh/utils.d.ts +1 -0
  44. package/dist/types/task/discovery.d.ts +3 -1
  45. package/dist/types/tools/fetch.d.ts +5 -8
  46. package/dist/types/tools/image-gen.d.ts +16 -0
  47. package/dist/types/tools/skill-discovery.d.ts +7 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  49. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  50. package/dist/types/web/insane/bridge.d.ts +1 -0
  51. package/dist/types/web/insane/url-guard.d.ts +17 -0
  52. package/package.json +8 -8
  53. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  54. package/src/capability/index.ts +50 -42
  55. package/src/capability/types.ts +3 -0
  56. package/src/cli/setup-cli.ts +10 -4
  57. package/src/cli.ts +34 -9
  58. package/src/commands/daemon.ts +22 -5
  59. package/src/commands/sdk.ts +42 -9
  60. package/src/config/keybindings.ts +1 -1
  61. package/src/config/model-profiles.ts +18 -0
  62. package/src/config/settings-schema.ts +71 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  64. package/src/discovery/ssh.ts +12 -1
  65. package/src/eval/py/kernel.ts +1 -21
  66. package/src/eval/py/runner-artifact.ts +84 -0
  67. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  68. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  69. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  71. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/internal-urls/local-protocol.ts +32 -6
  75. package/src/main.ts +87 -28
  76. package/src/modes/action-registry.ts +1 -1
  77. package/src/modes/components/btw-panel.ts +82 -16
  78. package/src/modes/components/model-selector.ts +31 -3
  79. package/src/modes/components/tool-status-header.ts +6 -1
  80. package/src/modes/controllers/btw-controller.ts +119 -36
  81. package/src/modes/controllers/command-controller.ts +15 -0
  82. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  83. package/src/modes/controllers/input-controller.ts +31 -0
  84. package/src/modes/controllers/selector-controller.ts +106 -12
  85. package/src/modes/interactive-mode.ts +16 -7
  86. package/src/modes/runtime-init.ts +16 -2
  87. package/src/modes/types.ts +1 -0
  88. package/src/prompts/system/btw-user.md +3 -8
  89. package/src/prompts/tools/skill-discovery.md +1 -0
  90. package/src/runtime-mcp/client.ts +41 -55
  91. package/src/runtime-mcp/content-limits.ts +71 -0
  92. package/src/runtime-mcp/json-rpc.ts +23 -9
  93. package/src/runtime-mcp/transports/http.ts +50 -16
  94. package/src/sdk/bus/chat-daemon-control.ts +20 -7
  95. package/src/sdk/bus/index.ts +8 -7
  96. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  97. package/src/sdk/bus/notification-service.ts +41 -18
  98. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  99. package/src/sdk/bus/telegram-daemon-control.ts +26 -8
  100. package/src/sdk/bus/telegram-daemon.ts +1736 -338
  101. package/src/sdk/bus/topic-registry.ts +451 -12
  102. package/src/sdk/client/discovery.ts +19 -3
  103. package/src/sdk/client/liveness.ts +49 -0
  104. package/src/sdk/host/control/dispatch.ts +14 -5
  105. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  106. package/src/sdk/session.ts +29 -5
  107. package/src/sdk/transport/auth-preface.ts +82 -0
  108. package/src/sdk/transport/index.ts +18 -0
  109. package/src/sdk/transport/relay.ts +252 -0
  110. package/src/sdk/transport/serve-cli.ts +105 -0
  111. package/src/sdk/transport/socket.ts +197 -0
  112. package/src/sdk/transport/stdio.ts +17 -0
  113. package/src/session/agent-session.ts +1212 -768
  114. package/src/session/blob-store.ts +19 -6
  115. package/src/session/btw-contract.ts +59 -0
  116. package/src/session/internal/managed-session-scope.ts +313 -57
  117. package/src/session/internal/managed-session-storage.ts +146 -35
  118. package/src/session/session-manager.ts +385 -85
  119. package/src/session/yield-queue.ts +20 -0
  120. package/src/setup/credential-import.ts +28 -3
  121. package/src/setup/provider-onboarding.ts +30 -1
  122. package/src/setup/provider-presets.json +2 -1
  123. package/src/slash-commands/builtin-registry.ts +37 -1
  124. package/src/ssh/utils.ts +14 -0
  125. package/src/task/discovery.ts +8 -2
  126. package/src/tools/ask-answer-registry.ts +15 -1
  127. package/src/tools/ask.ts +1 -1
  128. package/src/tools/fetch.ts +13 -131
  129. package/src/tools/image-gen.ts +301 -20
  130. package/src/tools/read.ts +2 -0
  131. package/src/tools/skill-discovery.ts +39 -3
  132. package/src/tools/sqlite-reader.ts +63 -5
  133. package/src/utils/pasted-image-path.ts +15 -6
  134. package/src/utils/shell-snapshot.ts +173 -63
  135. package/src/web/insane/bridge.ts +5 -1
  136. package/src/web/insane/url-guard.ts +138 -27
  137. package/src/web/scrapers/types.ts +19 -49
  138. package/src/web/scrapers/utils.ts +12 -26
@@ -1,12 +1,28 @@
1
- import { prompt } from "@gajae-code/utils";
2
1
  import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" };
2
+ import type { BtwConversationScope, BtwTurnCapture } from "../../session/agent-session";
3
+ import {
4
+ BTW_MAX_QUESTION_UTF8_BYTES,
5
+ type BtwTextExchange,
6
+ boundBtwExchanges,
7
+ sanitizeBtwError,
8
+ utf8ByteLength,
9
+ } from "../../session/btw-contract";
3
10
  import { BtwPanelComponent } from "../components/btw-panel";
4
11
  import type { InteractiveModeContext } from "../types";
5
12
 
13
+ type BtwFollowUpResult = "accepted" | "busy" | "closed" | "rejected";
14
+
15
+ interface ActiveBtwTurn extends BtwTurnCapture {
16
+ abortController: AbortController | undefined;
17
+ active: boolean;
18
+ }
19
+
6
20
  interface BtwRequest {
7
21
  component: BtwPanelComponent;
8
- abortController: AbortController;
9
- question: string;
22
+ conversationScope: BtwConversationScope | undefined;
23
+ activeTurn: ActiveBtwTurn | undefined;
24
+ inFlight: boolean;
25
+ contextExchanges: BtwTextExchange[];
10
26
  }
11
27
 
12
28
  export class BtwController {
@@ -18,84 +34,151 @@ export class BtwController {
18
34
  return this.#activeRequest !== undefined;
19
35
  }
20
36
 
37
+ hasOpenPanel(): boolean {
38
+ return this.hasActiveRequest();
39
+ }
40
+
41
+ isTurnInFlight(): boolean {
42
+ return this.#activeRequest?.inFlight ?? false;
43
+ }
44
+
21
45
  handleEscape(): boolean {
22
46
  if (!this.#activeRequest) return false;
23
- this.#closeActiveRequest({ abort: this.#activeRequest.abortController.signal.aborted === false });
47
+ this.#closeActiveRequest();
24
48
  return true;
25
49
  }
26
50
 
27
51
  dispose(): void {
28
- this.#closeActiveRequest({ abort: true });
52
+ this.#closeActiveRequest();
29
53
  }
30
54
 
31
55
  async start(question: string): Promise<void> {
56
+ if (this.hasOpenPanel()) {
57
+ this.ctx.showStatus("A /btw chat is already open. Type a follow-up or press Esc to return to the main chat.");
58
+ return;
59
+ }
32
60
  const trimmedQuestion = question.trim();
33
61
  if (!trimmedQuestion) {
34
62
  this.ctx.showStatus("Usage: /btw <question>");
35
63
  return;
36
64
  }
65
+ if (!this.#acceptQuestion(trimmedQuestion) || !this.#hasModel()) return;
66
+ const scope = this.ctx.session.createBtwConversationScope(btwUserPrompt);
67
+ const request = this.#openRequest(trimmedQuestion, scope);
68
+ void this.#runRequest(request);
69
+ }
37
70
 
38
- const model = this.ctx.session.model;
39
- if (!model) {
40
- this.ctx.showError("No active model available for /btw.");
41
- return;
71
+ async submitFollowUp(question: string): Promise<BtwFollowUpResult> {
72
+ const request = this.#activeRequest;
73
+ if (!request) return "closed";
74
+ if (request.inFlight) {
75
+ this.ctx.showStatus("The /btw chat is still answering. Wait for it to finish.");
76
+ return "busy";
42
77
  }
78
+ const trimmedQuestion = question.trim();
79
+ if (!trimmedQuestion) return "closed";
80
+ if (!this.#acceptQuestion(trimmedQuestion)) return "rejected";
81
+ if (!request.conversationScope) return "closed";
82
+ request.activeTurn = this.#createTurn(trimmedQuestion, request.conversationScope);
83
+ request.inFlight = true;
84
+ request.component.beginTurn(trimmedQuestion);
85
+ void this.#runRequest(request);
86
+ return "accepted";
87
+ }
88
+
89
+ #acceptQuestion(question: string): boolean {
90
+ if (utf8ByteLength(question) <= BTW_MAX_QUESTION_UTF8_BYTES) return true;
91
+ this.ctx.showError(`/btw questions are limited to ${BTW_MAX_QUESTION_UTF8_BYTES} UTF-8 bytes.`);
92
+ return false;
93
+ }
94
+
95
+ #hasModel(): boolean {
96
+ if (this.ctx.session.model) return true;
97
+ this.ctx.showError("No active model available for /btw.");
98
+ return false;
99
+ }
43
100
 
44
- this.#closeActiveRequest({ abort: true });
101
+ #createTurn(question: string, scope: BtwConversationScope): ActiveBtwTurn {
102
+ return { question, scope, abortController: new AbortController(), active: true };
103
+ }
45
104
 
105
+ #openRequest(question: string, scope: BtwConversationScope): BtwRequest {
46
106
  const request: BtwRequest = {
47
- component: new BtwPanelComponent({ question: trimmedQuestion, tui: this.ctx.ui }),
48
- abortController: new AbortController(),
49
- question: trimmedQuestion,
107
+ component: new BtwPanelComponent({ question, tui: this.ctx.ui }),
108
+ conversationScope: scope,
109
+ activeTurn: this.#createTurn(question, scope),
110
+ inFlight: true,
111
+ contextExchanges: [],
50
112
  };
51
113
  this.ctx.btwContainer.clear();
52
114
  this.ctx.btwContainer.addChild(request.component);
53
115
  this.ctx.ui.requestRender();
54
116
  this.#activeRequest = request;
55
- void this.#runRequest(request);
117
+ return request;
56
118
  }
57
119
 
58
120
  async #runRequest(request: BtwRequest): Promise<void> {
121
+ const turn = request.activeTurn;
122
+ const abortController = turn?.abortController;
123
+ if (!turn?.scope || !abortController) return;
59
124
  try {
60
- const promptText = prompt.render(btwUserPrompt, { question: request.question });
61
125
  const { replyText } = await this.ctx.session.runEphemeralTurn({
62
126
  purpose: "btw",
63
- promptText,
127
+ turn,
128
+ contextExchanges: request.contextExchanges.map(exchange => ({ ...exchange })),
64
129
  onTextDelta: delta => {
65
- if (this.#isActiveRequest(request)) {
66
- request.component.appendText(delta);
67
- }
130
+ if (turn.active && this.#isActiveRequest(request)) request.component.appendText(delta);
68
131
  },
69
- signal: request.abortController.signal,
132
+ signal: abortController.signal,
70
133
  });
71
-
72
- if (!this.#isActiveRequest(request)) {
73
- return;
74
- }
75
- if (replyText) {
76
- request.component.setAnswer(replyText);
77
- }
134
+ if (!turn.active || !this.#isActiveRequest(request)) return;
135
+ request.inFlight = false;
136
+ request.contextExchanges = boundBtwExchanges([
137
+ ...request.contextExchanges,
138
+ { question: turn.question, answer: replyText },
139
+ ]);
140
+ if (replyText) request.component.setAnswer(replyText);
78
141
  request.component.markComplete();
79
- } catch (error) {
80
- if (!this.#isActiveRequest(request)) {
81
- return;
82
- }
83
- if (request.abortController.signal.aborted) {
142
+ } catch {
143
+ if (!turn.active || !this.#isActiveRequest(request)) return;
144
+ request.inFlight = false;
145
+ if (abortController.signal.aborted) {
84
146
  request.component.markAborted();
85
147
  return;
86
148
  }
87
- request.component.markError(error instanceof Error ? error.message : String(error));
149
+ request.component.markError(sanitizeBtwError("Side-chat request failed."));
150
+ } finally {
151
+ if (request.activeTurn === turn) request.activeTurn = undefined;
152
+ this.#scrubTurn(turn);
88
153
  }
89
154
  }
90
155
 
91
- #closeActiveRequest(options: { abort: boolean }): void {
156
+ #scrubTurn(turn: ActiveBtwTurn): void {
157
+ turn.active = false;
158
+ turn.question = "";
159
+ turn.abortController = undefined;
160
+ turn.scope = undefined;
161
+ }
162
+
163
+ #closeActiveRequest(): void {
92
164
  const request = this.#activeRequest;
93
165
  if (!request) return;
94
166
  this.#activeRequest = undefined;
95
- if (options.abort) {
96
- request.abortController.abort();
167
+ const turn = request.activeTurn;
168
+ const abortController = turn?.abortController;
169
+ if (turn) this.#scrubTurn(turn);
170
+ request.activeTurn = undefined;
171
+ request.contextExchanges.splice(0);
172
+ request.inFlight = false;
173
+ if (request.conversationScope) {
174
+ request.conversationScope.messages.splice(0);
175
+ request.conversationScope.systemPrompt.splice(0);
176
+ request.conversationScope = undefined;
97
177
  }
178
+ abortController?.abort();
98
179
  request.component.close();
180
+ this.ctx.editor?.setText("");
181
+ this.ctx.pendingImages = [];
99
182
  this.ctx.btwContainer.clear();
100
183
  this.ctx.ui.requestRender();
101
184
  }
@@ -1260,6 +1260,10 @@ export class CommandController {
1260
1260
  }
1261
1261
 
1262
1262
  async handleHandoffCommand(customInstructions?: string): Promise<void> {
1263
+ if (this.ctx.session.isStreaming) {
1264
+ this.ctx.showWarning("Wait for the current response to finish or abort it before handing off.");
1265
+ return;
1266
+ }
1263
1267
  const entries = this.ctx.sessionManager.getEntries();
1264
1268
  const messageCount = entries.filter(e => e.type === "message").length;
1265
1269
 
@@ -1321,6 +1325,17 @@ export class CommandController {
1321
1325
  } else {
1322
1326
  this.ctx.showError(`Handoff failed: ${message}`);
1323
1327
  }
1328
+ // The failure is non-destructive: the current session is unchanged. If the
1329
+ // document was already generated, preserve it on the clipboard for retry.
1330
+ const retainedDocument =
1331
+ error &&
1332
+ typeof error === "object" &&
1333
+ typeof (error as { handoffDocument?: unknown }).handoffDocument === "string"
1334
+ ? (error as { handoffDocument: string }).handoffDocument
1335
+ : undefined;
1336
+ if (retainedDocument) {
1337
+ this.#doCopy(retainedDocument, "Handoff document preserved on clipboard; current session is unchanged.");
1338
+ }
1324
1339
  } finally {
1325
1340
  handoffLoader.stop();
1326
1341
  this.ctx.statusContainer.clear();
@@ -214,13 +214,25 @@ export class ExtensionUiController {
214
214
  try {
215
215
  return {
216
216
  handoff: await session.handoff(
217
- typeof input.instructions === "string" ? input.instructions : undefined,
217
+ typeof input.target === "string"
218
+ ? input.target
219
+ : typeof input.instructions === "string"
220
+ ? input.instructions
221
+ : undefined,
218
222
  ),
219
223
  };
220
224
  } catch (error) {
225
+ const typed = error as { code?: unknown; handoffDocument?: unknown };
226
+ const handoffDocument =
227
+ typeof typed?.handoffDocument === "string" ? { handoffDocument: typed.handoffDocument } : undefined;
228
+ // Preserve a safe typed code (e.g. transient `busy`) so clients keep
229
+ // correct retry/backoff semantics; only synthesize invalid_request for
230
+ // otherwise-untyped failures.
231
+ const code = typed?.code === "busy" ? "busy" : "invalid_request";
221
232
  throw Object.assign(
222
233
  new Error(error instanceof Error ? error.message : "Handoff is unavailable for the current state."),
223
- { code: "invalid_request" },
234
+ { code },
235
+ handoffDocument,
224
236
  );
225
237
  }
226
238
  case "session.export_html":
@@ -752,6 +752,29 @@ export class InputController {
752
752
  async submitText(text: string, composer: ComposerSubmissionOptions): Promise<void> {
753
753
  text = text.trim();
754
754
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
755
+ if (this.ctx.hasActiveBtw()) {
756
+ if (!text) return;
757
+ if (!text.startsWith("/")) {
758
+ const result = await this.ctx.handleBtwFollowUp(text);
759
+ if (result === "accepted" && this.#canModifyComposer(composer)) {
760
+ this.ctx.editor.setText("");
761
+ this.ctx.pendingImages = [];
762
+ }
763
+ return;
764
+ }
765
+ }
766
+
767
+ if (/^\/btw(?:\s|$)/.test(text)) {
768
+ const slashResult = await executeBuiltinSlashCommand(text, {
769
+ ctx: this.ctx,
770
+ handleBackgroundCommand: () => this.handleBackgroundCommand(),
771
+ composer,
772
+ });
773
+ if (slashResult === true) {
774
+ this.ctx.pendingImages = [];
775
+ return;
776
+ }
777
+ }
755
778
 
756
779
  // Empty submit while streaming with queued messages: flush queues immediately
757
780
  if (!text && this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount > 0) {
@@ -1357,6 +1380,14 @@ export class InputController {
1357
1380
  async handleFollowUp(): Promise<void> {
1358
1381
  const text = this.ctx.editor.getText().trim();
1359
1382
  if (!text) return;
1383
+ // While /btw is open, plain text stays in the side chat. Slash-origin
1384
+ // input keeps normal dispatch so commands remain available.
1385
+ if (this.ctx.hasActiveBtw() && !text.startsWith("/")) {
1386
+ if ((await this.ctx.handleBtwFollowUp(text)) === "accepted") {
1387
+ this.ctx.editor.setText("");
1388
+ }
1389
+ return;
1390
+ }
1360
1391
 
1361
1392
  // Compaction first: while compacting, queue free text and `/skill:*`
1362
1393
  // commands in the compaction-local queue. `flushCompactionQueue`
@@ -101,8 +101,10 @@ import {
101
101
  } from "../../setup/model-onboarding-guidance";
102
102
  import { addApiCompatibleProvider, formatProviderSetupResult } from "../../setup/provider-onboarding";
103
103
  import {
104
+ IMAGE_PROVIDER_DEFAULTS,
104
105
  isConfigurableSearchProviderId,
105
106
  isSearchProviderPreference,
107
+ setConfiguredImageModel,
106
108
  setPreferredImageProvider,
107
109
  setPreferredSearchProvider,
108
110
  setSearchFallbackProviders,
@@ -190,6 +192,7 @@ export function buildStatusLineSettings(settingsInstance: Settings): StatusLineS
190
192
  rightSegments: settingsInstance.get("statusLine.rightSegments"),
191
193
  separator: settingsInstance.get("statusLine.separator"),
192
194
  showHookStatus: settingsInstance.get("statusLine.showHookStatus"),
195
+ showActionHints: settingsInstance.get("statusLine.showActionHints"),
193
196
  sessionAccent: settingsInstance.get("statusLine.sessionAccent"),
194
197
  maxRows: settingsInstance.get("statusLine.maxRows"),
195
198
  segmentOptions: settingsInstance.get("statusLine.segmentOptions"),
@@ -1103,6 +1106,68 @@ export class SelectorController {
1103
1106
  }
1104
1107
  }
1105
1108
 
1109
+ async #handleImageGenerationConfig(): Promise<void> {
1110
+ const provider = await this.ctx.showHookInput(
1111
+ "Image Generation provider (auto, openai, gemini, openrouter, antigravity, custom)",
1112
+ "auto",
1113
+ );
1114
+ if (provider === undefined) return;
1115
+ const normalized = provider.trim().toLowerCase();
1116
+ const validProviders = ["auto", "openai", "gemini", "openrouter", "antigravity", "custom"];
1117
+ if (!validProviders.includes(normalized)) {
1118
+ this.ctx.showStatus(`Invalid image provider: ${normalized}. Valid: ${validProviders.join(", ")}`);
1119
+ return;
1120
+ }
1121
+ let model: string | undefined;
1122
+ if (normalized !== "auto" && normalized !== "custom") {
1123
+ const defaultModel = IMAGE_PROVIDER_DEFAULTS[normalized];
1124
+ model = await this.ctx.showHookInput(`Image model for ${normalized} (default: ${defaultModel})`, defaultModel);
1125
+ if (model === undefined) return;
1126
+ model = model.trim() || defaultModel;
1127
+ }
1128
+ let customUrl: string | undefined;
1129
+ let customKey: string | undefined;
1130
+ if (normalized === "custom") {
1131
+ customUrl = await this.ctx.showHookInput("Custom image endpoint base URL");
1132
+ if (!customUrl?.trim()) {
1133
+ this.ctx.showStatus("Custom image endpoint requires a base URL");
1134
+ return;
1135
+ }
1136
+ model = await this.ctx.showHookInput("Custom image model", IMAGE_PROVIDER_DEFAULTS.openai);
1137
+ if (model === undefined) return;
1138
+ model = model.trim() || IMAGE_PROVIDER_DEFAULTS.openai;
1139
+ customKey = await this.ctx.showHookInput("Custom image endpoint API key");
1140
+ }
1141
+ const scope = await this.ctx.showHookInput(
1142
+ "Scope: 'session' (this session only) or 'default' (persist)",
1143
+ "session",
1144
+ );
1145
+ if (scope === undefined) return;
1146
+ const persistDefault = scope.trim().toLowerCase() === "default";
1147
+
1148
+ const imageProvider = normalized as "auto" | "openai" | "gemini" | "openrouter" | "antigravity" | "custom";
1149
+ setPreferredImageProvider(imageProvider === "custom" ? "auto" : imageProvider);
1150
+ setConfiguredImageModel({
1151
+ provider: imageProvider,
1152
+ model: model ?? null,
1153
+ customUrl: customUrl?.trim(),
1154
+ customKey: customKey?.trim(),
1155
+ });
1156
+
1157
+ if (persistDefault) {
1158
+ this.ctx.settings.set("providers.image", imageProvider);
1159
+ if (model) this.ctx.settings.set("providers.imageModel", model);
1160
+ if (customUrl?.trim()) this.ctx.settings.set("providers.imageCustomUrl", customUrl.trim());
1161
+ if (customKey?.trim()) this.ctx.settings.set("providers.imageCustomKey", customKey.trim());
1162
+ }
1163
+
1164
+ const displayModel =
1165
+ model ?? (normalized !== "auto" && normalized !== "custom" ? IMAGE_PROVIDER_DEFAULTS[normalized] : undefined);
1166
+ const label = normalized === "auto" ? "Auto" : `${normalized}${displayModel ? ` (${displayModel})` : ""}`;
1167
+ this.ctx.showStatus(`Image Generation: ${label}${persistDefault ? " (default)" : " (session)"}`);
1168
+ this.ctx.ui.requestRender();
1169
+ }
1170
+
1106
1171
  showCustomProviderWizard(): void {
1107
1172
  this.showSelector(done => {
1108
1173
  let wizard: CustomProviderWizardComponent;
@@ -1516,6 +1581,7 @@ export class SelectorController {
1516
1581
  case "statusLine.separator":
1517
1582
  case "statusLineShowHooks":
1518
1583
  case "statusLine.showHookStatus":
1584
+ case "statusLine.showActionHints":
1519
1585
  case "statusLine.sessionAccent":
1520
1586
  case "statusLine.maxRows":
1521
1587
  case "statusLine.leftSegments":
@@ -1563,16 +1629,34 @@ export class SelectorController {
1563
1629
  }
1564
1630
  break;
1565
1631
  case "providers.image":
1632
+ case "providers.imageModel":
1633
+ case "providers.imageCustomUrl":
1634
+ case "providers.imageCustomKey":
1635
+ case "providers.imageCustomKeyEnv": {
1636
+ const imgProvider = this.ctx.settings.get("providers.image");
1637
+ const imgModel = this.ctx.settings.get("providers.imageModel");
1638
+ const imgCustomUrl = this.ctx.settings.get("providers.imageCustomUrl");
1639
+ const imgCustomKey = this.ctx.settings.get("providers.imageCustomKey");
1640
+ const imgCustomKeyEnv = this.ctx.settings.get("providers.imageCustomKeyEnv");
1566
1641
  if (
1567
- value === "auto" ||
1568
- value === "openai" ||
1569
- value === "gemini" ||
1570
- value === "openrouter" ||
1571
- value === "antigravity"
1642
+ imgProvider === "auto" ||
1643
+ imgProvider === "openai" ||
1644
+ imgProvider === "gemini" ||
1645
+ imgProvider === "openrouter" ||
1646
+ imgProvider === "antigravity" ||
1647
+ imgProvider === "custom"
1572
1648
  ) {
1573
- setPreferredImageProvider(value);
1649
+ setPreferredImageProvider(imgProvider === "custom" ? "auto" : imgProvider);
1650
+ setConfiguredImageModel({
1651
+ provider: imgProvider,
1652
+ model: imgModel ?? null,
1653
+ customUrl: imgCustomUrl,
1654
+ customKey: imgCustomKey,
1655
+ customKeyEnv: imgCustomKeyEnv,
1656
+ });
1574
1657
  }
1575
1658
  break;
1659
+ }
1576
1660
 
1577
1661
  // MCP update injection - live subscribe/unsubscribe
1578
1662
  case "mcp.notifications":
@@ -1631,6 +1715,11 @@ export class SelectorController {
1631
1715
  await this.#deleteCustomModelPreset(selection.profileName, modelSelector);
1632
1716
  return;
1633
1717
  }
1718
+ if (selection.kind === "imageGeneration") {
1719
+ done();
1720
+ await this.#handleImageGenerationConfig();
1721
+ return;
1722
+ }
1634
1723
  if (selection.kind === "profile") {
1635
1724
  await this.#applyModelProfile(selection.profileName, selection.setDefault);
1636
1725
  done();
@@ -2057,10 +2146,7 @@ export class SelectorController {
2057
2146
  }
2058
2147
 
2059
2148
  async showSessionSelector(): Promise<void> {
2060
- const sessions = await SessionManager.listForResumePickerReadOnly(
2061
- this.ctx.sessionManager.getCwd(),
2062
- this.ctx.sessionManager.getSessionDir(),
2063
- );
2149
+ const sessions = await this.ctx.sessionManager.listForResumePickerReadOnly();
2064
2150
  this.showSelector(done => {
2065
2151
  const selector = new SessionSelectorComponent(
2066
2152
  sessions,
@@ -2156,8 +2242,16 @@ export class SelectorController {
2156
2242
  this.#clearTransientSessionUi();
2157
2243
  const migrationPolicy =
2158
2244
  this.ctx.settings?.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain";
2159
- const writableSessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
2160
-
2245
+ let writableSessionPath = sessionPath;
2246
+ if (this.ctx.sessionManager.isManagedDestination()) {
2247
+ const inspection = await SessionManager.inspectSessionTailReadOnly(sessionPath);
2248
+ if (inspection.kind === "error") throw new Error(`Could not inspect selected session: ${inspection.reason}`);
2249
+ writableSessionPath = await this.ctx.sessionManager.prepareManagedCandidateForStrictAdoption(
2250
+ sessionPath,
2251
+ migrationPolicy,
2252
+ inspection.identity,
2253
+ );
2254
+ }
2161
2255
  // Switch session via AgentSession (emits hook and tool session events)
2162
2256
  if (!(await this.ctx.session.switchSession(writableSessionPath))) return;
2163
2257
  const switchingToDifferentSession = previousSessionId !== this.ctx.sessionManager.getSessionId();
@@ -264,6 +264,10 @@ export interface InteractiveModeOptions {
264
264
  initialMessages?: string[];
265
265
  }
266
266
 
267
+ export function selectShutdownDraft(editorText: string, hasActiveBtw: boolean): string {
268
+ return hasActiveBtw ? "" : editorText;
269
+ }
270
+
267
271
  export class InteractiveMode implements InteractiveModeContext {
268
272
  session: AgentSession;
269
273
  sessionManager: SessionManager;
@@ -1322,19 +1326,20 @@ export class InteractiveMode implements InteractiveModeContext {
1322
1326
  if (this.#isShuttingDown) return;
1323
1327
  this.#isShuttingDown = true;
1324
1328
 
1325
- // Snapshot the editor before any teardown empties it. Persisting the draft
1326
- // here covers Ctrl+D shutdown with non-empty text; for /exit the editor is
1327
- // already cleared so saveDraft("") just removes any stale sidecar.
1328
- const draftText = this.editor.getText();
1329
+ // `/btw` owns the shared composer while its panel is open. Never persist a
1330
+ // side-chat draft or pending side-chat images into the main-session draft.
1331
+ const hadActiveBtw = this.#btwController.hasOpenPanel();
1332
+ const draftText = selectShutdownDraft(this.editor.getText(), hadActiveBtw);
1333
+ if (hadActiveBtw) this.pendingImages = [];
1334
+ this.#btwController.dispose();
1329
1335
 
1330
- // Flush pending session writes before shutdown
1336
+ // Flush pending session writes before shutdown.
1331
1337
  await this.sessionManager.flush();
1332
1338
  try {
1333
1339
  await this.sessionManager.saveDraft(draftText);
1334
1340
  } catch (err) {
1335
1341
  logger.warn("Failed to save session draft", { error: String(err) });
1336
1342
  }
1337
- this.#btwController.dispose();
1338
1343
 
1339
1344
  // Emit shutdown event to hooks
1340
1345
  this.session.setSdkPlanModeHandler(null);
@@ -2025,7 +2030,11 @@ export class InteractiveMode implements InteractiveModeContext {
2025
2030
  }
2026
2031
 
2027
2032
  hasActiveBtw(): boolean {
2028
- return this.#btwController.hasActiveRequest();
2033
+ return this.#btwController.hasOpenPanel();
2034
+ }
2035
+
2036
+ handleBtwFollowUp(question: string): Promise<"accepted" | "busy" | "closed" | "rejected"> {
2037
+ return this.#btwController.submitFollowUp(question);
2029
2038
  }
2030
2039
 
2031
2040
  handleBtwEscape(): boolean {
@@ -301,15 +301,29 @@ export async function initializeExtensions(session: AgentSession, options: Initi
301
301
  try {
302
302
  return {
303
303
  handoff: await session.handoff(
304
- typeof input.instructions === "string" ? input.instructions : undefined,
304
+ typeof input.target === "string"
305
+ ? input.target
306
+ : typeof input.instructions === "string"
307
+ ? input.instructions
308
+ : undefined,
305
309
  ),
306
310
  };
307
311
  } catch (error) {
312
+ const typed = error as { code?: unknown; handoffDocument?: unknown };
313
+ const handoffDocument =
314
+ typeof typed?.handoffDocument === "string"
315
+ ? { handoffDocument: typed.handoffDocument }
316
+ : undefined;
317
+ // Preserve a safe typed code (e.g. transient `busy`) so clients keep
318
+ // correct retry/backoff semantics; only synthesize invalid_request for
319
+ // otherwise-untyped failures.
320
+ const code = typed?.code === "busy" ? "busy" : "invalid_request";
308
321
  throw Object.assign(
309
322
  new Error(
310
323
  error instanceof Error ? error.message : "Handoff is unavailable for the current state.",
311
324
  ),
312
- { code: "invalid_request" },
325
+ { code },
326
+ handoffDocument,
313
327
  );
314
328
  }
315
329
  case "session.export_html":
@@ -338,6 +338,7 @@ export interface InteractiveModeContext {
338
338
  handleBackgroundCommand(): void;
339
339
  handleImagePaste(): Promise<boolean>;
340
340
  handleBtwCommand(question: string): Promise<void>;
341
+ handleBtwFollowUp(question: string): Promise<"accepted" | "busy" | "closed" | "rejected">;
341
342
  hasActiveBtw(): boolean;
342
343
  handleBtwEscape(): boolean;
343
344
  cycleThinkingLevel(): void;
@@ -1,8 +1,3 @@
1
- <btw>
2
- This is an ephemeral side question for the current interactive session.
3
- Answer briefly and directly using the conversation context already provided.
4
- Do not use tools.
5
- Do not ask follow-up questions.
6
- Question:
7
- {{question}}
8
- </btw>
1
+ You are answering inside an ephemeral multi-turn side chat for the interactive session.
2
+ Use only the sanitized visible-text conversation scope and prior side-chat turns provided as messages.
3
+ Answer directly. Do not use tools or request hidden session state.
@@ -3,6 +3,7 @@ Discover project and user runtime skills without loading full skill content.
3
3
  <instruction>
4
4
  - Searches only custom runtime skill locations: nearest project `.gjc/skills`; then, under the home directory, canonical `<config>/agent/skills`, configured legacy `<config>/skills`, and historical legacy `.gjc/skills`. `<config>` is the home-relative directory name from `GJC_CONFIG_DIR`, then `PI_CONFIG_DIR`, then `.gjc`; even an absolute-looking configured name is joined beneath `<home>`. Duplicate names use that exact precedence. Built-in, bundled, and internal workflow skills are intentionally excluded.
5
5
  - Returns thin metadata only: name, description, source scope, path, and use conditions when present.
6
+ - When zero candidates are returned because discovery config is disabled (`skills.enabled`, `skills.enablePiProject`, `skills.enablePiUser`), the result carries a `notice` explaining which setting blocked the search — an empty result without a `notice` means the searched scopes genuinely contain no matching skills.
6
7
  - To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.
7
8
  </instruction>
8
9