@gajae-code/coding-agent 0.5.1 → 0.5.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 +17 -0
  2. package/README.md +1 -1
  3. package/dist/types/cli/setup-cli.d.ts +8 -1
  4. package/dist/types/commands/setup.d.ts +7 -0
  5. package/dist/types/config/file-lock.d.ts +24 -2
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/config/settings-schema.d.ts +62 -0
  9. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  12. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  13. package/dist/types/modes/interactive-mode.d.ts +1 -1
  14. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  15. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  16. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  17. package/dist/types/modes/theme/theme.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/session/history-storage.d.ts +2 -2
  20. package/dist/types/session/session-manager.d.ts +10 -1
  21. package/dist/types/setup/credential-import.d.ts +79 -0
  22. package/dist/types/task/executor.d.ts +1 -0
  23. package/dist/types/task/render.d.ts +1 -1
  24. package/dist/types/tools/subagent-render.d.ts +7 -1
  25. package/dist/types/tools/subagent.d.ts +21 -0
  26. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  27. package/dist/types/web/search/index.d.ts +4 -4
  28. package/dist/types/web/search/provider.d.ts +16 -20
  29. package/dist/types/web/search/providers/base.d.ts +2 -1
  30. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  31. package/dist/types/web/search/types.d.ts +14 -2
  32. package/package.json +7 -7
  33. package/scripts/build-binary.ts +7 -0
  34. package/src/cli/args.ts +2 -0
  35. package/src/cli/fast-help.ts +2 -0
  36. package/src/cli/setup-cli.ts +138 -3
  37. package/src/commands/setup.ts +5 -1
  38. package/src/commands/ultragoal.ts +3 -1
  39. package/src/config/file-lock-gc.ts +14 -2
  40. package/src/config/file-lock.ts +54 -12
  41. package/src/config/model-profile-activation.ts +15 -3
  42. package/src/config/model-profiles.ts +15 -15
  43. package/src/config/model-registry.ts +21 -1
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +62 -0
  46. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  47. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  48. package/src/gjc-runtime/launch-tmux.ts +3 -4
  49. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  50. package/src/gjc-runtime/state-runtime.ts +2 -1
  51. package/src/gjc-runtime/state-writer.ts +254 -7
  52. package/src/gjc-runtime/tmux-gc.ts +2 -1
  53. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  54. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  55. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  56. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  57. package/src/harness-control-plane/owner.ts +3 -2
  58. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  59. package/src/hooks/skill-state.ts +121 -2
  60. package/src/internal-urls/docs-index.generated.ts +13 -9
  61. package/src/lsp/defaults.json +1 -0
  62. package/src/main.ts +14 -4
  63. package/src/modes/acp/acp-agent.ts +4 -2
  64. package/src/modes/bridge/bridge-mode.ts +2 -1
  65. package/src/modes/components/history-search.ts +5 -2
  66. package/src/modes/components/model-selector.ts +26 -0
  67. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  68. package/src/modes/controllers/selector-controller.ts +80 -1
  69. package/src/modes/interactive-mode.ts +11 -1
  70. package/src/modes/rpc/rpc-mode.ts +132 -18
  71. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  72. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  73. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  74. package/src/modes/theme/defaults/claude-code.json +100 -0
  75. package/src/modes/theme/defaults/codex.json +100 -0
  76. package/src/modes/theme/defaults/index.ts +6 -0
  77. package/src/modes/theme/defaults/opencode.json +102 -0
  78. package/src/modes/theme/theme.ts +2 -2
  79. package/src/modes/types.ts +1 -1
  80. package/src/prompts/agents/executor.md +5 -2
  81. package/src/sdk.ts +12 -1
  82. package/src/session/agent-session.ts +22 -11
  83. package/src/session/history-storage.ts +32 -11
  84. package/src/session/session-manager.ts +70 -18
  85. package/src/setup/credential-import.ts +429 -0
  86. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  87. package/src/task/executor.ts +7 -1
  88. package/src/task/render.ts +18 -7
  89. package/src/tools/ask.ts +4 -2
  90. package/src/tools/cron.ts +1 -1
  91. package/src/tools/subagent-render.ts +119 -29
  92. package/src/tools/subagent.ts +147 -7
  93. package/src/tools/ultragoal-ask-guard.ts +39 -0
  94. package/src/web/search/index.ts +25 -25
  95. package/src/web/search/provider.ts +178 -87
  96. package/src/web/search/providers/base.ts +2 -1
  97. package/src/web/search/providers/openai-compatible.ts +151 -0
  98. package/src/web/search/types.ts +47 -22
