@gajae-code/coding-agent 0.5.1 → 0.5.3

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 (165) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +6 -0
  4. package/dist/types/cli/setup-cli.d.ts +8 -1
  5. package/dist/types/commands/setup.d.ts +7 -0
  6. package/dist/types/config/file-lock.d.ts +24 -2
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +62 -0
  10. package/dist/types/dap/client.d.ts +2 -1
  11. package/dist/types/edit/read-file.d.ts +6 -0
  12. package/dist/types/eval/js/context-manager.d.ts +3 -0
  13. package/dist/types/eval/js/executor.d.ts +1 -0
  14. package/dist/types/exec/bash-executor.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  19. package/dist/types/lsp/types.d.ts +2 -0
  20. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  23. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  24. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  28. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  29. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  30. package/dist/types/modes/theme/theme.d.ts +1 -0
  31. package/dist/types/modes/types.d.ts +1 -1
  32. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  33. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  34. package/dist/types/runtime-mcp/types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +17 -1
  36. package/dist/types/session/artifacts.d.ts +4 -1
  37. package/dist/types/session/history-storage.d.ts +2 -2
  38. package/dist/types/session/session-manager.d.ts +10 -1
  39. package/dist/types/session/streaming-output.d.ts +5 -0
  40. package/dist/types/setup/credential-import.d.ts +79 -0
  41. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  42. package/dist/types/task/executor.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +1 -1
  44. package/dist/types/tools/bash.d.ts +1 -0
  45. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  46. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  47. package/dist/types/tools/subagent-render.d.ts +7 -1
  48. package/dist/types/tools/subagent.d.ts +21 -0
  49. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  50. package/dist/types/web/search/index.d.ts +4 -4
  51. package/dist/types/web/search/provider.d.ts +16 -20
  52. package/dist/types/web/search/providers/base.d.ts +2 -1
  53. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  54. package/dist/types/web/search/types.d.ts +14 -2
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +7 -0
  57. package/src/async/job-manager.ts +153 -39
  58. package/src/cli/args.ts +2 -0
  59. package/src/cli/fast-help.ts +2 -0
  60. package/src/cli/setup-cli.ts +138 -3
  61. package/src/commands/setup.ts +5 -1
  62. package/src/commands/ultragoal.ts +3 -1
  63. package/src/config/file-lock-gc.ts +14 -2
  64. package/src/config/file-lock.ts +63 -13
  65. package/src/config/model-profile-activation.ts +15 -3
  66. package/src/config/model-profiles.ts +15 -15
  67. package/src/config/model-registry.ts +21 -1
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +62 -0
  70. package/src/dap/client.ts +105 -64
  71. package/src/dap/session.ts +44 -7
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  73. package/src/edit/read-file.ts +19 -1
  74. package/src/eval/js/context-manager.ts +228 -65
  75. package/src/eval/js/executor.ts +2 -0
  76. package/src/eval/js/index.ts +1 -0
  77. package/src/eval/js/worker-core.ts +10 -6
  78. package/src/eval/py/executor.ts +68 -19
  79. package/src/eval/py/kernel.ts +46 -22
  80. package/src/eval/py/runner.py +68 -14
  81. package/src/exec/bash-executor.ts +49 -13
  82. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  83. package/src/gjc-runtime/launch-tmux.ts +3 -4
  84. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  85. package/src/gjc-runtime/state-runtime.ts +2 -1
  86. package/src/gjc-runtime/state-writer.ts +254 -7
  87. package/src/gjc-runtime/tmux-gc.ts +88 -38
  88. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  89. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  90. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  91. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  92. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  93. package/src/harness-control-plane/owner.ts +3 -2
  94. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  95. package/src/hooks/skill-state.ts +121 -2
  96. package/src/internal-urls/artifact-protocol.ts +10 -1
  97. package/src/internal-urls/docs-index.generated.ts +14 -10
  98. package/src/lsp/client.ts +64 -26
  99. package/src/lsp/defaults.json +1 -0
  100. package/src/lsp/index.ts +2 -1
  101. package/src/lsp/lspmux.ts +33 -9
  102. package/src/lsp/types.ts +2 -0
  103. package/src/main.ts +14 -4
  104. package/src/modes/acp/acp-agent.ts +4 -2
  105. package/src/modes/bridge/bridge-mode.ts +23 -1
  106. package/src/modes/components/assistant-message.ts +10 -2
  107. package/src/modes/components/bash-execution.ts +5 -1
  108. package/src/modes/components/eval-execution.ts +5 -1
  109. package/src/modes/components/history-search.ts +5 -2
  110. package/src/modes/components/model-selector.ts +60 -2
  111. package/src/modes/components/oauth-selector.ts +5 -0
  112. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  113. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  114. package/src/modes/components/skill-message.ts +24 -16
  115. package/src/modes/components/tool-execution.ts +6 -0
  116. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  117. package/src/modes/controllers/input-controller.ts +5 -0
  118. package/src/modes/controllers/selector-controller.ts +86 -2
  119. package/src/modes/interactive-mode.ts +11 -1
  120. package/src/modes/rpc/rpc-mode.ts +132 -18
  121. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  122. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  123. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  124. package/src/modes/theme/defaults/claude-code.json +100 -0
  125. package/src/modes/theme/defaults/codex.json +100 -0
  126. package/src/modes/theme/defaults/index.ts +6 -0
  127. package/src/modes/theme/defaults/opencode.json +102 -0
  128. package/src/modes/theme/theme.ts +2 -2
  129. package/src/modes/types.ts +1 -1
  130. package/src/modes/utils/ui-helpers.ts +5 -2
  131. package/src/prompts/agents/executor.md +5 -2
  132. package/src/runtime/process-lifecycle.ts +400 -0
  133. package/src/runtime-mcp/manager.ts +164 -50
  134. package/src/runtime-mcp/transports/http.ts +12 -11
  135. package/src/runtime-mcp/transports/stdio.ts +64 -38
  136. package/src/runtime-mcp/types.ts +3 -0
  137. package/src/sdk.ts +39 -1
  138. package/src/session/agent-session.ts +190 -33
  139. package/src/session/artifacts.ts +17 -2
  140. package/src/session/blob-store.ts +36 -2
  141. package/src/session/history-storage.ts +32 -11
  142. package/src/session/session-manager.ts +99 -31
  143. package/src/session/streaming-output.ts +54 -3
  144. package/src/setup/credential-import.ts +429 -0
  145. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  146. package/src/slash-commands/builtin-registry.ts +30 -3
  147. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  148. package/src/task/executor.ts +7 -1
  149. package/src/task/render.ts +18 -7
  150. package/src/tools/archive-reader.ts +10 -1
  151. package/src/tools/ask.ts +4 -2
  152. package/src/tools/bash.ts +11 -4
  153. package/src/tools/browser/tab-supervisor.ts +22 -0
  154. package/src/tools/browser.ts +38 -4
  155. package/src/tools/cron.ts +1 -1
  156. package/src/tools/read.ts +11 -12
  157. package/src/tools/sqlite-reader.ts +19 -5
  158. package/src/tools/subagent-render.ts +119 -29
  159. package/src/tools/subagent.ts +147 -7
  160. package/src/tools/ultragoal-ask-guard.ts +39 -0
  161. package/src/web/search/index.ts +25 -25
  162. package/src/web/search/provider.ts +178 -87
  163. package/src/web/search/providers/base.ts +2 -1
  164. package/src/web/search/providers/openai-compatible.ts +151 -0
  165. package/src/web/search/types.ts +47 -22
