@gajae-code/coding-agent 0.7.7 → 0.7.9

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 (96) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +14 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/keybindings.d.ts +1 -1
  8. package/dist/types/config/model-profile-activation.d.ts +19 -2
  9. package/dist/types/config/model-profiles.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +9 -0
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +45 -0
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  15. package/dist/types/gjc-runtime/launch-tmux.d.ts +14 -5
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  17. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  18. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +3 -1
  19. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  20. package/dist/types/modes/components/hook-input.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +11 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  24. package/dist/types/modes/components/status-line.d.ts +7 -3
  25. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
  26. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  27. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  28. package/dist/types/modes/interactive-mode.d.ts +3 -1
  29. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  30. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +21 -1
  31. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  32. package/dist/types/modes/types.d.ts +3 -1
  33. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  34. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  35. package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
  36. package/dist/types/notifications/telegram-daemon.d.ts +1 -10
  37. package/dist/types/tools/ask.d.ts +24 -0
  38. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  39. package/package.json +7 -7
  40. package/src/cli/local-provider-smoke.ts +632 -0
  41. package/src/cli/notify-cli.ts +5 -3
  42. package/src/cli/update-cli.ts +155 -13
  43. package/src/cli.ts +19 -0
  44. package/src/commands/launch.ts +46 -1
  45. package/src/commands/local-provider.ts +62 -0
  46. package/src/commands/notify.ts +1 -1
  47. package/src/config/keybindings.ts +1 -1
  48. package/src/config/model-profile-activation.ts +92 -11
  49. package/src/config/model-profiles.ts +15 -2
  50. package/src/config/model-registry.ts +136 -6
  51. package/src/config/models-config-schema.ts +9 -0
  52. package/src/config/settings-schema.ts +48 -0
  53. package/src/config/settings.ts +11 -0
  54. package/src/coordinator-mcp/server.ts +280 -36
  55. package/src/deep-interview/render-middleware.ts +1 -0
  56. package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -6
  57. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
  58. package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
  59. package/src/gjc-runtime/launch-tmux.ts +321 -38
  60. package/src/gjc-runtime/launch-worktree.ts +16 -2
  61. package/src/gjc-runtime/team-runtime.ts +87 -7
  62. package/src/gjc-runtime/tmux-common.ts +16 -0
  63. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  64. package/src/gjc-runtime/ultragoal-guard.ts +11 -5
  65. package/src/internal-urls/docs-index.generated.ts +10 -9
  66. package/src/modes/components/agent-dashboard.ts +2 -1
  67. package/src/modes/components/custom-model-preset-wizard.ts +31 -192
  68. package/src/modes/components/hook-editor.ts +7 -4
  69. package/src/modes/components/hook-input.ts +4 -0
  70. package/src/modes/components/model-selector.ts +260 -12
  71. package/src/modes/components/session-observer-overlay.ts +3 -3
  72. package/src/modes/components/settings-selector.ts +506 -5
  73. package/src/modes/components/status-line/segments.ts +16 -19
  74. package/src/modes/components/status-line/types.ts +6 -2
  75. package/src/modes/components/status-line.ts +70 -35
  76. package/src/modes/controllers/event-controller.ts +98 -4
  77. package/src/modes/controllers/extension-ui-controller.ts +5 -0
  78. package/src/modes/controllers/input-controller.ts +15 -0
  79. package/src/modes/controllers/selector-controller.ts +142 -11
  80. package/src/modes/interactive-mode.ts +7 -2
  81. package/src/modes/prompt-action-autocomplete.ts +8 -0
  82. package/src/modes/shared/agent-wire/deep-interview-gate.ts +11 -4
  83. package/src/modes/tmux-scroll.ts +92 -0
  84. package/src/modes/types.ts +6 -1
  85. package/src/modes/utils/terminal-bell.ts +48 -0
  86. package/src/notifications/daemon-paths.ts +22 -0
  87. package/src/notifications/telegram-daemon-cli.ts +125 -13
  88. package/src/notifications/telegram-daemon.ts +6 -21
  89. package/src/prompts/system/system-prompt.md +5 -0
  90. package/src/sdk.ts +10 -1
  91. package/src/session/agent-session.ts +84 -6
  92. package/src/slash-commands/builtin-registry.ts +106 -13
  93. package/src/tools/ask.ts +11 -1
  94. package/src/tools/eval.ts +2 -2
  95. package/src/tools/render-utils.ts +2 -0
  96. package/src/tools/ultragoal-ask-guard.ts +39 -5
@@ -14,6 +14,8 @@ import { theme } from "../modes/theme/theme";
14
14
 
15
15
  const RELEASE_REPO = "Yeachan-Heo/gajae-code";
16
16
  const PACKAGE = "@gajae-code/coding-agent";