@@ -4,6 +4,7 @@
4
4
  "args": [],
5
5
  "fileTypes": [".rs"],
6
6
  "rootMarkers": ["Cargo.toml", "rust-analyzer.toml"],
7
+ "warmupTimeoutMs": 30000,
7
8
  "initOptions": {},
8
9
  "settings": {
9
10
  "rust-analyzer": {
package/src/main.ts CHANGED
@@ -977,10 +977,20 @@ export async function runRootCommand(
977
977
  }
978
978
 
979
979
  if (mode === "rpc" || mode === "rpc-ui") {
980
- const { runRpcMode } = await import("./modes/rpc/rpc-mode");
981
- await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, {
982
- listen: parsedArgs.rpcListen,
983
- });
980
+ const { RpcListenRefusedError, runRpcMode } = await import("./modes/rpc/rpc-mode");
981
+ try {
982
+ await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, {
983
+ listen: parsedArgs.rpcListen,
984
+ });
985
+ } catch (error) {
986
+ if (!(error instanceof RpcListenRefusedError)) throw error;
987
+ logger.setTransports({ console: true, file: true });
988
+ logger.error(error.message);
989
+ await session.dispose();
990
+ stopThemeWatcher();
991
+ await postmortem.quit(1);
992
+ process.exit(1);
993
+ }
984
994
  } else if (mode === "bridge") {
985
995
  const { runBridgeMode } = await import("./modes/bridge/bridge-mode");
986
996
  await runBridgeMode(session, setToolUIContext);
@@ -43,7 +43,8 @@ import {
43
43
  type Usage,
44
44
  } from "@agentclientprotocol/sdk";
45
45
  import type { AssistantMessage, Model } from "@gajae-code/ai";
46
- import { logger, VERSION } from "@gajae-code/utils";
46
+ import { logger } from "@gajae-code/utils";
47
+ import packageJson from "../../../package.json" with { type: "json" };
47
48
  import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
48
49
  import { Settings } from "../../config/settings";
49
50
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
@@ -98,6 +99,7 @@ const SESSION_PAGE_SIZE = 50;
98
99
  * wait past this guard without hard-coding the literal.
99
100
  */
100
101
  export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
102
+ const CODING_AGENT_VERSION: string = packageJson.version;
101
103
  const ACP_CANCEL_CLEANUP_TIMEOUT_MS = 5_000;
102
104
  const ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS = 250;
103
105
  const ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES = 3;
@@ -414,7 +416,7 @@ export class AcpAgent implements Agent {
414
416
  agentInfo: {
415
417
  name: "gajae-code",
416
418
  title: "Gajae Code",
417
- version: VERSION,
419
+ version: CODING_AGENT_VERSION,
418
420
  },
419
421
  authMethods,
420
422
  agentCapabilities: {
@@ -29,7 +29,7 @@ import {
29
29
  import { UiRequestBroker } from "../shared/agent-wire/ui-request-broker";
30
30
  import type { BridgeUiResult } from "../shared/agent-wire/ui-result";
31
31
  import { defaultAuditPath, UnattendedAuditLog } from "../shared/agent-wire/unattended-audit";
32
- import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
32
+ import { modelSupportsTokenCostMetrics, UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
33
33
  import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
34
34
  import { assertSafeBridgeBind, isBridgeTokenAuthorized } from "./auth";
35
35
  import { type BridgePermissionRequestPayload, createBridgeClientBridge } from "./bridge-client-bridge";
@@ -599,6 +599,7 @@ export async function runBridgeMode(
599
599
  emitFrame: gate => eventStream.publish(toBridgeWorkflowGateFrame(gate, sequencer)),
600
600
  store: gateStore,
601
601
  audit: recordAudit,
602
+ providerSupportsTokenCostMetrics: modelSupportsTokenCostMetrics(session.model),
602
603
  getUsageSnapshot: () => {
603
604
  const stats = session.getSessionStats();
604
605
  return { tokens: stats.tokens.total, costUsd: stats.cost };
@@ -10,6 +10,7 @@ import {
10
10
  truncateToWidth,
11
11
  visibleWidth,
12
12
  } from "@gajae-code/tui";
13
+ import { getProjectDir } from "@gajae-code/utils";
13
14
  import { theme } from "../../modes/theme/theme";
14
15
  import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
15
16
  import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
@@ -72,6 +73,7 @@ class HistoryResultsList implements Component {
72
73
 
73
74
  export class HistorySearchComponent extends Container {
74
75
  #historyStorage: HistoryStorage;
76
+ #cwd: string;
75
77
  #searchInput: Input;
76
78
  #results: HistoryEntry[] = [];
77
79
  #selectedIndex = 0;
@@ -83,6 +85,7 @@ export class HistorySearchComponent extends Container {
83
85
  constructor(historyStorage: HistoryStorage, onSelect: (prompt: string) => void, onCancel: () => void) {
84
86
  super();
85
87
  this.#historyStorage = historyStorage;
88
+ this.#cwd = getProjectDir();
86
89
  this.#onSelect = onSelect;
87
90
  this.#onCancel = onCancel;
88
91
 
@@ -150,8 +153,8 @@ export class HistorySearchComponent extends Container {
150
153
  #updateResults(): void {
151
154
  const query = this.#searchInput.getValue().trim();
152
155
  this.#results = query
153
- ? this.#historyStorage.search(query, this.#resultLimit)
154
- : this.#historyStorage.getRecent(this.#resultLimit);
156
+ ? this.#historyStorage.search(query, this.#resultLimit, this.#cwd)
157
+ : this.#historyStorage.getRecent(this.#resultLimit, this.#cwd);
155
158
  this.#selectedIndex = 0;
156
159
  this.#resultsList.setResults(this.#results, this.#selectedIndex);
157
160
  }
@@ -154,6 +154,20 @@ interface PresetBrowseRow {
154
154
 
155
155
  type PresetLandingRow = PresetGroupRow | PresetProfileRow | PresetBrowseRow;
156
156
 
157
+ // Stable logical identity for a preset landing row, independent of its current
158
+ // list position. Used to relocate the cursor after the expanded group changes so
159
+ // navigation does not silently overshoot the destination group header/profiles.
160
+ function presetRowIdentity(row: PresetLandingRow): string {
161
+ switch (row.kind) {
162
+ case "group":
163
+ return `group:${row.groupId}`;
164
+ case "profile":
165
+ return `profile:${row.groupId}:${row.profile.name}`;
166
+ case "browse":
167
+ return "browse";
168
+ }
169
+ }
170
+
157
171
  const PROFILE_ROLE_PREVIEW_ORDER: GjcModelAssignmentTargetId[] = [
158
172
  "default",
159
173
  "executor",
@@ -739,6 +753,18 @@ export class ModelSelectorComponent extends Container {
739
753
  if (selected?.kind === "group") this.#expandedPresetProviderId = selected.groupId;
740
754
  if (selected?.kind === "profile") this.#expandedPresetProviderId = selected.groupId;
741
755
  const rows = this.#getPresetRows();
756
+ // Expanding/collapsing a group shifts row positions. Relocate the cursor by
757
+ // the selected row's logical identity so crossing a provider group boundary
758
+ // keeps it on the same logical row instead of overshooting into the
759
+ // destination group's profiles (or off the end of the list).
760
+ if (selected) {
761
+ const targetIdentity = presetRowIdentity(selected);
762
+ const relocated = rows.findIndex(row => presetRowIdentity(row) === targetIdentity);
763
+ if (relocated >= 0) {
764
+ this.#presetCursor = relocated;
765
+ return;
766
+ }
767
+ }
742
768
  this.#presetCursor = Math.min(this.#presetCursor, Math.max(0, rows.length - 1));
743
769
  }
744
770
 
@@ -4,7 +4,7 @@ import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
4
4
  import { formatModelOnboardingGuidance } from "../../setup/model-onboarding-guidance";
5
5
  import { DynamicBorder } from "./dynamic-border";
6
6
 
7
- export type ProviderOnboardingAction = "custom-provider-wizard" | "oauth-login" | "api-guide";
7
+ export type ProviderOnboardingAction = "custom-provider-wizard" | "oauth-login" | "import-credentials" | "api-guide";
8
8
 
9
9
  interface ProviderOnboardingOption {
10
10
  label: string;
@@ -28,6 +28,11 @@ const PROVIDER_ONBOARDING_OPTIONS: ProviderOnboardingOption[] = [
28
28
  description: "Show the /provider add and gjc setup provider commands.",
29
29
  action: "api-guide",
30
30
  },
31
+ {
32
+ label: "Import existing credentials",
33
+ description: "Detect and import Claude Code / Codex CLI logins already on this machine.",
34
+ action: "import-credentials",
35
+ },
31
36
  ];
32
37
 
33
38
  export class ProviderOnboardingSelectorComponent extends Container {
@@ -32,13 +32,20 @@ import {
32
32
  import type { InteractiveModeContext } from "../../modes/types";
33
33
  import { type SessionInfo, SessionManager } from "../../session/session-manager";
34
34
  import { FileSessionStorage } from "../../session/session-storage";
35
+ import { discoverExternalCredentials, formatDiscoverySummary, importCredentials } from "../../setup/credential-import";
35
36
  import {
36
37
  MODEL_ONBOARDING_API_PROVIDER_COMMAND,
37
38
  MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND,
38
39
  MODEL_ONBOARDING_SETUP_COMMAND,
39
40
  } from "../../setup/model-onboarding-guidance";
40
41
  import { addApiCompatibleProvider, formatProviderSetupResult } from "../../setup/provider-onboarding";
41
- import { isSearchProviderPreference, setPreferredImageProvider, setPreferredSearchProvider } from "../../tools";
42
+ import {
43
+ isConfigurableSearchProviderId,
44
+ isSearchProviderPreference,
45
+ setPreferredImageProvider,
46
+ setPreferredSearchProvider,
47
+ setSearchFallbackProviders,
48
+ } from "../../tools";
42
49
  import { setSessionTerminalTitle } from "../../utils/title-generator";
43
50
  import { AgentDashboard } from "../components/agent-dashboard";
44
51
  import { AssistantMessageComponent } from "../components/assistant-message";
@@ -128,6 +135,8 @@ export class SelectorController {
128
135
  this.showCustomProviderWizard();
129
136
  } else if (action === "oauth-login") {
130
137
  void this.showOAuthSelector("login");
138
+ } else if (action === "import-credentials") {
139
+ void this.#handleCredentialImport();
131
140
  } else {
132
141
  this.ctx.showStatus(formatProviderOnboardingCommandGuide());
133
142
  }
@@ -141,6 +150,69 @@ export class SelectorController {
141
150
  });
142
151
  }
143
152
 
153
+ async #handleCredentialImport(): Promise<void> {
154
+ this.ctx.showStatus("Scanning for existing Claude Code / Codex CLI credentials…");
155
+ const result = await discoverExternalCredentials();
156
+ const summaryLines = formatDiscoverySummary(result);
157
+
158
+ if (result.importable.length === 0) {
159
+ this.ctx.chatContainer.addChild(new Spacer(1));
160
+ for (const line of summaryLines) {
161
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
162
+ }
163
+ this.ctx.chatContainer.addChild(
164
+ new Text(
165
+ theme.fg(
166
+ "warning",
167
+ "No importable Claude/Codex credentials found. Use /login or add a custom provider.",
168
+ ),
169
+ 1,
170
+ 0,
171
+ ),
172
+ );
173
+ this.ctx.ui.requestRender();
174
+ return;
175
+ }
176
+
177
+ const confirmed = await this.ctx.showHookConfirm(
178
+ `Import ${result.importable.length} credential(s)?`,
179
+ summaryLines.join("\n"),
180
+ );
181
+ if (!confirmed) {
182
+ this.ctx.showStatus("Credential import cancelled.");
183
+ return;
184
+ }
185
+
186
+ const summary = await importCredentials(result.importable, (provider, credential) =>
187
+ this.ctx.session.modelRegistry.authStorage.upsertCredential(provider, credential),
188
+ );
189
+ await this.ctx.session.modelRegistry.refresh();
190
+
191
+ this.ctx.chatContainer.addChild(new Spacer(1));
192
+ for (const credential of summary.imported) {
193
+ this.ctx.chatContainer.addChild(
194
+ new Text(
195
+ theme.fg("success", `${theme.status.success} Imported ${credential.provider} (${credential.source})`),
196
+ 1,
197
+ 0,
198
+ ),
199
+ );
200
+ }
201
+ for (const failure of summary.failed) {
202
+ this.ctx.chatContainer.addChild(
203
+ new Text(
204
+ theme.fg("error", `${theme.status.error} Failed ${failure.credential.provider}: ${failure.error}`),
205
+ 1,
206
+ 0,
207
+ ),
208
+ );
209
+ }
210
+ if (summary.imported.length > 0) {
211
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
212
+ }
213
+ this.ctx.ui.requestRender();
214
+ }
215
+
144
216
  showCustomProviderWizard(): void {
145
217
  this.showSelector(done => {
146
218
  let wizard: CustomProviderWizardComponent;
@@ -507,6 +579,13 @@ export class SelectorController {
507
579
  setPreferredSearchProvider(value);
508
580
  }
509
581
  break;
582
+ case "web_search.fallback":
583
+ if (Array.isArray(value)) {
584
+ setSearchFallbackProviders(
585
+ value.filter(item => typeof item === "string" && isConfigurableSearchProviderId(item)),
586
+ );
587
+ }
588
+ break;
510
589
  case "providers.image":
511
590
  if (
512
591
  value === "auto" ||
@@ -283,7 +283,7 @@ export class InteractiveMode implements InteractiveModeContext {
283
283
  }
284
284
  autoCompactionEscapeHandler?: () => void;
285
285
  retryEscapeHandler?: () => void;
286
- retryCountdownTimer?: ReturnType<typeof setInterval>;
286
+ retryCountdownTimer?: NodeJS.Timeout;
287
287
  unsubscribe?: () => void;
288
288
  onInputCallback?: (input: SubmittedUserInput) => void;
289
289
  optimisticUserMessageSignature: string | undefined = undefined;
@@ -709,6 +709,16 @@ export class InteractiveMode implements InteractiveModeContext {
709
709
  if (this.#pendingSubmittedInput) return;
710
710
  if (this.editor.getText().trim().length > 0) return;
711
711
  if ((this.pendingImages?.length ?? 0) > 0) return;
712
+ // Never fire an autonomous continuation prompt() while the session is
713
+ // busy. A wedged/orphaned subagent turn can leave isStreaming stuck true;
714
+ // firing prompt() here throws AgentBusyError, which submitInteractiveInput
715
+ // surfaces as a red "Error: Agent is already processing…" and then loops
716
+ // back to getUserInput(), re-arming this timer — an infinite error spam.
717
+ // Re-arm and only fire once the session returns to idle.
718
+ if (this.session.isStreaming || this.session.isCompacting) {
719
+ this.#scheduleGoalContinuation();
720
+ return;
721
+ }
712
722
  const latestState = this.session.getGoalModeState();
713
723
  if (!latestState?.enabled || latestState.goal.status !== "active") return;
714
724
  this.#goalContinuationTurnInFlight = true;
@@ -13,7 +13,7 @@
13
13
 
14
14
  import * as fs from "node:fs/promises";
15
15
  import * as path from "node:path";
16
- import { $pickenv, readLines, Snowflake } from "@gajae-code/utils";
16
+ import { $pickenv, logger, readLines, Snowflake } from "@gajae-code/utils";
17
17
  import type {
18
18
  ExtensionUIContext,
19
19
  ExtensionUIDialogOptions,
@@ -27,7 +27,7 @@ import { AgentWireFrameSequencer, toAgentWireEventFrame } from "../shared/agent-
27
27
  import { rpcError as error } from "../shared/agent-wire/responses";
28
28
  import { registerRpcSession, unregisterRpcSession } from "../shared/agent-wire/session-registry";
29
29
  import { defaultAuditPath, UnattendedAuditLog } from "../shared/agent-wire/unattended-audit";
30
- import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
30
+ import { modelSupportsTokenCostMetrics, UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
31
31
  import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
32
32
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
33
33
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
@@ -82,6 +82,82 @@ export function shouldEmitRpcTitlesForTest(): boolean {
82
82
 
83
83
  const shouldEmitRpcTitles = shouldEmitRpcTitlesForTest;
84
84
 
85
+ /**
86
+ * Cancellation commands bypass the ordered serial chain because they must
87
+ * interrupt in-flight work — they cannot wait behind the very command they are
88
+ * meant to abort.
89
+ */
90
+ export const RPC_CANCELLATION_COMMANDS: ReadonlySet<RpcCommand["type"]> = new Set<RpcCommand["type"]>([
91
+ "abort",
92
+ "abort_bash",
93
+ "abort_retry",
94
+ ]);
95
+
96
+ /**
97
+ * Safe read-only commands that bypass the ordered serial chain so they never
98
+ * head-of-line-block behind a long-running ordered command like
99
+ * `bash`/`compact`/`handoff`/`login` (#606, issue 13 — the partial fix only
100
+ * fast-laned cancellation).
101
+ *
102
+ * Every command listed here has a dispatch handler that is **fully synchronous
103
+ * and side-effect-free**: on the single-threaded event loop it runs to
104
+ * completion between the await points of any in-flight ordered command, reading
105
+ * live state without mutating it. Because such a read performs no causal write,
106
+ * jumping ahead of an earlier *queued* ordered command is observably harmless —
107
+ * there is no state change to reorder. Read payloads are additionally
108
+ * snapshotted inside the handler (e.g. `get_messages` returns a shallow copy of
109
+ * `session.messages`) so a fast-lane read can never serialize a half-mutated
110
+ * array that an ordered turn/compaction is rewriting in place.
111
+ *
112
+ * Deliberately excluded (kept ordered): every async/long command and every
113
+ * mutating command. In particular the control-flag setters (`set_thinking_level`,
114
+ * `cycle_thinking_level`, `set_steering_mode`, `set_follow_up_mode`,
115
+ * `set_interrupt_mode`, `set_auto_compaction`, `set_auto_retry`) stay ordered.
116
+ * Their handlers are synchronous, so fast-laning one ahead of an already-queued
117
+ * `prompt`/`bash` would apply the new mode *before* that earlier command runs —
118
+ * the earlier command would then observe the later setter's value, a
119
+ * causal-order (arrival-order) regression. Mutations therefore stay on the
120
+ * chain, and new command types default to ordered (fail-safe).
121
+ */
122
+ export const RPC_SAFE_READ_CONTROL_COMMANDS: ReadonlySet<RpcCommand["type"]> = new Set<RpcCommand["type"]>([
123
+ // Pure synchronous reads — snapshot live state at processing time, never mutate.
124
+ "get_state",
125
+ "get_session_stats",
126
+ "get_available_models",
127
+ "get_branch_messages",
128
+ "get_last_assistant_text",
129
+ "get_messages",
130
+ "get_login_providers",
131
+ ]);
132
+
133
+ /** True when a command may bypass the ordered serial chain and run immediately. */
134
+ export function isFastLaneRpcCommand(type: RpcCommand["type"]): boolean {
135
+ return RPC_CANCELLATION_COMMANDS.has(type) || RPC_SAFE_READ_CONTROL_COMMANDS.has(type);
136
+ }
137
+
138
+ /**
139
+ * Schedules inbound RPC commands: fast-lane commands run immediately while
140
+ * everything else runs through a serial chain so causal order is preserved. The
141
+ * read loop never blocks, which is what lets a fast-lane command reach a
142
+ * long-running ordered command instead of being head-of-line-blocked behind it.
143
+ */
144
+ export function createRpcCommandScheduler(
145
+ run: (command: RpcCommand) => Promise<void>,
146
+ track: (task: Promise<void>) => void,
147
+ ): { dispatch: (command: RpcCommand) => void } {
148
+ let orderedChain: Promise<void> = Promise.resolve();
149
+ return {
150
+ dispatch(command: RpcCommand): void {
151
+ if (isFastLaneRpcCommand(command.type)) {
152
+ track(run(command));
153
+ return;
154
+ }
155
+ orderedChain = orderedChain.then(() => run(command));
156
+ track(orderedChain);
157
+ },
158
+ };
159
+ }
160
+
85
161
  function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "exceeded" | "aborted" | "info" {
86
162
  if (event.includes("denied")) return "denied";
87
163
  if (event.includes("exceeded")) return "exceeded";
@@ -91,6 +167,43 @@ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "e
91
167
  return "info";
92
168
  }
93
169
 
170
+ export class RpcListenRefusedError extends Error {
171
+ constructor(socketPath: string) {
172
+ super(
173
+ `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
174
+ "Stop it first or choose a different --listen path.",
175
+ );
176
+ this.name = "RpcListenRefusedError";
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Probe whether a unix-domain socket path has a live server accepting
182
+ * connections. Returns `true` when a connection succeeds (a previous owner is
183
+ * still alive), and returns `false` only for known missing/stale endpoints
184
+ * (ENOENT / ECONNREFUSED). Unexpected probe failures fail closed as "alive" so
185
+ * `--listen` startup refuses to unlink a path it could not safely classify.
186
+ */
187
+ export async function isUnixSocketAlive(socketPath: string): Promise<boolean> {
188
+ try {
189
+ const socket = await Bun.connect({
190
+ unix: socketPath,
191
+ socket: { data() {}, open() {}, error() {}, close() {} },
192
+ });
193
+ socket.end();
194
+ return true;
195
+ } catch (err) {
196
+ const code = err && typeof err === "object" ? (err as { code?: unknown }).code : undefined;
197
+ if (code === "ENOENT" || code === "ECONNREFUSED") return false;
198
+ logger.warn("RPC --listen socket probe failed closed", {
199
+ socketPath,
200
+ code: typeof code === "string" ? code : undefined,
201
+ error: err instanceof Error ? err.message : String(err),
202
+ });
203
+ return true;
204
+ }
205
+ }
206
+
94
207
  export function requestRpcEditor(
95
208
  pendingRequests: Map<string, PendingExtensionRequest>,
96
209
  output: RpcOutput,
@@ -230,6 +343,7 @@ export async function runRpcMode(
230
343
  emitFrame: gate => output(gate),
231
344
  store: gateStore,
232
345
  audit: recordAudit,
346
+ providerSupportsTokenCostMetrics: modelSupportsTokenCostMetrics(session.model),
233
347
  getUsageSnapshot: () => {
234
348
  const stats = session.getSessionStats();
235
349
  return { tokens: stats.tokens.total, costUsd: stats.cost };
@@ -537,14 +651,13 @@ export async function runRpcMode(
537
651
  unattendedControlPlane,
538
652
  });
539
653
 
540
- // Cancellation commands must interrupt in-flight work, so they bypass the ordered
541
- // queue and run immediately. Everything else runs through a serial chain so causal
542
- // order is preserved (e.g. `get_state` after `bash` still observes the bash result)
543
- // while the read loop itself never blocks that is what lets a cancellation command
544
- // reach a long-running `bash`/`compact`/`handoff`/`login` instead of being
545
- // head-of-line-blocked behind it (issue 13).
546
- const CANCELLATION_COMMANDS = new Set<RpcCommand["type"]>(["abort", "abort_bash", "abort_retry"]);
547
- let orderedChain: Promise<void> = Promise.resolve();
654
+ // Fast-lane commands (cancellation + safe read/control, see
655
+ // isFastLaneRpcCommand) bypass the ordered serial chain and run immediately;
656
+ // everything else runs through a serial chain so causal order is preserved
657
+ // (e.g. an ordered `set_model` after `bash` still applies after the bash
658
+ // result) while the read loop itself never blocks — that is what lets a
659
+ // fast-lane command reach a long-running `bash`/`compact`/`handoff`/`login`
660
+ // instead of being head-of-line-blocked behind it (issue 13).
548
661
  const runCommand = async (command: RpcCommand): Promise<void> => {
549
662
  try {
550
663
  output(await handleCommand(command));
@@ -556,14 +669,7 @@ export async function runRpcMode(
556
669
  inFlightCommands.add(task);
557
670
  void task.finally(() => inFlightCommands.delete(task));
558
671
  };
559
- const dispatchCommand = (command: RpcCommand): void => {
560
- if (CANCELLATION_COMMANDS.has(command.type)) {
561
- trackCommand(runCommand(command));
562
- return;
563
- }
564
- orderedChain = orderedChain.then(() => runCommand(command));
565
- trackCommand(orderedChain);
566
- };
672
+ const { dispatch: dispatchCommand } = createRpcCommandScheduler(runCommand, trackCommand);
567
673
 
568
674
  /**
569
675
  * Check if shutdown was requested and perform shutdown if so.
@@ -620,6 +726,14 @@ export async function runRpcMode(
620
726
  if (options?.listen) {
621
727
  const socketPath = options.listen;
622
728
  await fs.mkdir(path.dirname(socketPath), { recursive: true }).catch(() => {});
729
+ // Refuse to clobber a live previous owner: probe the path first and only
730
+ // unlink a stale endpoint. A second `--listen` on the same path must not
731
+ // remove the socket another running server is still serving (#606).
732
+ // Unexpected probe failures are treated as alive, so this also refuses
733
+ // rather than unlinking a socket path we could not safely classify.
734
+ if (await isUnixSocketAlive(socketPath)) {
735
+ throw new RpcListenRefusedError(socketPath);
736
+ }
623
737
  await fs.rm(socketPath, { force: true }).catch(() => {});
624
738
  await registerRpcSession({
625
739
  sessionId: session.sessionId,
@@ -363,7 +363,10 @@ export async function dispatchRpcCommand(
363
363
  }
364
364
 
365
365
  case "get_messages": {
366
- return rpcSuccess(id, "get_messages", { messages: session.messages });
366
+ // Fast-lane read: snapshot the live array so a concurrent ordered
367
+ // turn/compaction mutating session.messages in place cannot make this
368
+ // response serialize a half-rewritten array (#606, issue 13).
369
+ return rpcSuccess(id, "get_messages", { messages: [...session.messages] });
367
370
  }
368
371
 
369
372
  case "get_login_providers": {
@@ -447,7 +450,7 @@ export async function dispatchRpcCommand(
447
450
 
448
451
  default: {
449
452
  const unknownCommand = command as { type: string };
450
- return rpcError(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
453
+ return rpcError(id, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
451
454
  }
452
455
  }
453
456
  } catch (err) {
@@ -93,6 +93,9 @@ export class RpcHostToolBridge {
93
93
  }
94
94
 
95
95
  setTools(tools: RpcHostToolDefinition[]): AgentTool[] {
96
+ if (tools.some(tool => tool.name === "ask")) {
97
+ throw new Error('RPC host tool "ask" is reserved and cannot be supplied by the host');
98
+ }
96
99
  this.#definitions = new Map(tools.map(tool => [tool.name, tool]));
97
100
  return tools.map(tool => new RpcHostToolAdapter(tool, this));
98
101
  }
@@ -14,6 +14,7 @@
14
14
  * Also implements the dispatch-facing {@link RpcUnattendedControlPlane} so the
15
15
  * RPC server can route `negotiate_unattended` + `workflow_gate_response` here.
16
16
  */
17
+ import type { Model } from "@gajae-code/ai";
17
18
  import type {
18
19
  RpcCommand,
19
20
  RpcUnattendedAccepted,
@@ -38,6 +39,20 @@ import { type GateStore, MemoryGateStore, type OpenGateInput, WorkflowGateBroker
38
39
  */
39
40
  const CHARGED_COMMAND_TYPES = new Set<RpcCommand["type"]>(["bash", "prompt", "steer", "follow_up", "abort_and_prompt"]);
40
41
 
42
+ /**
43
+ * Derive an explicit `providerSupportsTokenCostMetrics` capability from the
44
+ * active model so unattended negotiation fails closed when token/cost usage
45
+ * cannot be accounted for (#606). Callers that omit a model — or whose model is
46
+ * configured to suppress streaming usage (`compat.supportsUsageInStreaming:
47
+ * false`) — get `false`, which the controller refuses with
48
+ * `unsupported_budget_metric`.
49
+ */
50
+ export function modelSupportsTokenCostMetrics(model: Model | undefined): boolean {
51
+ if (!model) return false;
52
+ const compat = model.compat as { supportsUsageInStreaming?: boolean } | undefined;
53
+ return compat?.supportsUsageInStreaming !== false;
54
+ }
55
+
41
56
  /** Minimal surface a skill runtime / ask tool uses to emit a gate and await its answer. */
42
57
  export interface WorkflowGateEmitter {
43
58
  /** True only when unattended mode has been negotiated. */
@@ -92,7 +107,7 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
92
107
  this.#rejectAllPending(new Error(`unattended run aborted: ${reason}`));
93
108
  },
94
109
  },
95
- providerSupportsTokenCostMetrics: this.opts.providerSupportsTokenCostMetrics ?? true,
110
+ providerSupportsTokenCostMetrics: this.opts.providerSupportsTokenCostMetrics,
96
111
  });
97
112
  this.#controller = controller;
98
113
  this.#broker = new WorkflowGateBroker(this.opts.runId, this.opts.store ?? new MemoryGateStore(), {