@gajae-code/coding-agent 0.4.5 → 0.5.0

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 (87) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/types/commands/harness.d.ts +3 -0
  3. package/dist/types/config/model-profile-activation.d.ts +11 -2
  4. package/dist/types/config/model-profiles.d.ts +7 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/model-resolver.d.ts +2 -0
  7. package/dist/types/config/models-config-schema.d.ts +30 -0
  8. package/dist/types/config/settings-schema.d.ts +4 -3
  9. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
  10. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  11. package/dist/types/harness-control-plane/owner.d.ts +1 -1
  12. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  13. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  14. package/dist/types/harness-control-plane/types.d.ts +4 -0
  15. package/dist/types/hindsight/mental-models.d.ts +5 -5
  16. package/dist/types/modes/components/model-selector.d.ts +1 -12
  17. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  18. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  19. package/dist/types/sdk.d.ts +5 -0
  20. package/dist/types/session/agent-session.d.ts +2 -0
  21. package/dist/types/session/blob-store.d.ts +20 -1
  22. package/dist/types/session/session-manager.d.ts +24 -6
  23. package/dist/types/session/streaming-output.d.ts +3 -2
  24. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  25. package/dist/types/task/receipt.d.ts +1 -0
  26. package/dist/types/task/types.d.ts +7 -0
  27. package/dist/types/thinking-metadata.d.ts +16 -0
  28. package/dist/types/thinking.d.ts +3 -12
  29. package/dist/types/tools/index.d.ts +2 -0
  30. package/dist/types/tools/resolve.d.ts +0 -10
  31. package/dist/types/utils/tool-choice.d.ts +14 -1
  32. package/package.json +7 -7
  33. package/src/cli.ts +8 -4
  34. package/src/commands/harness.ts +36 -2
  35. package/src/commands/launch.ts +2 -2
  36. package/src/commands/session.ts +3 -1
  37. package/src/config/model-profile-activation.ts +15 -3
  38. package/src/config/model-profiles.ts +255 -56
  39. package/src/config/model-resolver.ts +9 -6
  40. package/src/config/models-config-schema.ts +1 -0
  41. package/src/config/settings-schema.ts +6 -3
  42. package/src/coordinator-mcp/server.ts +54 -23
  43. package/src/cursor.ts +16 -2
  44. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  45. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  46. package/src/export/html/index.ts +13 -9
  47. package/src/gjc-runtime/team-runtime.ts +33 -7
  48. package/src/gjc-runtime/tmux-common.ts +15 -0
  49. package/src/gjc-runtime/tmux-sessions.ts +19 -11
  50. package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
  51. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  52. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  53. package/src/harness-control-plane/owner.ts +78 -27
  54. package/src/harness-control-plane/receipt-spool.ts +128 -0
  55. package/src/harness-control-plane/state-machine.ts +27 -6
  56. package/src/harness-control-plane/storage.ts +23 -0
  57. package/src/harness-control-plane/types.ts +4 -0
  58. package/src/hindsight/mental-models.ts +17 -16
  59. package/src/internal-urls/docs-index.generated.ts +2 -2
  60. package/src/modes/components/assistant-message.ts +26 -14
  61. package/src/modes/components/diff.ts +97 -0
  62. package/src/modes/components/model-selector.ts +353 -181
  63. package/src/modes/components/tool-execution.ts +30 -13
  64. package/src/modes/controllers/selector-controller.ts +33 -42
  65. package/src/modes/rpc/rpc-client.ts +3 -2
  66. package/src/modes/rpc/rpc-mode.ts +44 -14
  67. package/src/modes/rpc/rpc-types.ts +5 -2
  68. package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
  69. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  70. package/src/sdk.ts +29 -2
  71. package/src/secrets/obfuscator.ts +102 -27
  72. package/src/session/agent-session.ts +105 -20
  73. package/src/session/blob-store.ts +89 -3
  74. package/src/session/session-manager.ts +309 -58
  75. package/src/session/streaming-output.ts +185 -122
  76. package/src/session/tool-choice-queue.ts +23 -0
  77. package/src/task/executor.ts +69 -6
  78. package/src/task/receipt.ts +5 -0
  79. package/src/task/render.ts +21 -1
  80. package/src/task/types.ts +8 -0
  81. package/src/thinking-metadata.ts +51 -0
  82. package/src/thinking.ts +26 -46
  83. package/src/tools/bash.ts +1 -1
  84. package/src/tools/index.ts +2 -0
  85. package/src/tools/resolve.ts +93 -18
  86. package/src/utils/edit-mode.ts +1 -1
  87. package/src/utils/tool-choice.ts +45 -16