@@ -1,5 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
- import { isEnoent } from "@gajae-code/utils";
2
+ import { isEnoent } from "@gajae-code/utils/fs-error";
3
3
 
4
4
  export interface FileLockOptions {
5
5
  staleMs?: number;
@@ -45,29 +45,79 @@ export async function readFileLockInfoForGc(lockDir: string): Promise<{ pid: num
45
45
  return info;
46
46
  }
47
47
 
48
- /** @internal */
49
- export async function removeFileLockDirForGc(lockDir: string): Promise<void> {
48
+ /** Owner identity stamped into a `<file>.lock/info` record. */
49
+ export interface FileLockOwnerToken {
50
+ pid: number;
51
+ timestamp: number;
52
+ }
53
+
54
+ /** Outcome of a guarded GC removal attempt (`removeFileLockDirForGc`). */
55
+ export type FileLockGcRemoval = "removed" | "owner_changed" | "missing";
56
+
57
+ /**
58
+ * @internal
59
+ * Fail-closed removal of a dead lock dir for GC. Re-reads the on-disk owner
60
+ * token as close to the unlink as possible and only deletes the dir when it
61
+ * STILL holds the exact `{pid, timestamp}` identity the caller observed dead.
62
+ *
63
+ * Closes the prune-time TOCTOU window (#606): between GC's dead re-read/probe
64
+ * and the unlink, a live process can reclaim a stale lock at the same path
65
+ * (`acquireLock` rms the stale dir, then re-`mkdir`s and rewrites `info` with a
66
+ * fresh pid+timestamp). Deleting by path alone would reap that LIVE lock. Any
67
+ * mismatch (`owner_changed`) or absent/unreadable info (`missing` — e.g. a
68
+ * fresh acquirer between `mkdir` and `writeLockInfo`) refuses the delete and
69
+ * leaves the dir intact. POSIX has no atomic compare-and-delete for a
70
+ * directory, so the residual read->unlink window cannot be fully eliminated,
71
+ * but the reclaim-after-stale scenario the issue describes is now guarded.
72
+ */
73
+ export async function removeFileLockDirForGc(
74
+ lockDir: string,
75
+ expected: FileLockOwnerToken,
76
+ ): Promise<FileLockGcRemoval> {
77
+ const current = await readLockInfo(lockDir);
78
+ if (!current) return "missing";
79
+ if (current.pid !== expected.pid || current.timestamp !== expected.timestamp) {
80
+ return "owner_changed";
81
+ }
50
82
  await fs.rm(lockDir, { recursive: true, force: true });
83
+ return "removed";
51
84
  }
52
85
 
53
- function isProcessAlive(pid: number): boolean {
86
+ type OwnerLiveness = "alive" | "dead" | "unknown";
87
+
88
+ function ownerLiveness(pid: number): OwnerLiveness {
89
+ if (!Number.isFinite(pid) || pid <= 0) return "unknown";
54
90
  try {
55
91
  process.kill(pid, 0);
56
- return true;
57
- } catch {
58
- return false;
92
+ return "alive";
93
+ } catch (error) {
94
+ const code = (error as NodeJS.ErrnoException).code;
95
+ if (code === "ESRCH") return "dead";
96
+ // EPERM means the process exists but we may not signal it; treat as alive.
97
+ // Anything else is indeterminate.
98
+ return code === "EPERM" ? "alive" : "unknown";
59
99
  }
60
100
  }
61
101
 
62
102
  async function isLockStale(lockPath: string, staleMs: number): Promise<boolean> {
63
103
  const info = await readLockInfo(lockPath);
64
- if (!info) return true;
65
-
66
- if (!isProcessAlive(info.pid)) return true;
67
-
68
- if (Date.now() - info.timestamp > staleMs) return true;
104
+ if (!info) {
105
+ try {
106
+ const stats = await fs.stat(lockPath);
107
+ return Date.now() - stats.mtimeMs > staleMs;
108
+ } catch (err) {
109
+ if (isEnoent(err)) return false;
110
+ throw err;
111
+ }
112
+ }
69
113
 
70
- return false;
114
+ // Never reap a live owner by elapsed time: a long legitimate critical section must
115
+ // not have its lock stolen (#652). Reclaim a dead owner immediately. Only when owner
116
+ // liveness is indeterminate do we fall back to the staleMs elapsed-time heuristic.
117
+ const liveness = ownerLiveness(info.pid);
118
+ if (liveness === "dead") return true;
119
+ if (liveness === "alive") return false;
120
+ return Date.now() - info.timestamp > staleMs;
71
121
  }
72
122
 
73
123
  async function tryAcquireLock(lockPath: string): Promise<boolean> {
@@ -11,6 +11,8 @@ import { type GjcModelAssignmentTargetId, isAuthenticated, type ModelRegistry }
11
11
  import { resolveModelRoleValue } from "./model-resolver";
12
12
  import type { Settings } from "./settings";
13
13
 
14
+ const LEGACY_MODEL_PROFILE_ALIASES: ReadonlyMap<string, string> = new Map([["codex-standard", "codex-medium"]]);
15
+
14
16
  type ModelProfileActivationSession = Pick<AgentSession, "model" | "thinkingLevel" | "sessionId"> & {
15
17
  setModelTemporary?: AgentSession["setModelTemporary"];
16
18
  setActiveModelProfile?: (name: string | undefined) => void;
@@ -51,12 +53,22 @@ export function formatModelProfileCredentialError(profileName: string, providers
51
53
  return `Model profile "${profileName}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
52
54
  }
53
55
 
56
+ function resolveModelProfileName(profileName: string, profiles: ReadonlyMap<string, unknown>): string {
57
+ // A retired-name alias is fallback-only: never shadow a profile that actually
58
+ // exists under the requested name (e.g. a user-defined `codex-standard`).
59
+ if (profiles.has(profileName)) return profileName;
60
+ const replacement = LEGACY_MODEL_PROFILE_ALIASES.get(profileName);
61
+ return replacement && profiles.has(replacement) ? replacement : profileName;
62
+ }
63
+
54
64
  export async function prepareModelProfileActivation(
55
65
  options: PrepareModelProfileActivationOptions,
56
66
  ): Promise<PreparedModelProfileActivation> {
57
- const profile = options.modelRegistry.getModelProfile(options.profileName);
67
+ const profiles = options.modelRegistry.getModelProfiles();
68
+ const profileName = resolveModelProfileName(options.profileName, profiles);
69
+ const profile = profiles.get(profileName) ?? options.modelRegistry.getModelProfile(profileName);
58
70
  if (!profile) {
59
- const available = formatAvailableProfileNames(options.modelRegistry.getModelProfiles());
71
+ const available = formatAvailableProfileNames(profiles);
60
72
  throw new Error(`Unknown model profile "${options.profileName}". Available profiles: ${available}`);
61
73
  }
62
74
 
@@ -101,7 +113,7 @@ export async function prepareModelProfileActivation(
101
113
  }
102
114
 
103
115
  return {
104
- profileName: options.profileName,
116
+ profileName,
105
117
  session: options.session as PreparedModelProfileActivation["session"],
106
118
  settings: options.settings as PreparedModelProfileActivation["settings"],
107
119
  previousModel: options.session.model,
@@ -202,25 +202,25 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
202
202
  architect: "cursor/composer-1.5:xhigh",
203
203
  }),
204
204
  profile("minimax-eco", ["minimax-code"], {
205
- default: "minimax-code/minimax-v3:low",
206
- executor: "minimax-code/minimax-v3:minimal",
207
- planner: "minimax-code/minimax-v3:low",
208
- critic: "minimax-code/minimax-v3:medium",
209
- architect: "minimax-code/minimax-v3:high",
205
+ default: "minimax-code/minimax-m3:low",
206
+ executor: "minimax-code/minimax-m3:minimal",
207
+ planner: "minimax-code/minimax-m3:low",
208
+ critic: "minimax-code/minimax-m3:medium",
209
+ architect: "minimax-code/minimax-m3:high",
210
210
  }),
211
211
  profile("minimax-medium", ["minimax-code"], {
212
- default: "minimax-code/minimax-v3:medium",
213
- executor: "minimax-code/minimax-v3:low",
214
- planner: "minimax-code/minimax-v3:medium",
215
- critic: "minimax-code/minimax-v3:high",
216
- architect: "minimax-code/minimax-v3:xhigh",
212
+ default: "minimax-code/minimax-m3:medium",
213
+ executor: "minimax-code/minimax-m3:low",
214
+ planner: "minimax-code/minimax-m3:medium",
215
+ critic: "minimax-code/minimax-m3:high",
216
+ architect: "minimax-code/minimax-m3:xhigh",
217
217
  }),
218
218
  profile("minimax-pro", ["minimax-code"], {
219
- default: "minimax-code/minimax-v3:xhigh",
220
- executor: "minimax-code/minimax-v3:medium",
221
- planner: "minimax-code/minimax-v3:high",
222
- critic: "minimax-code/minimax-v3:xhigh",
223
- architect: "minimax-code/minimax-v3:xhigh",
219
+ default: "minimax-code/minimax-m3:xhigh",
220
+ executor: "minimax-code/minimax-m3:medium",
221
+ planner: "minimax-code/minimax-m3:high",
222
+ critic: "minimax-code/minimax-m3:xhigh",
223
+ architect: "minimax-code/minimax-m3:xhigh",
224
224
  }),
225
225
  profile("opus-codex", ["anthropic", "openai-codex"], {
226
226
  default: "anthropic/claude-opus-4-8:xhigh",
@@ -35,6 +35,7 @@ import { $pickenv, isRecord, logger } from "@gajae-code/utils";
35
35
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
36
36
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
37
37
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
38
+ import type { ActiveSearchModelContext, WebSearchMode } from "../web/search/types";
38
39
  import { type ConfigError, ConfigFile } from "./config-file";
39
40
  import {
40
41
  buildCanonicalModelIndex,
@@ -910,7 +911,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
910
911
  const output = resolvedModel.output ?? reference?.output;
911
912
  return enrichModelThinking({
912
913
  id: resolvedModel.id,
913
- name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
914
+ name: resolvedModel.name ?? reference?.name ?? (options.useDefaults ? resolvedModel.id : undefined),
914
915
  api: resolvedModel.api,
915
916
  provider: resolvedModel.provider,
916
917
  baseUrl: resolvedModel.baseUrl,
@@ -964,6 +965,7 @@ export class ModelRegistry {
964
965
  #models: Model<Api>[] = [];
965
966
  #canonicalIndex: CanonicalModelIndex = { records: [], byId: new Map(), bySelector: new Map() };
966
967
  #customProviderApiKeys: Map<string, string> = new Map();
968
+ #providerWebSearchModes: Map<string, WebSearchMode> = new Map();
967
969
  #keylessProviders: Set<string> = new Set();
968
970
  #discoverableProviders: DiscoveryProviderConfig[] = [];
969
971
  #customModelOverlays: CustomModelOverlay[] = [];
@@ -1073,6 +1075,7 @@ export class ModelRegistry {
1073
1075
  }
1074
1076
  this.#modelsConfigFile.invalidate();
1075
1077
  this.#customProviderApiKeys.clear();
1078
+ this.#providerWebSearchModes.clear();
1076
1079
  this.#keylessProviders.clear();
1077
1080
  this.#discoverableProviders = [];
1078
1081
  // Drop config-sourced apiKeys from AuthStorage before reload; entries
@@ -1390,6 +1393,7 @@ export class ModelRegistry {
1390
1393
  const configuredProviders = new Set(Object.keys(value.providers ?? {}));
1391
1394
 
1392
1395
  for (const [providerName, providerConfig] of providerEntries) {
1396
+ if (providerConfig.webSearch) this.#providerWebSearchModes.set(providerName, providerConfig.webSearch);
1393
1397
  const providerApiKeyConfig = providerConfig.apiKey ?? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv);
1394
1398
  // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present
1395
1399
  if (
@@ -2441,6 +2445,22 @@ export class ModelRegistry {
2441
2445
  );
2442
2446
  }
2443
2447
 
2448
+ getProviderWebSearchMode(provider: string): WebSearchMode | undefined {
2449
+ return this.#providerWebSearchModes.get(provider);
2450
+ }
2451
+
2452
+ getActiveSearchModelContext(model: Model<Api>): ActiveSearchModelContext {
2453
+ return {
2454
+ provider: model.provider,
2455
+ modelId: model.id,
2456
+ wireModelId: model.wireModelId,
2457
+ api: model.api,
2458
+ baseUrl: model.baseUrl,
2459
+ headers: model.headers,
2460
+ webSearch: this.getProviderWebSearchMode(model.provider),
2461
+ };
2462
+ }
2463
+
2444
2464
  /**
2445
2465
  * Get API key for a model.
2446
2466
  */
@@ -218,6 +218,7 @@ const ProviderConfigSchema = z
218
218
  .optional(),
219
219
  headers: z.record(z.string(), z.string()).optional(),
220
220
  compat: OpenAICompatSchema.optional(),
221
+ webSearch: z.enum(["on", "off", "auto"]).optional(),
221
222
  authHeader: z.boolean().optional(),
222
223
  auth: ProviderAuthSchema.optional(),
223
224
  discovery: ProviderDiscoverySchema.optional(),
@@ -2,6 +2,7 @@ import type { Effort } from "@gajae-code/ai/model-thinking";
2
2
  import { TASK_SIMPLE_MODES } from "../task/simple-mode";
3
3
  import { getThinkingLevelMetadata } from "../thinking-metadata";
4
4
  import { EDIT_MODES } from "../utils/edit-mode";
5
+ import { CONFIGURABLE_SEARCH_PROVIDER_IDS } from "../web/search/types";
5
6
 
6
7
  const THINKING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as readonly Effort[];
7
8
 
@@ -164,6 +165,7 @@ interface EnumDef<T extends readonly string[]> {
164
165
  interface ArrayDef<T> {
165
166
  type: "array";
166
167
  default: T[];
168
+ items?: { enum: readonly string[] };
167
169
  ui?: UiBase;
168
170
  }
169
171
 
@@ -832,6 +834,55 @@ export const SETTINGS_SCHEMA = {
832
834
  },
833
835
  },
834
836
 
837
+ "task.serviceTier": {
838
+ type: "enum",
839
+ values: [
840
+ "inherit",
841
+ "none",
842
+ "auto",
843
+ "default",
844
+ "flex",
845
+ "scale",
846
+ "priority",
847
+ "openai-only",
848
+ "claude-only",
849
+ ] as const,
850
+ default: "inherit",
851
+ ui: {
852
+ tab: "tasks",
853
+ label: "Subagent Service Tier",
854
+ description:
855
+ 'Service tier applied to task-tool subagents only. "inherit" copies the main session tier; any explicit value overrides it for subagents without touching the main session.',
856
+ options: [
857
+ {
858
+ value: "inherit",
859
+ label: "Inherit",
860
+ description: "Use the main session's service tier (default)",
861
+ },
862
+ { value: "none", label: "None", description: "Omit service_tier for subagents" },
863
+ { value: "auto", label: "Auto", description: "Use provider default tier selection (OpenAI)" },
864
+ { value: "default", label: "Default", description: "Standard priority processing (OpenAI)" },
865
+ { value: "flex", label: "Flex", description: "Flexible capacity tier when available (OpenAI)" },
866
+ { value: "scale", label: "Scale", description: "Scale Tier credits when available (OpenAI)" },
867
+ {
868
+ value: "priority",
869
+ label: "Priority",
870
+ description: "Priority on every supported provider (OpenAI `service_tier`, Anthropic fast mode)",
871
+ },
872
+ {
873
+ value: "openai-only",
874
+ label: "Priority (OpenAI only)",
875
+ description: "Priority on OpenAI/OpenAI-Codex requests; ignored elsewhere",
876
+ },
877
+ {
878
+ value: "claude-only",
879
+ label: "Priority (Claude only)",
880
+ description: "Anthropic fast mode on direct Claude requests; ignored elsewhere (incl. Bedrock/Vertex)",
881
+ },
882
+ ],
883
+ },
884
+ },
885
+
835
886
  // Retries
836
887
  "retry.enabled": { type: "boolean", default: true },
837
888
 
@@ -2068,6 +2119,17 @@ export const SETTINGS_SCHEMA = {
2068
2119
  ui: { tab: "tools", label: "Web Search", description: "Enable the web_search tool for web searching" },
2069
2120
  },
2070
2121
 
2122
+ "web_search.fallback": {
2123
+ type: "array",
2124
+ default: EMPTY_STRING_ARRAY,
2125
+ items: { enum: CONFIGURABLE_SEARCH_PROVIDER_IDS },
2126
+ ui: {
2127
+ tab: "tools",
2128
+ label: "Web Search Fallback",
2129
+ description: "Ordered fallback web search providers after the active model native provider",
2130
+ },
2131
+ },
2132
+
2071
2133
  "browser.enabled": {
2072
2134
  type: "boolean",
2073
2135
  default: true,
package/src/dap/client.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { logger, ptree } from "@gajae-code/utils";
1
+ import { existsSync } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import { logger } from "@gajae-code/utils";
2
4
  import { formatCrashDiagnosticNotice, writeCrashReport } from "../debug/crash-diagnostics";
3
5
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
6
+ import { type OwnedProcess, spawnOwnedProcess } from "../runtime/process-lifecycle";
4
7
  import { ToolAbortError } from "../tools/tool-errors";
5
8
  import type {
6
9
  DapCapabilities,
@@ -69,10 +72,21 @@ function toErrorMessage(value: unknown): string {
69
72
  return String(value);
70
73
  }
71
74
 
75
+ async function drainReadable(readable: ReadableStream<Uint8Array>): Promise<void> {
76
+ const reader = readable.getReader();
77
+ try {
78
+ while (!(await reader.read()).done) {}
79
+ } catch {
80
+ /* drain best-effort */
81
+ } finally {
82
+ reader.releaseLock();
83
+ }
84
+ }
72
85
  export class DapClient {
73
86
  readonly adapter: DapResolvedAdapter;
74
87
  readonly cwd: string;
75
88
  readonly proc: DapClientState["proc"];
89
+ readonly #owner: OwnedProcess;
76
90
  /** ReadableStream of DAP bytes — from proc.stdout (stdio) or a socket (socket mode). */
77
91
  readonly #readable: ReadableStream<Uint8Array>;
78
92
  /** Write sink — proc.stdin (stdio) or a socket (socket mode). */
@@ -93,14 +107,15 @@ export class DapClient {
93
107
  constructor(
94
108
  adapter: DapResolvedAdapter,
95
109
  cwd: string,
96
- proc: DapClientState["proc"],
110
+ owner: OwnedProcess,
97
111
  options?: { readable?: ReadableStream<Uint8Array>; writeSink?: DapWriteSink; socket?: { end(): void } },
98
112
  ) {
99
113
  this.adapter = adapter;
100
114
  this.cwd = cwd;
101
- this.proc = proc;
102
- this.#readable = options?.readable ?? (proc.stdout as ReadableStream<Uint8Array>);
103
- this.#writeSink = options?.writeSink ?? proc.stdin;
115
+ this.proc = owner.child as DapClientState["proc"];
116
+ this.#owner = owner;
117
+ this.#readable = options?.readable ?? (this.proc.stdout as ReadableStream<Uint8Array>);
118
+ this.#writeSink = options?.writeSink ?? this.proc.stdin;
104
119
  this.#socket = options?.socket;
105
120
  }
106
121
 
@@ -116,13 +131,14 @@ export class DapClient {
116
131
  ...Bun.env,
117
132
  ...NON_INTERACTIVE_ENV,
118
133
  };
119
- const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args], {
134
+ const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args], {
120
135
  cwd,
121
136
  stdin: "pipe",
122
137
  env,
123
- detached: true,
138
+ name: `dap:${adapter.name}`,
124
139
  });
125
- const client = new DapClient(adapter, cwd, proc);
140
+ const client = new DapClient(adapter, cwd, owner);
141
+ const proc = owner.child as DapClientState["proc"];
126
142
  proc.exited.then(() => {
127
143
  client.#handleProcessExit();
128
144
  });
@@ -159,32 +175,40 @@ export class DapClient {
159
175
  env: Record<string, string | undefined>;
160
176
  }): Promise<DapClient> {
161
177
  const socketPath = `/tmp/dap-${adapter.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`;
162
- const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], {
178
+ const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], {
163
179
  cwd,
164
180
  stdin: "pipe",
165
181
  env,
166
- detached: true,
182
+ name: `dap:${adapter.name}:unix-socket`,
167
183
  });
184
+ const proc = owner.child as DapClientState["proc"];
185
+ void drainReadable(proc.stdout);
186
+ let transport: SocketTransport | undefined;
168
187
 
169
- // Wait for the socket file to appear (dlv needs to start listening)
170
- await waitForCondition(
171
- () => {
172
- try {
173
- Bun.file(socketPath).size;
174
- return true;
175
- } catch {
176
- return false;
177
- }
178
- },
179
- 10_000,
180
- proc,
181
- );
188
+ try {
189
+ // Wait for the socket file to appear (dlv needs to start listening)
190
+ await waitForCondition(
191
+ // `Bun.file(path).size` returns 0 for a missing file instead of
192
+ // throwing, so it can't gate socket readiness. Use an existence
193
+ // check so the adapter has actually created the listener socket.
194
+ () => existsSync(socketPath),
195
+ 10_000,
196
+ proc,
197
+ );
182
198
 
183
- const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
184
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
185
- proc.exited.then(() => client.#handleProcessExit());
186
- void client.#startMessageReader();
187
- return client;
199
+ transport = await connectSocket({ unix: socketPath }, 10_000);
200
+ const client = new DapClient(adapter, cwd, owner, transport);
201
+ proc.exited.then(() => client.#handleProcessExit());
202
+ void client.#startMessageReader();
203
+ return client;
204
+ } catch (err) {
205
+ transport?.socket.end();
206
+ await owner.dispose();
207
+ await owner.awaitExit({ timeoutMs: 1_000 });
208
+ throw err;
209
+ } finally {
210
+ await fs.unlink(socketPath).catch(() => undefined);
211
+ }
188
212
  }
189
213
 
190
214
  /** macOS/other: listen on a random TCP port, spawn adapter with --client-addr, accept connection. */
@@ -214,12 +238,14 @@ export class DapClient {
214
238
  });
215
239
 
216
240
  const port = server.port;
217
- const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--client-addr=127.0.0.1:${port}`], {
241
+ const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args, `--client-addr=127.0.0.1:${port}`], {
218
242
  cwd,
219
243
  stdin: "pipe",
220
244
  env,
221
- detached: true,
245
+ name: `dap:${adapter.name}:client-addr`,
222
246
  });
247
+ const proc = owner.child as DapClientState["proc"];
248
+ void drainReadable(proc.stdout);
223
249
 
224
250
  // Wait for dlv to connect (with timeout)
225
251
  let rawSocket: Bun.Socket<undefined>;
@@ -230,13 +256,17 @@ export class DapClient {
230
256
  );
231
257
  try {
232
258
  rawSocket = await Promise.race([connPromise, timeoutPromise]);
259
+ } catch (err) {
260
+ await owner.dispose();
261
+ await owner.awaitExit({ timeoutMs: 1_000 });
262
+ throw err;
233
263
  } finally {
234
264
  clearTimeout(connectTimeout);
235
265
  server.stop();
236
266
  }
237
267
 
238
268
  const { readable, writeSink, socket } = wrapBunSocket(rawSocket);
239
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
269
+ const client = new DapClient(adapter, cwd, owner, { readable, writeSink, socket });
240
270
  proc.exited.then(() => client.#handleProcessExit());
241
271
  void client.#startMessageReader();
242
272
  return client;
@@ -414,14 +444,14 @@ export class DapClient {
414
444
  /* socket may already be closed */
415
445
  }
416
446
  try {
417
- this.proc.kill();
447
+ await this.#owner.dispose();
448
+ await this.#owner.awaitExit({ timeoutMs: 1_000 });
418
449
  } catch (error) {
419
- logger.debug("Failed to kill DAP adapter", {
450
+ logger.debug("Failed to dispose DAP adapter", {
420
451
  adapter: this.adapter.name,
421
452
  error: toErrorMessage(error),
422
453
  });
423
454
  }
424
- await this.proc.exited.catch(() => {});
425
455
  }
426
456
 
427
457
  async #startMessageReader(): Promise<void> {
@@ -604,8 +634,8 @@ function socketToSink(socket: Bun.Socket<undefined>): DapWriteSink {
604
634
  }
605
635
 
606
636
  /** Connect to a unix domain socket and return DAP transport streams. */
607
- async function connectSocket(options: { unix: string }): Promise<SocketTransport> {
608
- const { promise, resolve } = Promise.withResolvers<SocketTransport>();
637
+ async function connectSocket(options: { unix: string }, timeoutMs = 10_000): Promise<SocketTransport> {
638
+ const { promise, resolve, reject } = Promise.withResolvers<SocketTransport>();
609
639
  let streamController: ReadableStreamDefaultController<Uint8Array>;
610
640
 
611
641
  const readable = new ReadableStream<Uint8Array>({
@@ -614,35 +644,46 @@ async function connectSocket(options: { unix: string }): Promise<SocketTransport
614
644
  },
615
645
  });
616
646
 
617
- Bun.connect({
618
- unix: options.unix,
619
- socket: {
620
- open(socket) {
621
- resolve({
622
- readable,
623
- writeSink: socketToSink(socket),
624
- socket,
625
- });
626
- },
627
- data(_socket, data) {
628
- streamController.enqueue(new Uint8Array(data));
629
- },
630
- close() {
631
- try {
632
- streamController.close();
633
- } catch {
634
- /* already closed */
635
- }
636
- },
637
- error(_socket, err) {
638
- try {
639
- streamController.error(err);
640
- } catch {
641
- /* already closed */
642
- }
647
+ const timeout = setTimeout(() => reject(new Error(`Socket connect timed out after ${timeoutMs}ms`)), timeoutMs);
648
+ let settled = false;
649
+ const settle = (fn: () => void) => {
650
+ if (settled) return;
651
+ settled = true;
652
+ clearTimeout(timeout);
653
+ fn();
654
+ };
655
+ try {
656
+ const socketPromise = Bun.connect({
657
+ unix: options.unix,
658
+ socket: {
659
+ open(socket) {
660
+ settle(() =>
661
+ resolve({
662
+ readable,
663
+ writeSink: socketToSink(socket),
664
+ socket,
665
+ }),
666
+ );
667
+ },
668
+ data(_socket, data) {
669
+ streamController.enqueue(new Uint8Array(data));
670
+ },
671
+ close() {
672
+ try {
673
+ streamController.close();
674
+ } catch {
675
+ /* already closed */
676
+ }
677
+ },
678
+ error(_socket, err) {
679
+ settle(() => reject(err));
680
+ },
643
681
  },
644
- },
645
- });
682
+ });
683
+ void socketPromise.catch(err => settle(() => reject(err)));
684
+ } catch (err) {
685
+ settle(() => reject(err));
686
+ }
646
687
 
647
688
  return promise;
648
689
  }