17
+ const NPM_WRAPPER_PACKAGE = "gajae-code";
18
+ const NPM_MANAGED_PACKAGES = [NPM_WRAPPER_PACKAGE, PACKAGE] as const;
17
19
 
18
20
  interface ReleaseInfo {
19
21
  tag: string;
@@ -27,6 +29,23 @@ export interface InstalledVersionVerification {
27
29
  path?: string;
28
30
  smokeTestFailed?: boolean;
29
31
  smokeTestOutput?: string;
32
+ cleanupWarning?: string;
33
+ }
34
+
35
+ export interface PackageManagerUpdateResult {
36
+ exitCode: number | null;
37
+ text: () => string;
38
+ }
39
+
40
+ export type PackageManagerUpdateRunner = (expectedVersion: string) => Promise<PackageManagerUpdateResult>;
41
+
42
+ export interface PackageManagerUpdateOptions {
43
+ managerName: string;
44
+ expectedVersion: string;
45
+ runInstall: PackageManagerUpdateRunner;
46
+ verifyInstalledRuntime: (expectedVersion: string) => Promise<InstalledVersionVerification>;
47
+ printVerificationResult?: (expectedVersion: string) => Promise<void>;
48
+ printRecoveredVerification?: (expectedVersion: string) => void;
30
49
  }
31
50
 
32
51
  /** Paths and verifier used while replacing a downloaded binary update. */
@@ -102,23 +121,76 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
102
121
  return isPathInDirectoryLexical(resolvedFile, dirReal);
103
122
  }
104
123
 