@@ -152,6 +152,7 @@ export class ToolExecutionComponent extends Container {
152
152
  isError?: boolean;
153
153
  details?: any;
154
154
  };
155
+ #textOutputCache?: { content: unknown; showImages: boolean; terminalImageProtocol: unknown; output: string };
155
156
  // Edit preview state
156
157
  #editMode?: EditMode;
157
158
  #editDiffPreview?: PerFileDiffPreview[];
@@ -197,9 +198,11 @@ export class ToolExecutionComponent extends Container {
197
198
 
198
199
  this.addChild(new Spacer(1));
199
200
 
200
- // Always create both - contentBox for custom tools/bash/tools with renderers, contentText for other built-ins
201
- this.#contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
202
- this.#contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
201
+ // Always create both - contentBox for custom tools/bash/tools with renderers, contentText for other built-ins.
202
+ // Vertical padding is 0: block separation comes solely from the leading Spacer
203
+ // (1 blank line above each block), matching reference TUIs (083.2).
204
+ this.#contentBox = new Box(1, 0, (text: string) => theme.bg("toolPendingBg", text));
205
+ this.#contentText = new Text("", 1, 0, (text: string) => theme.bg("toolPendingBg", text));
203
206
 
204
207
  // Use Box for custom tools or built-in tools that have renderers
205
208
  const hasRenderer = toolName in toolRenderers;
@@ -295,6 +298,7 @@ export class ToolExecutionComponent extends Container {
295
298
  isPartial = false,
296
299
  _toolCallId?: string,
297
300
  ): void {
301
+ this.#textOutputCache = undefined;
298
302
  this.#result = result;
299
303
  this.#isPartial = isPartial;
300
304
  // When tool is complete, ensure args are marked complete so spinner stops
@@ -397,6 +401,7 @@ export class ToolExecutionComponent extends Container {
397
401
 
398
402
  setShowImages(show: boolean): void {
399
403
  this.#showImages = show;
404
+ this.#textOutputCache = undefined;
400
405
  this.#updateDisplay();
401
406
  }
402
407
 
@@ -514,7 +519,7 @@ export class ToolExecutionComponent extends Container {
514
519
  const fileBgFn = fileResult.isError
515
520
  ? (text: string) => theme.bg("toolErrorBg", text)
516
521
  : (text: string) => theme.bg("toolSuccessBg", text);
517
- const fileBox = new Box(1, 1, fileBgFn);
522
+ const fileBox = new Box(1, 0, fileBgFn);
518
523
  try {
519
524
  const resultComponent = renderer.renderResult(
520
525
  { content: [], details: fileResult, isError: fileResult.isError },
@@ -540,7 +545,7 @@ export class ToolExecutionComponent extends Container {
540
545
  const pendingSpacer = new Spacer(1);
541
546
  this.#multiFileBoxes.push(pendingSpacer);
542
547
  this.addChild(pendingSpacer);
543
- const pendingBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
548
+ const pendingBox = new Box(1, 0, (text: string) => theme.bg("toolPendingBg", text));
544
549
  const pendingText = renderStatusLine(
545
550
  {
546
551
  icon: "pending",
@@ -723,16 +728,27 @@ export class ToolExecutionComponent extends Container {
723
728
  #getTextOutput(): string {
724
729
  if (!this.#result) return "";
725
730
 
726
- const textBlocks = this.#result.content?.filter((c: any) => c.type === "text") || [];
727
- const imageBlocks = this.#getAllImageBlocks();
731
+ const content = this.#result.content;
732
+ const terminalImageProtocol = TERMINAL.imageProtocol;
733
+ const cached = this.#textOutputCache;
734
+ if (
735
+ cached?.content === content &&
736
+ cached.showImages === this.#showImages &&
737
+ cached.terminalImageProtocol === terminalImageProtocol
738
+ ) {
739
+ return cached.output;
740
+ }
728
741
 
729
- let output = textBlocks
730
- .map((c: any) => {
731
- return sanitizeWithOptionalSixelPassthrough(c.text || "", sanitizeText);
732
- })
733
- .join("\n");
742
+ const textParts: string[] = [];
743
+ for (const block of content ?? []) {
744
+ if (block.type !== "text") continue;
745
+ const text = block.text || "";
746
+ textParts.push(sanitizeWithOptionalSixelPassthrough(text, sanitizeText));
747
+ }
748
+ let output = textParts.join("\n");
734
749
 
735
- if (imageBlocks.length > 0 && (!TERMINAL.imageProtocol || !this.#showImages)) {
750
+ const imageBlocks = this.#getAllImageBlocks();
751
+ if (imageBlocks.length > 0 && (!terminalImageProtocol || !this.#showImages)) {
736
752
  const imageIndicators = imageBlocks
737
753
  .map((img: any) => {
738
754
  const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
@@ -742,6 +758,7 @@ export class ToolExecutionComponent extends Container {
742
758
  output = output ? `${output}\n${imageIndicators}` : imageIndicators;
743
759
  }
744
760
 
761
+ this.#textOutputCache = { content, showImages: this.#showImages, terminalImageProtocol, output };
745
762
  return output;
746
763
  }
747
764
 
@@ -5,6 +5,7 @@ import type { Component, OverlayHandle } from "@gajae-code/tui";
5
5
  import { Input, Loader, Spacer, Text } from "@gajae-code/tui";
6
6
  import { getAgentDbPath, getProjectDir } from "@gajae-code/utils";
7
7
  import { activateModelProfile } from "../../config/model-profile-activation";
8
+ import { recommendModelProfileForProvider } from "../../config/model-profiles";
8
9
  import { settings } from "../../config/settings";
9
10
  import { DebugSelectorComponent } from "../../debug";
10
11
  import { disableProvider, enableProvider } from "../../discovery";
@@ -45,7 +46,7 @@ import { CustomProviderWizardComponent, type CustomProviderWizardSubmit } from "
45
46
  import { ExtensionDashboard } from "../components/extensions";
46
47
  import { HistorySearchComponent } from "../components/history-search";
47
48
  import { JobsOverlayComponent } from "../components/jobs-overlay";
48
- import { ModelSelectorComponent, type ModelSelectorSelection } from "../components/model-selector";
49
+ import { ModelSelectorComponent } from "../components/model-selector";
49
50
  import { OAuthSelectorComponent } from "../components/oauth-selector";
50
51
  import { PluginSelectorComponent } from "../components/plugin-selector";
51
52
  import {
@@ -537,12 +538,6 @@ export class SelectorController {
537
538
  this.ctx.session.scopedModels,
538
539
  async selection => {
539
540
  try {
540
- if (selection.kind === "preset") {
541
- await this.#applyModelAssignmentPreset(selection);
542
- done();
543
- this.ctx.ui.requestRender();
544
- return;
545
- }
546
541
  if (selection.kind === "profile") {
547
542
  await activateModelProfile(
548
543
  {
@@ -609,45 +604,12 @@ export class SelectorController {
609
604
  done();
610
605
  this.ctx.ui.requestRender();
611
606
  },
612
- options,
607
+ { ...options, sessionId: this.ctx.session.sessionId },
613
608
  );
614
609
  return { component: selector, focus: selector };
615
610
  });
616
611
  }
617
612
 
618
- async #applyModelAssignmentPreset(selection: Extract<ModelSelectorSelection, { kind: "preset" }>): Promise<void> {
619
- const { assignments, model, preset, selector } = selection;
620
- const apiKey = await this.ctx.session.modelRegistry.getApiKey(model, this.ctx.session.sessionId);
621
- if (!apiKey) {
622
- throw new Error(`No API key for ${model.provider}/${model.id}`);
623
- }
624
-
625
- const defaultThinkingLevel = assignments.default;
626
- await this.ctx.session.setModel(model, "default", {
627
- selector,
628
- thinkingLevel: defaultThinkingLevel,
629
- });
630
- if (defaultThinkingLevel && defaultThinkingLevel !== ThinkingLevel.Inherit) {
631
- this.ctx.session.setThinkingLevel(defaultThinkingLevel);
632
- }
633
-
634
- const overrides = this.ctx.settings.get("task.agentModelOverrides");
635
- const nextOverrides = { ...overrides };
636
- for (const [targetRole, presetThinkingLevel] of Object.entries(assignments) as [
637
- keyof Extract<ModelSelectorSelection, { kind: "preset" }>["assignments"],
638
- ThinkingLevel,
639
- ][]) {
640
- if (!targetRole || targetRole === "default") continue;
641
- nextOverrides[targetRole] =
642
- presetThinkingLevel === ThinkingLevel.Inherit ? selector : `${selector}:${presetThinkingLevel}`;
643
- }
644
- this.ctx.settings.set("task.agentModelOverrides", nextOverrides);
645
- this.ctx.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
646
- this.ctx.statusLine.invalidate();
647
- this.ctx.updateEditorBorderColor();
648
- this.ctx.showStatus(`${preset.label}: ${selector}`);
649
- }
650
-
651
613
  async showPluginSelector(mode: "install" | "uninstall" = "install"): Promise<void> {
652
614
  const mgr = new MarketplaceManager({
653
615
  marketplacesRegistryPath: getMarketplacesRegistryPath(),
@@ -1034,6 +996,34 @@ export class SelectorController {
1034
996
  await this.showSessionSelector();
1035
997
  }
1036
998
 
999
+ async #handlePostLoginModelProfileRecommendation(providerId: string): Promise<void> {
1000
+ const recommendedProfile = recommendModelProfileForProvider(
1001
+ providerId,
1002
+ this.ctx.session.modelRegistry.getModelProfiles(),
1003
+ );
1004
+ if (!recommendedProfile) {
1005
+ return;
1006
+ }
1007
+
1008
+ const activeProfile = this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default");
1009
+ if (activeProfile) {
1010
+ this.ctx.showStatus(`Preset ${recommendedProfile.name} is available in /model.`);
1011
+ return;
1012
+ }
1013
+
1014
+ const confirmed = await this.ctx.showHookConfirm(`Apply ${recommendedProfile.name} now?`, "");
1015
+ if (!confirmed) {
1016
+ return;
1017
+ }
1018
+
1019
+ await activateModelProfile({
1020
+ session: this.ctx.session,
1021
+ modelRegistry: this.ctx.session.modelRegistry,
1022
+ settings: this.ctx.settings,
1023
+ profileName: recommendedProfile.name,
1024
+ });
1025
+ }
1026
+
1037
1027
  async #handleOAuthLogin(providerId: string): Promise<void> {
1038
1028
  this.ctx.showStatus(`Logging in to ${providerId}…`);
1039
1029
  const manualInput = this.ctx.oauthManualInput;
@@ -1090,6 +1080,7 @@ export class SelectorController {
1090
1080
  new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
1091
1081
  );
1092
1082
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
1083
+ await this.#handlePostLoginModelProfileRecommendation(providerId);
1093
1084
  this.ctx.ui.requestRender();
1094
1085
  } catch (error: unknown) {
1095
1086
  this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -1120,7 +1111,7 @@ export class SelectorController {
1120
1111
  async showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
1121
1112
  if (providerId) {
1122
1113
  const oauthProvider = getOAuthProviders().find(provider => provider.id === providerId);
1123
- if (!oauthProvider) {
1114
+ if (!oauthProvider && !this.ctx.session.modelRegistry.getModelProfiles().has(providerId)) {
1124
1115
  this.ctx.showError(`Unknown OAuth provider: ${providerId}`);
1125
1116
  return;
1126
1117
  }
@@ -12,6 +12,7 @@ import type { SessionStats } from "../../session/agent-session";
12
12
  import type {
13
13
  RpcCommand,
14
14
  RpcExtensionUIRequest,
15
+ RpcGetStateInclude,
15
16
  RpcHandoffResult,
16
17
  RpcHostToolCallRequest,
17
18
  RpcHostToolCancelRequest,
@@ -442,8 +443,8 @@ export class RpcClient {
442
443
  /**
443
444
  * Get current session state.
444
445
  */
445
- async getState(): Promise<RpcSessionState> {
446
- const response = await this.#send({ type: "get_state" });
446
+ async getState(include?: RpcGetStateInclude[]): Promise<RpcSessionState> {
447
+ const response = await this.#send(include ? { type: "get_state", include } : { type: "get_state" });
447
448
  return this.#getData(response);
448
449
  }
449
450
 
@@ -11,7 +11,7 @@
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
13
  import * as path from "node:path";
14
- import { $env, readJsonl, Snowflake } from "@gajae-code/utils";
14
+ import { $env, readLines, Snowflake } from "@gajae-code/utils";
15
15
  import type {
16
16
  ExtensionUIContext,
17
17
  ExtensionUIDialogOptions,
@@ -169,6 +169,7 @@ export async function runRpcMode(
169
169
  process.stdout.write(`${JSON.stringify(obj)}\n`);
170
170
  };
171
171
  const emitRpcTitles = shouldEmitRpcTitles();
172
+ const decodeError = (err: unknown): string => (err instanceof Error ? err.message : String(err));
172
173
 
173
174
  const pendingExtensionRequests = new Map<string, PendingExtensionRequest>();
174
175
  const hostToolBridge = new RpcHostToolBridge(output);
@@ -229,6 +230,28 @@ export async function runRpcMode(
229
230
 
230
231
  // Shutdown request flag (wrapped in object to allow mutation with const)
231
232
  const shutdownState = { requested: false };
233
+ let shutdownStarted = false;
234
+ async function shutdown(exitCode: number, reason: string): Promise<never> {
235
+ if (shutdownStarted) {
236
+ process.exit(exitCode);
237
+ }
238
+ shutdownStarted = true;
239
+ hostToolBridge.rejectAllPending(`${reason} before host tool execution completed`);
240
+ hostUriBridge.clear(`${reason} before host URI request completed`);
241
+ try {
242
+ await session.sessionManager.ensureOnDisk();
243
+ } catch (err) {
244
+ output(error(undefined, "shutdown", decodeError(err)));
245
+ process.exit(1);
246
+ }
247
+ try {
248
+ await session.dispose();
249
+ } catch (err) {
250
+ output(error(undefined, "shutdown", decodeError(err)));
251
+ process.exit(1);
252
+ }
253
+ process.exit(exitCode);
254
+ }
232
255
 
233
256
  /**
234
257
  * Extension UI context that uses the RPC protocol.
@@ -497,16 +520,24 @@ export async function runRpcMode(
497
520
  */
498
521
  async function checkShutdownRequested(): Promise<void> {
499
522
  if (!shutdownState.requested) return;
523
+ await shutdown(0, "RPC shutdown requested");
524
+ }
500
525
 
501
- if (session.extensionRunner?.hasHandlers("session_shutdown")) {
502
- await session.extensionRunner.emit({ type: "session_shutdown" });
503
- }
526
+ // Listen for JSONL input using Bun's stdin. Parse frame-by-frame so a malformed
527
+ // command reports a parse error without poisoning the whole long-lived RPC session.
528
+ const inputDecoder = new TextDecoder("utf-8", { fatal: false });
529
+ for await (const line of readLines(Bun.stdin.stream())) {
530
+ const text = inputDecoder.decode(line).trim();
531
+ if (!text) continue;
504
532
 
505
- process.exit(0);
506
- }
533
+ let parsed: unknown;
534
+ try {
535
+ parsed = JSON.parse(text);
536
+ } catch (err) {
537
+ output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
538
+ continue;
539
+ }
507
540
 
508
- // Listen for JSON input using Bun's stdin
509
- for await (const parsed of readJsonl(Bun.stdin.stream())) {
510
541
  try {
511
542
  // Handle extension UI responses
512
543
  if ((parsed as RpcExtensionUIResponse).type === "extension_ui_response") {
@@ -540,13 +571,12 @@ export async function runRpcMode(
540
571
 
541
572
  // Check for deferred shutdown request (idle between commands)
542
573
  await checkShutdownRequested();
543
- } catch (e: any) {
544
- output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
574
+ } catch (err) {
575
+ output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
545
576
  }
546
577
  }
547
578
 
548
- // stdin closed — RPC client is gone, exit cleanly
549
- hostToolBridge.rejectAllPending("RPC client disconnected before host tool execution completed");
550
- hostUriBridge.clear("RPC client disconnected before host URI request completed");
551
- process.exit(0);
579
+ // stdin closed — RPC client is gone, flush durable state and exit cleanly
580
+ await shutdown(0, "RPC client disconnected");
581
+ throw new Error("RPC shutdown returned unexpectedly");
552
582
  }
@@ -12,6 +12,8 @@ import type { ContextUsage } from "../../extensibility/extensions/types";
12
12
  import type { SessionStats } from "../../session/agent-session";
13
13
  import type { TodoPhase } from "../../tools/todo-write";
14
14
 
15
+ export type RpcGetStateInclude = "tools" | "dumpTools" | "systemPrompt";
16
+
15
17
  // ============================================================================
16
18
  // RPC Commands (stdin)
17
19
  // ============================================================================
@@ -26,7 +28,7 @@ export type RpcCommand =
26
28
  | { id?: string; type: "new_session"; parentSession?: string }
27
29
 
28
30
  // State
29
- | { id?: string; type: "get_state" }
31
+ | { id?: string; type: "get_state"; include?: RpcGetStateInclude[] }
30
32
  | { id?: string; type: "set_todos"; phases: TodoPhase[] }
31
33
  | { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
32
34
  | { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] }
@@ -99,8 +101,9 @@ export interface RpcSessionState {
99
101
  messageCount: number;
100
102
  queuedMessageCount: number;
101
103
  todoPhases: TodoPhase[];
102
- /** For session dump / export (plain-text parity with /dump). */
104
+ /** Optional static system prompt blocks. Omitted by default; request with get_state include ["systemPrompt"]. */
103
105
  systemPrompt?: string[];
106
+ /** Optional static tool schemas. Omitted by default; request with get_state include ["tools"]. */
104
107
  dumpTools?: Array<{ name: string; description: string; parameters: unknown }>;
105
108
  /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */
106
109
  contextUsage?: ContextUsage;
@@ -183,14 +183,19 @@ export async function dispatchRpcCommand(
183
183
  messageCount: session.messages.length,
184
184
  queuedMessageCount: session.queuedMessageCount,
185
185
  todoPhases: session.getTodoPhases(),
186
- systemPrompt: session.systemPrompt,
187
- dumpTools: session.agent.state.tools.map(tool => ({
186
+ contextUsage: session.getContextUsage(),
187
+ };
188
+ const include = new Set(command.include ?? []);
189
+ if (include.has("systemPrompt")) {
190
+ state.systemPrompt = session.systemPrompt;
191
+ }
192
+ if (include.has("tools") || include.has("dumpTools")) {
193
+ state.dumpTools = session.agent.state.tools.map(tool => ({
188
194
  name: tool.name,
189
195
  description: tool.description,
190
196
  parameters: tool.parameters,
191
- })),
192
- contextUsage: session.getContextUsage(),
193
- };
197
+ }));
198
+ }
194
199
  return rpcSuccess(id, "get_state", state);
195
200
  }
196
201
 
@@ -28,6 +28,15 @@ function stringArray(value: unknown): value is string[] {
28
28
  return Array.isArray(value) && value.every(item => typeof item === "string");
29
29
  }
30
30
 
31
+ const GET_STATE_INCLUDES = new Set(["tools", "dumpTools", "systemPrompt"]);
32
+
33
+ function optionalGetStateInclude(value: unknown): boolean {
34
+ return (
35
+ value === undefined ||
36
+ (Array.isArray(value) && value.every(item => typeof item === "string" && GET_STATE_INCLUDES.has(item)))
37
+ );
38
+ }
39
+
31
40
  function todoPhase(value: unknown): boolean {
32
41
  if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.tasks)) return false;
33
42
  return value.tasks.every(
@@ -96,7 +105,9 @@ export function isRpcCommand(value: unknown): value is RpcCommand {
96
105
  case "follow_up":
97
106
  return stringField(value, "message") && optionalArray(value.images);
98
107
  case "abort":
108
+ return true;
99
109
  case "get_state":
110
+ return optionalGetStateInclude(value.include);
100
111
  case "cycle_model":
101
112
  case "get_available_models":
102
113
  case "cycle_thinking_level":
package/src/sdk.ts CHANGED
@@ -133,7 +133,7 @@ import { ToolContextStore } from "./tools/context";
133
133
  import { getImageGenTools } from "./tools/image-gen";
134
134
  import { wrapToolWithMetaNotice } from "./tools/output-meta";
135
135
  import { EventBus } from "./utils/event-bus";
136
- import { buildNamedToolChoice } from "./utils/tool-choice";
136
+ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "./utils/tool-choice";
137
137
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
138
138
 
139
139
  type AsyncResultEntry = {
@@ -234,6 +234,8 @@ export interface CreateAgentSessionOptions {
234
234
  modelPattern?: string;
235
235
  /** Thinking selector. Default: from settings, else unset */
236
236
  thinkingLevel?: ThinkingLevel;
237
+ /** Runtime substitution metadata for the initial model_change session event. */
238
+ modelSubstitution?: { requestedModel: Model; reason: string };
237
239
  /** Models available for cycling (Ctrl+P in interactive mode) */
238
240
  scopedModels?: ScopedModelSelection[];
239
241
 
@@ -1212,6 +1214,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1212
1214
  const m = session.model;
1213
1215
  return m ? buildNamedToolChoice(name, m) : undefined;
1214
1216
  },
1217
+ buildToolChoiceResult: name => buildNamedToolChoiceResult(name, session.model),
1215
1218
  steer: msg =>
1216
1219
  session.agent.steer({
1217
1220
  role: "custom",
@@ -1898,6 +1901,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1898
1901
  }
1899
1902
  return result;
1900
1903
  },
1904
+ onToolChoiceIncapability: event => {
1905
+ const droppedLabel = session?.toolChoiceQueue.degradeInFlight(event.reason);
1906
+ logger.debug("Dropped in-flight tool choice after runtime incapability", {
1907
+ droppedLabel,
1908
+ api: event.api,
1909
+ provider: event.provider,
1910
+ model: event.model,
1911
+ requestedLevel: event.requestedLevel,
1912
+ resolvedLevel: event.resolvedLevel,
1913
+ reason: event.reason,
1914
+ registryKey: event.registryKey,
1915
+ });
1916
+ },
1901
1917
  intentTracing: !!intentField,
1902
1918
  getToolChoice: () => session?.nextToolChoice(),
1903
1919
  telemetry: options.telemetry,
@@ -1912,7 +1928,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1912
1928
  } else {
1913
1929
  // Save initial model, thinking level, and service tier for new sessions so they can be restored on resume.
1914
1930
  if (model) {
1915
- sessionManager.appendModelChange(`${model.provider}/${model.id}`);
1931
+ const substitution = options.modelSubstitution;
1932
+ sessionManager.appendModelChange(
1933
+ `${model.provider}/${model.id}`,
1934
+ undefined,
1935
+ substitution
1936
+ ? {
1937
+ previousModel: `${substitution.requestedModel.provider}/${substitution.requestedModel.id}`,
1938
+ reason: substitution.reason,
1939
+ thinkingLevel: thinkingLevel ?? null,
1940
+ }
1941
+ : undefined,
1942
+ );
1916
1943
  }
1917
1944
  sessionManager.appendThinkingLevelChange(thinkingLevel);
1918
1945
  if (initialServiceTier) {
@@ -73,9 +73,24 @@ export class SecretObfuscator {
73
73
  /** Replace-mode plain mappings: secret → replacement */
74
74
  #replaceMappings = new Map<string, string>();
75
75
 
76
+ /** Replace-mode plain mappings sorted longest-first for deterministic longest-match replacement. */
77
+ #sortedReplaceMappings: Array<{ secret: string; replacement: string }> = [];
78
+
79
+ /** Obfuscate-mode plain and regex-discovered mappings sorted longest-first. */
80
+ #sortedObfuscateMappings: Array<{ secret: string; index: number; placeholder: string }> = [];
81
+
82
+ /** Reverse lookup for obfuscate-mode secrets to avoid scanning mappings. */
83
+ #obfuscateIndexBySecret = new Map<string, number>();
84
+
76
85
  /** Reverse lookup for deobfuscation: placeholder → secret */
77
86
  #deobfuscateMap = new Map<string, string>();
78
87
 
88
+ /** Combined plain-secret regex cache for single-pass replacement. */
89
+ #combinedPlainRegex: RegExp | undefined;
90
+ #combinedPlainReplacementBySecret = new Map<string, string>();
91
+ #combinedPlainRegexDirty = true;
92
+ #useSequentialPlainReplacement = false;
93
+
79
94
  /** Next available index for regex match discoveries */
80
95
  #nextIndex: number;
81
96
 
@@ -93,6 +108,7 @@ export class SecretObfuscator {
93
108
  this.#plainMappings.set(entry.content, index);
94
109
  this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
95
110
  this.#deobfuscateMap.set(placeholder, entry.content);
111
+ this.#obfuscateIndexBySecret.set(entry.content, index);
96
112
  index++;
97
113
  } else {
98
114
  // replace mode
@@ -111,6 +127,16 @@ export class SecretObfuscator {
111
127
  }
112
128
 
113
129
  this.#nextIndex = index;
130
+ this.#sortedReplaceMappings = [...this.#replaceMappings]
131
+ .sort((a, b) => b[0].length - a[0].length)
132
+ .map(([secret, replacement]) => ({ secret, replacement }));
133
+ this.#sortedObfuscateMappings = [...this.#plainMappings]
134
+ .sort((a, b) => b[0].length - a[0].length)
135
+ .map(([secret, mappingIndex]) => ({
136
+ secret,
137
+ index: mappingIndex,
138
+ placeholder: this.#obfuscateMappings.get(mappingIndex)!.placeholder,
139
+ }));
114
140
  this.#hasAny = entries.length > 0;
115
141
  }
116
142
 
@@ -121,18 +147,7 @@ export class SecretObfuscator {
121
147
  /** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */
122
148
  obfuscate(text: string): string {
123
149
  if (!this.#hasAny) return text;
124
- let result = text;
125
-
126
- // 1. Process replace-mode plain secrets
127
- for (const [secret, replacement] of [...this.#replaceMappings].sort((a, b) => b[0].length - a[0].length)) {
128
- result = replaceAll(result, secret, replacement);
129
- }
130
-
131
- // 2. Process obfuscate-mode plain secrets
132
- for (const [secret, index] of [...this.#plainMappings].sort((a, b) => b[0].length - a[0].length)) {
133
- const mapping = this.#obfuscateMappings.get(index)!;
134
- result = replaceAll(result, secret, mapping.placeholder);
135
- }
150
+ let result = this.#obfuscatePlainMappings(text);
136
151
 
137
152
  // 3. Process regex entries — discover new matches
138
153
  for (const entry of this.#regexEntries) {
@@ -160,6 +175,9 @@ export class SecretObfuscator {
160
175
  const placeholder = buildPlaceholder(index);
161
176
  this.#obfuscateMappings.set(index, { secret: matchValue, placeholder });
162
177
  this.#deobfuscateMap.set(placeholder, matchValue);
178
+ this.#obfuscateIndexBySecret.set(matchValue, index);
179
+ this.#insertSortedObfuscateMapping({ secret: matchValue, index, placeholder });
180
+ this.#combinedPlainRegexDirty = true;
163
181
  }
164
182
  const mapping = this.#obfuscateMappings.get(index)!;
165
183
  result = replaceAll(result, matchValue, mapping.placeholder);
@@ -186,15 +204,74 @@ export class SecretObfuscator {
186
204
 
187
205
  /** Find the obfuscate index for a known secret value. */
188
206
  #findObfuscateIndex(secret: string): number | undefined {
189
- // Check plain mappings first
190
- const plainIndex = this.#plainMappings.get(secret);
191
- if (plainIndex !== undefined) return plainIndex;
207
+ return this.#obfuscateIndexBySecret.get(secret);
208
+ }
209
+
210
+ #insertSortedObfuscateMapping(mapping: { secret: string; index: number; placeholder: string }): void {
211
+ let lo = 0;
212
+ let hi = this.#sortedObfuscateMappings.length;
213
+ while (lo < hi) {
214
+ const mid = (lo + hi) >> 1;
215
+ if (this.#sortedObfuscateMappings[mid]!.secret.length < mapping.secret.length) {
216
+ hi = mid;
217
+ } else {
218
+ lo = mid + 1;
219
+ }
220
+ }
221
+ this.#sortedObfuscateMappings.splice(lo, 0, mapping);
222
+ }
223
+
224
+ #obfuscatePlainMappings(text: string): string {
225
+ this.#ensureCombinedPlainRegex();
226
+ if (this.#useSequentialPlainReplacement) return this.#obfuscatePlainMappingsSequential(text);
227
+ if (!this.#combinedPlainRegex) return text;
228
+ return text.replace(
229
+ this.#combinedPlainRegex,
230
+ match => this.#combinedPlainReplacementBySecret.get(match) ?? match,
231
+ );
232
+ }
192
233
 
193
- // Check regex-discovered mappings
194
- for (const [index, mapping] of this.#obfuscateMappings) {
195
- if (mapping.secret === secret) return index;
234
+ #obfuscatePlainMappingsSequential(text: string): string {
235
+ let result = text;
236
+ for (const mapping of this.#sortedReplaceMappings) {
237
+ result = replaceAll(result, mapping.secret, mapping.replacement);
238
+ }
239
+ for (const mapping of this.#sortedObfuscateMappings) {
240
+ result = replaceAll(result, mapping.secret, mapping.placeholder);
196
241
  }
197
- return undefined;
242
+ return result;
243
+ }
244
+
245
+ #ensureCombinedPlainRegex(): void {
246
+ if (!this.#combinedPlainRegexDirty) return;
247
+ this.#combinedPlainRegexDirty = false;
248
+ this.#combinedPlainReplacementBySecret = new Map<string, string>();
249
+
250
+ const mappings = [
251
+ ...this.#sortedReplaceMappings.map(mapping => ({ secret: mapping.secret, replacement: mapping.replacement })),
252
+ ...this.#sortedObfuscateMappings.map(mapping => ({
253
+ secret: mapping.secret,
254
+ replacement: mapping.placeholder,
255
+ })),
256
+ ];
257
+
258
+ this.#useSequentialPlainReplacement = mappings.some((mapping, index) =>
259
+ mappings.some(
260
+ (other, otherIndex) =>
261
+ other.secret.length > 0 &&
262
+ (mapping.replacement.includes(other.secret) ||
263
+ (index !== otherIndex &&
264
+ (mapping.secret.includes(other.secret) || other.secret.includes(mapping.secret)))),
265
+ ),
266
+ );
267
+ for (const mapping of mappings) {
268
+ if (!this.#combinedPlainReplacementBySecret.has(mapping.secret))
269
+ this.#combinedPlainReplacementBySecret.set(mapping.secret, mapping.replacement);
270
+ }
271
+ this.#combinedPlainRegex =
272
+ mappings.length > 0
273
+ ? new RegExp(mappings.map(mapping => escapeRegex(mapping.secret)).join("|"), "g")
274
+ : undefined;
198
275
  }
199
276
  }
200
277
 
@@ -238,14 +315,12 @@ export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Messag
238
315
 
239
316
  /** Replace all occurrences of `search` in `text` with `replacement`. */
240
317
  function replaceAll(text: string, search: string, replacement: string): string {
241
- if (search.length === 0) return text;
242
- let result = text;
243
- let idx = result.indexOf(search);
244
- while (idx !== -1) {
245
- result = result.slice(0, idx) + replacement + result.slice(idx + search.length);
246
- idx = result.indexOf(search, idx + replacement.length);
247
- }
248
- return result;
318
+ if (search.length === 0 || !text.includes(search)) return text;
319
+ return text.split(search).join(replacement);
320
+ }
321
+
322
+ function escapeRegex(value: string): string {
323
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
249
324
  }
250
325
 
251
326
  /** Deep-walk an object, transforming all string values. */