105
- type UpdateTarget = { method: "bun" } | { method: "binary"; path: string };
124
+ type PackageManagerTarget = { manager: "npm"; packageName: string };
125
+ type UpdateTarget = { method: "bun" } | { method: "npm"; packageName: string } | { method: "binary"; path: string };
126
+
127
+ type PathPlatform = NodeJS.Platform;
128
+ type PackageExists = (packageName: string, packageRoot: string) => boolean;
129
+
130
+ function pathApiForPlatform(platform: PathPlatform): typeof path.posix | typeof path.win32 {
131
+ return platform === "win32" ? path.win32 : path.posix;
132
+ }
133
+
134
+ function defaultPackageExists(_packageName: string, packageRoot: string): boolean {
135
+ return fs.existsSync(path.join(packageRoot, "package.json"));
136
+ }
137
+
138
+ function npmPackageRootForBinPath(binPath: string, packageName: string, platform: PathPlatform): string {
139
+ const pathApi = pathApiForPlatform(platform);
140
+ const segments = packageName.split("/");
141
+ return pathApi.join(pathApi.dirname(binPath), "node_modules", ...segments);
142
+ }
143
+
144
+ function resolveNpmManagedTarget(
145
+ ompPath: string,
146
+ platform: PathPlatform = process.platform,
147
+ packageExists: PackageExists = defaultPackageExists,
148
+ ): PackageManagerTarget | undefined {
149
+ if (platform !== "win32") return undefined;
150
+ const pathApi = pathApiForPlatform(platform);
151
+ const extension = pathApi.extname(ompPath).toLowerCase();
152
+ if (extension !== ".cmd" && extension !== ".ps1") return undefined;
153
+ const basename = pathApi.basename(ompPath, extension).toLowerCase();
154
+ if (basename !== APP_NAME.toLowerCase()) return undefined;
106
155
 
107
- function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
156
+ for (const packageName of NPM_MANAGED_PACKAGES) {
157
+ const packageRoot = npmPackageRootForBinPath(ompPath, packageName, platform);
158
+ if (packageExists(packageName, packageRoot)) return { manager: "npm", packageName };
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "npm" | "binary" {
164
+ if (resolveNpmManagedTarget(ompPath)) return "npm";
108
165
  if (!bunBinDir) return "binary";
109
166
  return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
110
167
  }
111
168
 
112
- export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
169
+ export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "npm" | "binary" {
113
170
  return resolveUpdateMethod(ompPath, bunBinDir);
114
171
  }
172
+
173
+ export function resolveNpmManagedTargetForTest(
174
+ ompPath: string,
175
+ platform: PathPlatform,
176
+ packageExists: PackageExists,
177
+ ): PackageManagerTarget | undefined {
178
+ return resolveNpmManagedTarget(ompPath, platform, packageExists);
179
+ }
115
180
  async function resolveUpdateTarget(): Promise<UpdateTarget> {
116
181
  const bunBinDir = await getBunGlobalBinDir();
117
182
  const ompPath = resolveGjcPath();
118
183
 
119
184
  if (ompPath) {
185
+ const npmTarget = resolveNpmManagedTarget(ompPath);
186
+ if (npmTarget) return { method: "npm", packageName: npmTarget.packageName };
120
187
  const method = resolveUpdateMethod(ompPath, bunBinDir);
121
188
  if (method === "bun") return { method };
189
+ if (method === "npm") {
190
+ throw new Error(
191
+ formatUnsupportedTargetMessage(`Could not resolve npm package root for ${APP_NAME} shim ${ompPath}`),
192
+ );
193
+ }
122
194
  return { method, path: ompPath };
123
195
  }
124
196
 
@@ -262,6 +334,11 @@ function printVerifiedVersion(expectedVersion: string): void {
262
334
  console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
263
335
  }
264
336
 
337
+ function printSuccessfulVerification(expectedVersion: string): void {
338
+ printVerifiedVersion(expectedVersion);
339
+ printRestartGuidance();
340
+ }
341
+
265
342
  function formatBinaryInstallInstruction(platform: NodeJS.Platform = process.platform): string {
266
343
  if (platform === "win32") {
267
344
  return `For a supported binary install, reinstall with PowerShell: irm https://raw.githubusercontent.com/${RELEASE_REPO}/main/scripts/install.ps1 | iex`;
@@ -351,8 +428,7 @@ export function formatVerificationFailureForTest(
351
428
  async function printVerification(expectedVersion: string): Promise<void> {
352
429
  const result = await verifyInstalledRuntime(expectedVersion);
353
430
  if (result.ok) {
354
- printVerifiedVersion(expectedVersion);
355
- printRestartGuidance();
431
+ printSuccessfulVerification(expectedVersion);
356
432
  return;
357
433
  }
358
434
  console.log(chalk.yellow(`\nWarning: ${formatVerificationFailure(result, expectedVersion)}`));
@@ -367,6 +443,19 @@ async function unlinkIfExists(filePath: string): Promise<void> {
367
443
  }
368
444
  }
369
445
 
446
+ function formatBackupCleanupWarning(backupPath: string, err: unknown): string {
447
+ return `Installed update, but could not remove backup file ${backupPath}: ${err}. You can delete it manually after closing shells or antivirus processes that may still hold it.`;
448
+ }
449
+
450
+ async function cleanupVerifiedBackup(backupPath: string): Promise<string | undefined> {
451
+ try {
452
+ await unlinkIfExists(backupPath);
453
+ return undefined;
454
+ } catch (err) {
455
+ return formatBackupCleanupWarning(backupPath, err);
456
+ }
457
+ }
458
+
370
459
  /**
371
460
  * Atomically replace the installed binary and roll back if version verification fails.
372
461
  */
@@ -386,8 +475,8 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
386
475
  }
387
476
 
388
477
  backupReady = false;
389
- await unlinkIfExists(options.backupPath);
390
- return verification;
478
+ const cleanupWarning = await cleanupVerifiedBackup(options.backupPath);
479
+ return cleanupWarning ? { ...verification, cleanupWarning } : verification;
391
480
  } catch (err) {
392
481
  if (backupReady) {
393
482
  await unlinkIfExists(options.targetPath);
@@ -398,17 +487,67 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
398
487
  }
399
488
  }
400
489
 
490
+ function formatPackageManagerInstallFailure(
491
+ managerName: string,
492
+ result: PackageManagerUpdateResult,
493
+ verification: InstalledVersionVerification,
494
+ expectedVersion: string,
495
+ ): string {
496
+ const output = normalizeVerificationOutput(result.text());
497
+ const outputSuffix = output ? `: ${output}` : "";
498
+ return `${managerName} install failed with exit code ${result.exitCode ?? "unknown"}${outputSuffix}. ${formatVerificationFailure(verification, expectedVersion)}`;
499
+ }
500
+
501
+ export async function runPackageManagerUpdateForTest(
502
+ options: PackageManagerUpdateOptions,
503
+ ): Promise<InstalledVersionVerification> {
504
+ return updateViaPackageManager(options);
505
+ }
506
+
507
+ async function updateViaPackageManager(options: PackageManagerUpdateOptions): Promise<InstalledVersionVerification> {
508
+ const result = await options.runInstall(options.expectedVersion);
509
+ if (result.exitCode === 0) {
510
+ await (options.printVerificationResult ?? printVerification)(options.expectedVersion);
511
+ return await options.verifyInstalledRuntime(options.expectedVersion);
512
+ }
513
+
514
+ const verification = await options.verifyInstalledRuntime(options.expectedVersion);
515
+ if (verification.ok) {
516
+ console.warn(
517
+ chalk.yellow(
518
+ `${options.managerName} exited with ${result.exitCode ?? "unknown"}, but ${APP_NAME} now verifies as ${options.expectedVersion}. Treating the update as installed.`,
519
+ ),
520
+ );
521
+ (options.printRecoveredVerification ?? printSuccessfulVerification)(options.expectedVersion);
522
+ return verification;
523
+ }
524
+
525
+ throw new Error(
526
+ formatPackageManagerInstallFailure(options.managerName, result, verification, options.expectedVersion),
527
+ );
528
+ }
529
+
401
530
  /**
402
531
  * Update via bun package manager.
403
532
  */
404
533
  async function updateViaBun(expectedVersion: string): Promise<void> {
405
534
  console.log(chalk.dim("Updating via bun..."));
406
- const result = await $`bun install -g ${PACKAGE}@${expectedVersion}`.nothrow();
407
- if (result.exitCode !== 0) {
408
- throw new Error(`bun install failed with exit code ${result.exitCode}`);
409
- }
535
+ await updateViaPackageManager({
536
+ managerName: "bun",
537
+ expectedVersion,
538
+ runInstall: async version => await $`bun install -g ${PACKAGE}@${version}`.nothrow(),
539
+ verifyInstalledRuntime,
540
+ });
541
+ }
410
542
 
411
- await printVerification(expectedVersion);
543
+ async function updateViaNpm(packageName: string, expectedVersion: string): Promise<void> {
544
+ console.log(chalk.dim(`Updating npm-managed install via npm (${packageName})...`));
545
+ await updateViaPackageManager({
546
+ managerName: "npm",
547
+ expectedVersion,
548
+ runInstall: async version => await $`npm install -g ${packageName}@${version}`.nothrow(),
549
+ verifyInstalledRuntime,
550
+ });
412
551
  }
413
552
 
414
553
  /**
@@ -430,7 +569,7 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
430
569
  await pipeline(response.body, fileStream);
431
570
 
432
571
  console.log(chalk.dim("Installing update..."));
433
- await replaceBinaryForUpdate({
572
+ const verification = await replaceBinaryForUpdate({
434
573
  targetPath,
435
574
  tempPath,
436
575
  backupPath,
@@ -438,6 +577,7 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
438
577
  verifyInstalledVersion: verifyInstalledRuntime,
439
578
  });
440
579
  printVerifiedVersion(expectedVersion);
580
+ if (verification.cleanupWarning) console.warn(chalk.yellow(verification.cleanupWarning));
441
581
  printRestartGuidance();
442
582
  }
443
583
 
@@ -479,6 +619,8 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
479
619
  const target = await resolveUpdateTarget();
480
620
  if (target.method === "bun") {
481
621
  await updateViaBun(release.version);
622
+ } else if (target.method === "npm") {
623
+ await updateViaNpm(target.packageName, release.version);
482
624
  } else {
483
625
  await updateViaBinaryAt(target.path, release.version);
484
626
  }
package/src/cli.ts CHANGED
@@ -26,6 +26,7 @@ export const commands: CommandEntry[] = [
26
26
  { name: "codex-native-hook", load: () => import("./commands/codex-native-hook").then(m => m.default) },
27
27
  { name: "state", load: () => import("./commands/state").then(m => m.default) },
28
28
  { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
29
+ { name: "acp", load: () => import("./commands/acp").then(m => m.default) },
29
30
  { name: "skills", load: () => import("./commands/skills").then(m => m.default) },
30
31
  { name: "session", load: () => import("./commands/session").then(m => m.default) },
31
32
  { name: "harness", load: () => import("./commands/harness").then(m => m.default) },
@@ -38,6 +39,7 @@ export const commands: CommandEntry[] = [
38
39
  { name: "notify", load: () => import("./commands/notify").then(m => m.default) },
39
40
  { name: "daemon", load: () => import("./commands/daemon").then(m => m.default) },
40
41
  { name: "web-search", aliases: ["q"], load: () => import("./commands/web-search").then(m => m.default) },
42
+ { name: "local-provider", load: () => import("./commands/local-provider").then(m => m.default) },
41
43
  { name: "mcp-serve", load: () => import("./commands/mcp-serve").then(m => m.default) },
42
44
  { name: "mcp", load: () => import("./commands/mcp").then(m => m.default) },
43
45
  {
@@ -79,6 +81,19 @@ async function installRuntimeGlobals(): Promise<void> {
79
81
  delete process.env.MallocStackLoggingNoCompact;
80
82
  }
81
83
 
84
+ function isNotifyDaemonInternalFastPath(argv: string[]): boolean {
85
+ return argv[0] === "notify" && argv[1] === "daemon-internal";
86
+ }
87
+
88
+ async function runNotifyDaemonInternalFastPath(argv: string[]): Promise<void> {
89
+ const { parseNotifyArgs, runNotifyCommand } = await import("./cli/notify-cli");
90
+ const cmd = parseNotifyArgs(argv);
91
+ if (cmd?.action !== "daemon-internal") {
92
+ throw new Error("invalid notify daemon-internal fast path");
93
+ }
94
+ await runNotifyCommand(cmd);
95
+ }
96
+
82
97
  function hasRootFastFlag(argv: string[], flags: readonly string[]): boolean {
83
98
  for (const arg of argv) {
84
99
  if (isSubcommand(arg)) return false;
@@ -208,6 +223,10 @@ async function runSmokeTest(): Promise<void> {
208
223
 
209
224
  /** Run the CLI with the given argv (no `process.argv` prefix). */
210
225
  export async function runCli(argv: string[]): Promise<void> {
226
+ if (isNotifyDaemonInternalFastPath(argv)) {
227
+ await runNotifyDaemonInternalFastPath(argv);
228
+ return;
229
+ }
211
230
  if (argv[0] === "--smoke-test") {
212
231
  await runSmokeTest();
213
232
  return;
@@ -2,15 +2,54 @@
2
2
  * Root command for the coding agent CLI.
3
3
  */
4
4
 
5
+ import * as fs from "node:fs/promises";
6
+ import * as path from "node:path";
5
7
  import { THINKING_EFFORTS } from "@gajae-code/ai";
6
8
  import { APP_NAME, setProjectDir } from "@gajae-code/utils";
7
9
  import { Args, Command, Flags } from "@gajae-code/utils/cli";
8
10
  import { parseArgs } from "../cli/args";
9
11
  import { launchDefaultTmuxIfNeeded } from "../gjc-runtime/launch-tmux";
10
12
  import { prepareLaunchWorktree } from "../gjc-runtime/launch-worktree";
13
+ import {
14
+ GJC_COORDINATOR_SESSION_ID_ENV,
15
+ GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
16
+ } from "../gjc-runtime/session-state-sidecar";
11
17
  import { runRootCommand } from "../main";
12
18
  import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth";
13
19
 
20
+ async function persistCoordinatorLaunchFailure(error: unknown, cwd: string): Promise<void> {
21
+ const stateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
22
+ if (!stateFile) return;
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ const code = message.split(":", 1)[0] || "launch_failed";
25
+ const now = new Date().toISOString();
26
+ const payload = {
27
+ schema_version: 1,
28
+ session_id: process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || null,
29
+ state: "errored",
30
+ ready_for_input: false,
31
+ updated_at: now,
32
+ current_turn_id: null,
33
+ last_turn_id: null,
34
+ live: false,
35
+ reason: code,
36
+ source: "agent_session_event",
37
+ event: "launch_error",
38
+ cwd,
39
+ session_file: null,
40
+ final_response: {
41
+ text: message,
42
+ format: "markdown",
43
+ source: "launch_error",
44
+ artifact_path: null,
45
+ truncated: false,
46
+ },
47
+ error: { code, message, recoverable: true },
48
+ };
49
+ await fs.mkdir(path.dirname(stateFile), { recursive: true });
50
+ await Bun.write(stateFile, `${JSON.stringify(payload, null, 2)}\n`);
51
+ }
52
+
14
53
  export default class Index extends Command {
15
54
  static description = "Red-claw AI coding assistant";
16
55
  static hidden = true;
@@ -157,7 +196,13 @@ export default class Index extends Command {
157
196
  return;
158
197
  }
159
198
 
160
- const launch = prepareLaunchWorktree(process.cwd(), args);
199
+ let launch: ReturnType<typeof prepareLaunchWorktree>;
200
+ try {
201
+ launch = prepareLaunchWorktree(process.cwd(), args);
202
+ } catch (error) {
203
+ await persistCoordinatorLaunchFailure(error, process.cwd());
204
+ throw error;
205
+ }
161
206
  if (launch.worktree.enabled) {
162
207
  process.chdir(launch.cwd);
163
208
  setProjectDir(launch.cwd);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Test configured local OpenAI-compatible providers.
3
+ */
4
+ import { Args, Command, Flags } from "@gajae-code/utils/cli";
5
+ import {
6
+ runLocalProviderDiscoverCommand,
7
+ runLocalProviderSmokeCommand,
8
+ runLocalProviderStatusCommand,
9
+ } from "../cli/local-provider-smoke";
10
+
11
+ export const LOCAL_PROVIDER_ACTIONS = ["status", "diagnose", "discover", "models", "smoke"] as const;
12
+ export const LOCAL_PROVIDER_DEFAULT_ACTION = "status";
13
+
14
+ export default class LocalProvider extends Command {
15
+ static description = "Diagnose configured local OpenAI-compatible providers";
16
+
17
+ static args = {
18
+ action: Args.string({ description: "Action", required: false, options: LOCAL_PROVIDER_ACTIONS }),
19
+ };
20
+
21
+ static flags = {
22
+ model: Flags.string({ description: "Model id to use for smoke (otherwise uses the first /models id)" }),
23
+ "models-path": Flags.string({ description: "Override models config path" }),
24
+ "timeout-ms": Flags.integer({ description: "Request timeout in milliseconds" }),
25
+ smoke: Flags.boolean({ description: "Run optional streaming chat smoke during status/diagnose" }),
26
+ json: Flags.boolean({ description: "Output JSON" }),
27
+ };
28
+
29
+ async run(): Promise<void> {
30
+ const { args, flags } = await this.parse(LocalProvider);
31
+ const action = args.action ?? LOCAL_PROVIDER_DEFAULT_ACTION;
32
+ if (action === "status" || action === "diagnose") {
33
+ await runLocalProviderStatusCommand({
34
+ model: flags.model,
35
+ modelsPath: flags["models-path"],
36
+ timeoutMs: flags["timeout-ms"],
37
+ smoke: flags.smoke,
38
+ json: flags.json,
39
+ });
40
+ return;
41
+ }
42
+ if (action === "discover" || action === "models") {
43
+ await runLocalProviderDiscoverCommand({
44
+ modelsPath: flags["models-path"],
45
+ timeoutMs: flags["timeout-ms"],
46
+ json: flags.json,
47
+ });
48
+ return;
49
+ }
50
+ if (action === "smoke") {
51
+ await runLocalProviderSmokeCommand({
52
+ model: flags.model,
53
+ modelsPath: flags["models-path"],
54
+ timeoutMs: flags["timeout-ms"],
55
+ json: flags.json,
56
+ });
57
+ return;
58
+ }
59
+ process.stderr.write(`Unsupported local-provider action: ${action}\n`);
60
+ process.exitCode = 1;
61
+ }
62
+ }
@@ -55,7 +55,7 @@ export default class Notify extends Command {
55
55
  redact: Boolean(flags.redact),
56
56
  };
57
57
 
58
- await initTheme();
58
+ if (action !== "daemon-internal") await initTheme();
59
59
  await runNotifyCommand(cmd);
60
60
  }
61
61
  }
@@ -118,7 +118,7 @@ export const KEYBINDINGS = {
118
118
  },
119
119
  "app.message.followUp": {
120
120
  defaultKeys: [],
121
- description: "Send follow-up message (no default; Ctrl+Enter inserts a newline)",
121
+ description: "Send follow-up message (no default; Ctrl+Enter submits)",
122
122
  },
123
123
  "app.message.queue": {
124
124
  defaultKeys: "alt+enter",
@@ -5,6 +5,7 @@ import { formatClampedModelSelector } from "../thinking";
5
5
  import {
6
6
  aggregateModelProfileRequiredProviders,
7
7
  formatAvailableProfileNames,
8
+ formatModelProfileDisplayLabel,
8
9
  resolveProfileBindings,
9
10
  } from "./model-profiles";
10
11
  import {
@@ -46,7 +47,7 @@ export interface PrepareModelProfileActivationOptions {
46
47
  export interface PreparedModelProfileActivation {
47
48
  profileName: string;
48
49
  session: ModelProfileActivationSession & { setModelTemporary: AgentSession["setModelTemporary"] };
49
- settings: Pick<Settings, "get" | "override" | "set" | "flush">;
50
+ settings: Pick<Settings, "clearOverride" | "get" | "getGlobal" | "override" | "set" | "flush">;
50
51
  previousModel: Model<Api> | undefined;
51
52
  previousThinkingLevel: ThinkingLevel | undefined;
52
53
  previousAgentModelOverrides: Record<string, string>;
@@ -108,8 +109,8 @@ export function materializeActiveModelProfileAssignment(options: MaterializeMode
108
109
  return true;
109
110
  }
110
111
 
111
- export function formatModelProfileCredentialError(profileName: string, providers: readonly string[]): string {
112
- return `Model profile "${profileName}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
112
+ export function formatModelProfileCredentialError(profileLabel: string, providers: readonly string[]): string {
113
+ return `Model profile "${profileLabel}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
113
114
  }
114
115
 
115
116
  function resolveModelProfileName(profileName: string, profiles: ReadonlyMap<string, unknown>): string {
@@ -183,6 +184,7 @@ export async function prepareModelProfileActivation(
183
184
  const available = formatAvailableProfileNames(profiles);
184
185
  throw new Error(`Unknown model profile "${options.profileName}". Available profiles: ${available}`);
185
186
  }
187
+ const profileLabel = formatModelProfileDisplayLabel(profile);
186
188
 
187
189
  const allProviders = aggregateModelProfileRequiredProviders(profile.requiredProviders, profile);
188
190
  const alternativeGroups = profile.alternativeProviderGroups ?? [];
@@ -202,19 +204,19 @@ export async function prepareModelProfileActivation(
202
204
  // Check strict (non-alternative) providers — all must be authenticated.
203
205
  const strictMissing = missingProviders.filter(p => !alternativeSet.has(p));
204
206
  if (strictMissing.length > 0) {
205
- throw new Error(formatModelProfileCredentialError(options.profileName, strictMissing));
207
+ throw new Error(formatModelProfileCredentialError(profileLabel, strictMissing));
206
208
  }
207
209
 
208
210
  // Check alternative groups — at least one provider per group must be authenticated.
209
211
  for (const group of alternativeGroups) {
210
212
  const groupAuthenticated = group.some(p => authenticatedProviders.includes(p));
211
213
  if (!groupAuthenticated) {
212
- throw new Error(formatModelProfileCredentialError(options.profileName, [...group]));
214
+ throw new Error(formatModelProfileCredentialError(profileLabel, [...group]));
213
215
  }
214
216
  }
215
217
 
216
218
  if (authenticatedProviders.length === 0) {
217
- throw new Error(formatModelProfileCredentialError(options.profileName, missingProviders));
219
+ throw new Error(formatModelProfileCredentialError(profileLabel, missingProviders));
218
220
  }
219
221
 
220
222
  const availableModels = options.modelRegistry.getAll();
@@ -229,9 +231,7 @@ export async function prepareModelProfileActivation(
229
231
  })
230
232
  : undefined;
231
233
  if (bindings.defaultSelector && !resolvedDefault?.model) {
232
- throw new Error(
233
- `Model profile "${options.profileName}" default selector did not resolve: ${bindings.defaultSelector}`,
234
- );
234
+ throw new Error(`Model profile "${profileLabel}" default selector did not resolve: ${bindings.defaultSelector}`);
235
235
  }
236
236
 
237
237
  const modelRoles: Record<string, string> = {};
@@ -241,7 +241,7 @@ export async function prepareModelProfileActivation(
241
241
  modelRegistry: options.modelRegistry,
242
242
  });
243
243
  if (!resolved.model) {
244
- throw new Error(`Model profile "${options.profileName}" ${role} selector did not resolve: ${selector}`);
244
+ throw new Error(`Model profile "${profileLabel}" ${role} selector did not resolve: ${selector}`);
245
245
  }
246
246
  modelRoles[role] = formatClampedModelSelector(selector, resolved.model);
247
247
  }
@@ -256,7 +256,7 @@ export async function prepareModelProfileActivation(
256
256
  modelRegistry: options.modelRegistry,
257
257
  });
258
258
  if (!resolved.model) {
259
- throw new Error(`Model profile "${options.profileName}" ${role} selector did not resolve: ${selector}`);
259
+ throw new Error(`Model profile "${profileLabel}" ${role} selector did not resolve: ${selector}`);
260
260
  }
261
261
  agentModelOverrides[role] = formatClampedModelSelector(selector, resolved.model);
262
262
  }
@@ -357,6 +357,87 @@ export async function applyPreparedModelProfileActivation(
357
357
  }
358
358
  }
359
359
 
360
+ export interface MaterializeModelProfileForDeletionResult {
361
+ modelRoles: Record<string, string>;
362
+ agentModelOverrides: Record<string, string>;
363
+ previousModelRoles: Record<string, string>;
364
+ previousAgentModelOverrides: Record<string, string>;
365
+ previousDefaultProfile: string | undefined;
366
+ previousPersistedDefaultProfile: string | undefined;
367
+ previousActiveModelProfile: string | undefined;
368
+ }
369
+
370
+ export async function materializeModelProfileForDeletion(
371
+ options: PrepareModelProfileActivationOptions & {
372
+ settings: Pick<Settings, "clearOverride" | "flush" | "get" | "getGlobal" | "override" | "set">;
373
+ },
374
+ ): Promise<MaterializeModelProfileForDeletionResult> {
375
+ const prepared = await prepareModelProfileActivation(options);
376
+ const previousDefaultProfile = prepared.settings.get("modelProfile.default");
377
+ const previousPersistedDefaultProfile = prepared.settings.getGlobal("modelProfile.default");
378
+ const nextModelRoles = {
379
+ ...prepared.previousModelRoles,
380
+ ...(prepared.defaultModel
381
+ ? {
382
+ default: formatModelSelectorValue(
383
+ `${prepared.defaultModel.provider}/${prepared.defaultModel.id}`,
384
+ prepared.defaultThinkingLevel,
385
+ ),
386
+ }
387
+ : {}),
388
+ ...prepared.modelRoles,
389
+ };
390
+ const nextAgentModelOverrides = {
391
+ ...prepared.previousAgentModelOverrides,
392
+ ...prepared.agentModelOverrides,
393
+ };
394
+
395
+ try {
396
+ prepared.settings.set("modelRoles", nextModelRoles);
397
+ prepared.settings.set("task.agentModelOverrides", nextAgentModelOverrides);
398
+ prepared.settings.set("modelProfile.default", undefined);
399
+ prepared.settings.clearOverride("modelProfile.default");
400
+ prepared.settings.override("modelRoles", nextModelRoles);
401
+ prepared.settings.override("task.agentModelOverrides", nextAgentModelOverrides);
402
+ prepared.session.setActiveModelProfile?.(undefined);
403
+ await prepared.settings.flush();
404
+ } catch (error) {
405
+ prepared.settings.set("modelRoles", prepared.previousModelRoles);
406
+ prepared.settings.set("task.agentModelOverrides", prepared.previousAgentModelOverrides);
407
+ prepared.settings.set("modelProfile.default", previousPersistedDefaultProfile);
408
+ prepared.settings.override("modelRoles", prepared.previousModelRoles);
409
+ prepared.settings.override("task.agentModelOverrides", prepared.previousAgentModelOverrides);
410
+ prepared.settings.override("modelProfile.default", previousDefaultProfile);
411
+ prepared.session.setActiveModelProfile?.(prepared.previousActiveModelProfile);
412
+ throw error;
413
+ }
414
+
415
+ return {
416
+ modelRoles: nextModelRoles,
417
+ agentModelOverrides: nextAgentModelOverrides,
418
+ previousModelRoles: prepared.previousModelRoles,
419
+ previousAgentModelOverrides: prepared.previousAgentModelOverrides,
420
+ previousDefaultProfile,
421
+ previousPersistedDefaultProfile,
422
+ previousActiveModelProfile: prepared.previousActiveModelProfile,
423
+ };
424
+ }
425
+
426
+ export async function restoreMaterializedModelProfileForDeletion(options: {
427
+ settings: Pick<Settings, "flush" | "override" | "set">;
428
+ session: Pick<ModelProfileActivationSession, "setActiveModelProfile">;
429
+ snapshot: MaterializeModelProfileForDeletionResult;
430
+ }): Promise<void> {
431
+ options.settings.set("modelRoles", options.snapshot.previousModelRoles);
432
+ options.settings.set("task.agentModelOverrides", options.snapshot.previousAgentModelOverrides);
433
+ options.settings.set("modelProfile.default", options.snapshot.previousPersistedDefaultProfile);
434
+ options.settings.override("modelRoles", options.snapshot.previousModelRoles);
435
+ options.settings.override("task.agentModelOverrides", options.snapshot.previousAgentModelOverrides);
436
+ options.settings.override("modelProfile.default", options.snapshot.previousDefaultProfile);
437
+ options.session.setActiveModelProfile?.(options.snapshot.previousActiveModelProfile);
438
+ await options.settings.flush();
439
+ }
440
+
360
441
  export async function activateModelProfile(
361
442
  options: PrepareModelProfileActivationOptions,
362
443
  applyOptions: { persistDefault?: boolean } = {},
@@ -1,3 +1,4 @@
1
+ import { sanitizeText } from "@gajae-code/utils";
1
2
  import type { GjcModelAssignmentTargetId } from "./model-registry";
2
3
  import type { ModelsConfig } from "./models-config-schema";
3
4
 
@@ -98,7 +99,7 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
98
99
  }),
99
100
  profile("claude-opus", ["anthropic"], {
100
101
  default: "anthropic/claude-opus-4-8:xhigh",
101
- executor: "anthropic/claude-sonnet-4-6",
102
+ executor: "anthropic/claude-sonnet-5",
102
103
  planner: "anthropic/claude-opus-4-8:low",
103
104
  critic: "anthropic/claude-opus-4-8:high",
104
105
  architect: "anthropic/claude-opus-4-8:xhigh",
@@ -267,6 +268,10 @@ export interface ModelProfilePresentation {
267
268
  providerGroup: string;
268
269
  }
269
270
 
271
+ function sanitizeModelProfileLabel(value: string): string {
272
+ return sanitizeText(value).replace(/\s+/g, " ").trim();
273
+ }
274
+
270
275
  const PROFILE_PRESENTATION: Record<string, ModelProfilePresentation> = {
271
276
  "codex-eco": { displayName: "Codex Eco", providerGroup: "CODEX" },
272
277
  "codex-medium": { displayName: "Codex Medium", providerGroup: "CODEX" },
@@ -332,7 +337,15 @@ export function getModelProfilePresentation(
332
337
  const displayName = typeof profile === "string" ? undefined : profile.displayName;
333
338
  const presentation = PROFILE_PRESENTATION[name];
334
339
  if (presentation) return presentation;
335
- return { displayName: displayName ?? name, providerGroup: "CUSTOM" };
340
+ return { displayName: formatModelProfileDisplayLabel({ name, displayName }), providerGroup: "CUSTOM" };
341
+ }
342
+
343
+ export function formatModelProfileDisplayLabel(profile: Pick<ModelProfileDefinition, "name" | "displayName">): string {
344
+ return (
345
+ sanitizeModelProfileLabel(profile.displayName ?? profile.name) ||
346
+ sanitizeModelProfileLabel(profile.name) ||
347
+ "Unnamed profile"
348
+ );
336
349
  }
337
350
 
338
351
  export function groupModelProfilesForPresetLanding(