@gajae-code/coding-agent 0.4.1 → 0.4.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 (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/async/job-manager.d.ts +25 -0
  3. package/dist/types/commands/ultragoal.d.ts +1 -0
  4. package/dist/types/commit/model-selection.d.ts +1 -1
  5. package/dist/types/config/model-registry.d.ts +3 -1
  6. package/dist/types/config/model-resolver.d.ts +1 -19
  7. package/dist/types/config/models-config-schema.d.ts +12 -0
  8. package/dist/types/config/settings-schema.d.ts +26 -4
  9. package/dist/types/gjc-runtime/goal-mode-request.d.ts +8 -1
  10. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  12. package/dist/types/harness-control-plane/finalize.d.ts +8 -0
  13. package/dist/types/harness-control-plane/receipts.d.ts +16 -1
  14. package/dist/types/harness-control-plane/types.d.ts +16 -3
  15. package/dist/types/modes/acp/acp-event-mapper.d.ts +2 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  17. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +18 -0
  18. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +84 -0
  19. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +14 -7
  20. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +37 -0
  21. package/dist/types/modes/shared/agent-wire/protocol.d.ts +13 -34
  22. package/dist/types/reminders/star-reminder.d.ts +115 -0
  23. package/dist/types/session/agent-session.d.ts +30 -1
  24. package/dist/types/session/session-manager.d.ts +1 -1
  25. package/dist/types/tools/bash.d.ts +2 -0
  26. package/dist/types/tools/browser/actions.d.ts +54 -0
  27. package/dist/types/tools/browser.d.ts +80 -0
  28. package/dist/types/tools/image-gen.d.ts +1 -0
  29. package/dist/types/tools/index.d.ts +3 -1
  30. package/dist/types/tools/job.d.ts +1 -1
  31. package/examples/extensions/README.md +20 -41
  32. package/package.json +7 -7
  33. package/src/async/job-manager.ts +120 -1
  34. package/src/cli/grep-cli.ts +1 -1
  35. package/src/commands/harness.ts +42 -3
  36. package/src/commands/ultragoal.ts +8 -1
  37. package/src/commit/agentic/index.ts +2 -2
  38. package/src/commit/model-selection.ts +7 -22
  39. package/src/commit/pipeline.ts +2 -2
  40. package/src/config/model-registry.ts +17 -9
  41. package/src/config/model-resolver.ts +14 -84
  42. package/src/config/models-config-schema.ts +2 -0
  43. package/src/config/settings-schema.ts +27 -4
  44. package/src/defaults/gjc/skills/team/SKILL.md +10 -1
  45. package/src/defaults/gjc/skills/ultragoal/SKILL.md +3 -2
  46. package/src/gjc-runtime/goal-mode-request.ts +21 -1
  47. package/src/gjc-runtime/launch-tmux.ts +25 -2
  48. package/src/gjc-runtime/team-runtime.ts +78 -3
  49. package/src/gjc-runtime/ultragoal-guard.ts +18 -2
  50. package/src/gjc-runtime/ultragoal-runtime.ts +240 -30
  51. package/src/harness-control-plane/finalize.ts +84 -0
  52. package/src/harness-control-plane/owner.ts +16 -3
  53. package/src/harness-control-plane/receipts.ts +39 -1
  54. package/src/harness-control-plane/rpc-adapter.ts +7 -1
  55. package/src/harness-control-plane/types.ts +33 -12
  56. package/src/internal-urls/docs-index.generated.ts +3 -3
  57. package/src/memories/index.ts +1 -1
  58. package/src/modes/acp/acp-agent.ts +17 -9
  59. package/src/modes/acp/acp-event-mapper.ts +33 -1
  60. package/src/modes/components/custom-editor.ts +19 -3
  61. package/src/modes/controllers/input-controller.ts +27 -7
  62. package/src/modes/controllers/selector-controller.ts +7 -1
  63. package/src/modes/interactive-mode.ts +29 -1
  64. package/src/modes/rpc/rpc-client.ts +16 -3
  65. package/src/modes/rpc/rpc-mode.ts +5 -2
  66. package/src/modes/shared/agent-wire/command-contract.ts +18 -0
  67. package/src/modes/shared/agent-wire/event-contract.ts +147 -0
  68. package/src/modes/shared/agent-wire/event-envelope.ts +35 -16
  69. package/src/modes/shared/agent-wire/event-observation.ts +397 -0
  70. package/src/modes/shared/agent-wire/protocol.ts +24 -81
  71. package/src/modes/utils/context-usage.ts +2 -2
  72. package/src/prompts/agents/explore.md +1 -1
  73. package/src/prompts/agents/plan.md +1 -1
  74. package/src/prompts/agents/reviewer.md +1 -1
  75. package/src/prompts/tools/browser.md +3 -2
  76. package/src/reminders/star-reminder.ts +422 -0
  77. package/src/runtime-mcp/manager.ts +15 -2
  78. package/src/sdk.ts +3 -1
  79. package/src/session/agent-session.ts +139 -17
  80. package/src/session/session-manager.ts +1 -1
  81. package/src/task/agents.ts +1 -1
  82. package/src/tools/bash.ts +6 -1
  83. package/src/tools/browser/actions.ts +189 -0
  84. package/src/tools/browser.ts +91 -1
  85. package/src/tools/image-gen.ts +42 -15
  86. package/src/tools/index.ts +7 -1
  87. package/src/tools/inspect-image.ts +10 -8
  88. package/src/tools/job.ts +12 -2
  89. package/src/tools/monitor.ts +98 -17
  90. package/src/utils/commit-message-generator.ts +6 -13
  91. package/src/utils/title-generator.ts +1 -1
  92. package/dist/types/harness-control-plane/frame-mapper.d.ts +0 -29
  93. package/src/harness-control-plane/frame-mapper.ts +0 -286
  94. package/src/priority.json +0 -37
@@ -6,6 +6,7 @@ const DELIVERY_RETRY_MAX_MS = 30_000;
6
6
  const DELIVERY_RETRY_JITTER_MS = 200;
7
7
  const DEFAULT_RETENTION_MS = 5 * 60 * 1000;
8
8
  const DEFAULT_MAX_RUNNING_JOBS = 15;
9
+ const MONITOR_TOMBSTONE_TTL_MS = 5 * 60_000;
9
10
 
10
11
  export interface AsyncJob {
11
12
  id: string;
@@ -120,6 +121,27 @@ export interface AsyncJobDeliveryState {
120
121
  pendingJobIds: string[];
121
122
  }
122
123
 
124
+ export interface AsyncJobLifecycleCleanup {
125
+ onCancel?: (job: AsyncJob) => void;
126
+ onTerminal?: (job: AsyncJob) => void;
127
+ onEvict?: (job: AsyncJob) => void;
128
+ /**
129
+ * Idempotent residual cleanup invoked by a post-eviction tombstone purge
130
+ * (e.g. a late `job cancel` after the job left the registry). Kept distinct
131
+ * from the at-most-once lifecycle phases so a tombstone purge never has to
132
+ * re-invoke a phase hook. Must be safe to call repeatedly.
133
+ */
134
+ onTombstonePurge?: (job: AsyncJob) => void;
135
+ }
136
+
137
+ export interface MonitorTombstone {
138
+ jobId: string;
139
+ ownerId?: string;
140
+ status: AsyncJob["status"];
141
+ expiresAt: number;
142
+ purge: () => unknown;
143
+ }
144
+
123
145
  export interface AsyncJobRegisterOptions {
124
146
  id?: string;
125
147
  /** Registry id of the agent that owns this job; used to scope cancelAll. */
@@ -127,6 +149,7 @@ export interface AsyncJobRegisterOptions {
127
149
  /** Structured metadata for tool-specific control surfaces. */
128
150
  metadata?: AsyncJobMetadata;
129
151
  onProgress?: (text: string, details?: Record<string, unknown>) => void | Promise<void>;
152
+ lifecycle?: AsyncJobLifecycleCleanup;
130
153
  }
131
154
 
132
155
  /**
@@ -214,6 +237,9 @@ export class AsyncJobManager {
214
237
  readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
215
238
  readonly #outputState = new Map<string, AsyncJobOutputState>();
216
239
  readonly #ownerCleanups = new Map<string, Set<() => void>>();
240
+ readonly #lifecycles = new Map<string, AsyncJobLifecycleCleanup>();
241
+ readonly #lifecyclePhases = new Map<string, Set<"cancel" | "terminal" | "evict">>();
242
+ readonly #monitorTombstones = new Map<string, MonitorTombstone>();
217
243
  readonly #outputRetentionBytes = DEFAULT_JOB_OUTPUT_RETENTION_BYTES;
218
244
  readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
219
245
  readonly #maxRunningJobs: number;
@@ -292,6 +318,7 @@ export class AsyncJobManager {
292
318
  );
293
319
  }
294
320
 
321
+ this.#expireMonitorTombstones();
295
322
  const id = this.#resolveJobId(options?.id);
296
323
  this.#suppressedDeliveries.delete(id);
297
324
  const abortController = new AbortController();
@@ -308,6 +335,7 @@ export class AsyncJobManager {
308
335
  ownerId: options?.ownerId,
309
336
  metadata: options?.metadata,
310
337
  };
338
+ if (options?.lifecycle) this.#lifecycles.set(id, options.lifecycle);
311
339
 
312
340
  const reportProgress = async (text: string, details?: Record<string, unknown>): Promise<void> => {
313
341
  if (!options?.onProgress) return;
@@ -325,8 +353,10 @@ export class AsyncJobManager {
325
353
  const result = await run({ jobId: id, signal: abortController.signal, reportProgress });
326
354
  const outcome: SubagentRunOutcome =
327
355
  typeof result === "string" ? { kind: "completed", text: result } : result;
356
+
328
357
  if (job.status === "cancelled") {
329
358
  job.resultText = outcome.kind === "completed" ? outcome.text : outcome.note;
359
+ this.#runLifecycle(id, "terminal");
330
360
  this.#scheduleEviction(id);
331
361
  this.#markRecordTerminal(id, "cancelled");
332
362
  this.#drainResumeQueue();
@@ -342,20 +372,24 @@ export class AsyncJobManager {
342
372
  this.#drainResumeQueue();
343
373
  return;
344
374
  }
375
+
345
376
  job.status = "completed";
346
377
  job.resultText = outcome.text;
347
378
  this.#enqueueDelivery(id, outcome.text);
379
+ this.#runLifecycle(id, "terminal");
348
380
  this.#scheduleEviction(id);
349
381
  this.#markRecordTerminal(id, "completed");
350
382
  this.#drainResumeQueue();
351
383
  } catch (error) {
352
384
  if (job.status === "cancelled") {
353
385
  job.errorText = error instanceof Error ? error.message : String(error);
386
+ this.#runLifecycle(id, "terminal");
354
387
  this.#scheduleEviction(id);
355
388
  this.#markRecordTerminal(id, "cancelled");
356
389
  this.#drainResumeQueue();
357
390
  return;
358
391
  }
392
+ this.#runLifecycle(id, "terminal");
359
393
  const errorText = error instanceof Error ? error.message : String(error);
360
394
  job.status = "failed";
361
395
  job.errorText = errorText;
@@ -381,6 +415,7 @@ export class AsyncJobManager {
381
415
  if (!job) return false;
382
416
  if (filter?.ownerId && job.ownerId !== filter.ownerId) return false;
383
417
  if (job.status === "paused") {
418
+ this.#runLifecycle(id, "cancel");
384
419
  // Paused jobs have no running promise to abort; transition directly.
385
420
  // The session file is kept, so the record stays resumable by id.
386
421
  job.status = "cancelled";
@@ -390,12 +425,76 @@ export class AsyncJobManager {
390
425
  return true;
391
426
  }
392
427
  if (job.status !== "running") return false;
428
+ this.#runLifecycle(id, "cancel");
393
429
  job.status = "cancelled";
394
430
  job.abortController.abort();
395
- this.#scheduleEviction(id);
396
431
  return true;
397
432
  }
398
433
 
434
+ #runLifecycle(jobId: string, phase: "cancel" | "terminal" | "evict"): void {
435
+ const fired = this.#lifecyclePhases.get(jobId) ?? new Set<"cancel" | "terminal" | "evict">();
436
+ if (fired.has(phase)) return;
437
+ fired.add(phase);
438
+ this.#lifecyclePhases.set(jobId, fired);
439
+ const lifecycle = this.#lifecycles.get(jobId);
440
+ const job = this.#jobs.get(jobId);
441
+ if (!lifecycle || !job) return;
442
+ try {
443
+ if (phase === "cancel") lifecycle.onCancel?.(job);
444
+ else if (phase === "terminal") lifecycle.onTerminal?.(job);
445
+ else lifecycle.onEvict?.(job);
446
+ } catch (error) {
447
+ logger.warn("Async job lifecycle cleanup failed", {
448
+ jobId,
449
+ phase,
450
+ error: error instanceof Error ? error.message : String(error),
451
+ });
452
+ }
453
+ }
454
+
455
+ #expireMonitorTombstones(): void {
456
+ const now = Date.now();
457
+ for (const [jobId, tombstone] of this.#monitorTombstones) {
458
+ if (tombstone.expiresAt <= now) this.#monitorTombstones.delete(jobId);
459
+ }
460
+ }
461
+
462
+ #recordMonitorTombstone(jobId: string): void {
463
+ const job = this.#jobs.get(jobId);
464
+ if (!job?.metadata?.monitor) return;
465
+ const lifecycle = this.#lifecycles.get(jobId);
466
+ this.#monitorTombstones.set(jobId, {
467
+ jobId,
468
+ ownerId: job.ownerId,
469
+ status: job.status,
470
+ expiresAt: Date.now() + MONITOR_TOMBSTONE_TTL_MS,
471
+ purge: () => (lifecycle?.onTombstonePurge ?? lifecycle?.onEvict)?.(job),
472
+ });
473
+ }
474
+
475
+ getMonitorTombstone(jobId: string, filter?: AsyncJobFilter): MonitorTombstone | undefined {
476
+ this.#expireMonitorTombstones();
477
+ const tombstone = this.#monitorTombstones.get(jobId);
478
+ if (!tombstone) return undefined;
479
+ if (filter?.ownerId && tombstone.ownerId !== filter.ownerId) return undefined;
480
+ return tombstone;
481
+ }
482
+
483
+ purgeMonitorTombstone(jobId: string, filter?: AsyncJobFilter): { found: boolean; status?: AsyncJob["status"] } {
484
+ const tombstone = this.getMonitorTombstone(jobId, filter);
485
+ if (!tombstone) return { found: false };
486
+ this.#monitorTombstones.delete(jobId);
487
+ try {
488
+ tombstone.purge();
489
+ } catch (error) {
490
+ logger.warn("Monitor tombstone purge failed", {
491
+ jobId,
492
+ error: error instanceof Error ? error.message : String(error),
493
+ });
494
+ }
495
+ return { found: true, status: tombstone.status };
496
+ }
497
+
399
498
  // ── Subagent control plane (pause / resume / steer support) ──────────
400
499
 
401
500
  /** Register or replace the canonical record for a subagent. */
@@ -836,6 +935,7 @@ export class AsyncJobManager {
836
935
  */
837
936
  cancelAll(filter?: AsyncJobFilter): void {
838
937
  for (const job of this.getRunningJobs(filter)) {
938
+ this.#runLifecycle(job.id, "cancel");
839
939
  job.status = "cancelled";
840
940
  job.abortController.abort();
841
941
  this.#scheduleEviction(job.id);
@@ -898,6 +998,17 @@ export class AsyncJobManager {
898
998
  // manager. Errors in cleanup callbacks are logged but never escalated.
899
999
  this.runOwnerCleanups();
900
1000
  this.cancelAll();
1001
+ for (const tombstone of this.#monitorTombstones.values()) {
1002
+ try {
1003
+ tombstone.purge();
1004
+ } catch (error) {
1005
+ logger.warn("Monitor tombstone purge failed during dispose", {
1006
+ jobId: tombstone.jobId,
1007
+ error: error instanceof Error ? error.message : String(error),
1008
+ });
1009
+ }
1010
+ }
1011
+ this.#monitorTombstones.clear();
901
1012
  await this.waitForAll();
902
1013
  const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
903
1014
  this.#clearEvictionTimers();
@@ -945,7 +1056,11 @@ export class AsyncJobManager {
945
1056
  #scheduleEviction(jobId: string): void {
946
1057
  this.#notifyChange();
947
1058
  if (this.#retentionMs <= 0) {
1059
+ this.#recordMonitorTombstone(jobId);
1060
+ this.#runLifecycle(jobId, "evict");
948
1061
  this.#jobs.delete(jobId);
1062
+ this.#lifecycles.delete(jobId);
1063
+ this.#lifecyclePhases.delete(jobId);
949
1064
  this.#suppressedDeliveries.delete(jobId);
950
1065
  this.#watchedJobs.delete(jobId);
951
1066
  this.#outputState.delete(jobId);
@@ -957,7 +1072,11 @@ export class AsyncJobManager {
957
1072
  }
958
1073
  const timer = setTimeout(() => {
959
1074
  this.#evictionTimers.delete(jobId);
1075
+ this.#recordMonitorTombstone(jobId);
1076
+ this.#runLifecycle(jobId, "evict");
960
1077
  this.#jobs.delete(jobId);
1078
+ this.#lifecycles.delete(jobId);
1079
+ this.#lifecyclePhases.delete(jobId);
961
1080
  this.#suppressedDeliveries.delete(jobId);
962
1081
  this.#watchedJobs.delete(jobId);
963
1082
  this.#outputState.delete(jobId);
@@ -98,7 +98,7 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise<void> {
98
98
  console.log(chalk.green(`Files with matches: ${result.filesWithMatches}`));
99
99
  console.log(chalk.green(`Files searched: ${result.filesSearched}`));
100
100
  if (result.limitReached) {
101
- console.log(chalk.yellow(`Limit reached: true`));
101
+ console.log(chalk.yellow(`Result limit reached (${cmd.limit} matches). Use --limit to show more.`));
102
102
  }
103
103
  console.log("");
104
104
 
@@ -241,6 +241,12 @@ interface OwnerExitEvidence {
241
241
  lastSignal: string | null;
242
242
  promptAcceptedSeen: boolean;
243
243
  completedSeen: boolean;
244
+ /** True only when the owner is genuinely gone (lease missing or process dead). */
245
+ terminal: boolean;
246
+ /** True when the owner process is provably alive (live lease + fresh heartbeat) but the endpoint did not route. */
247
+ transient: boolean;
248
+ /** ISO timestamp of the most recent non-terminal RPC-derived owner event, if any (observability only). */
249
+ lastRpcActivityAt: string | null;
244
250
  }
245
251
 
246
252
  async function buildOwnerExitEvidence(root: string, state: SessionState): Promise<OwnerExitEvidence> {
@@ -251,12 +257,23 @@ async function buildOwnerExitEvidence(root: string, state: SessionState): Promis
251
257
  let lastSignal: string | null = null;
252
258
  let promptAcceptedSeen = false;
253
259
  let completedSeen = false;
260
+ let lastRpcActivityAt: string | null = null;
254
261
  for (const event of events) {
255
262
  const signal = (event.evidence as { signal?: unknown } | undefined)?.signal;
256
263
  if (typeof signal === "string") lastSignal = signal;
257
264
  if (event.kind === "prompt_accepted" || signal === "prompt-accepted") promptAcceptedSeen = true;
258
265
  if (event.kind === "rpc_agent_completed" || signal === "completed") completedSeen = true;
266
+ // Terminal completion/failure frames are NOT owner liveness — exclude them from activity.
267
+ if (event.kind.startsWith("rpc_") && event.kind !== "rpc_agent_completed" && event.kind !== "rpc_agent_failed") {
268
+ lastRpcActivityAt = event.createdAt;
269
+ }
259
270
  }
271
+ // Owner liveness is the lease heartbeat, never RPC frames: a "live" lease means the owner process
272
+ // is alive and heartbeating within TTL, so a failed endpoint call is a transient observation gap.
273
+ // Real owner loss (missing/dead lease) stays terminal and keeps its original reason string so
274
+ // existing consumers that match on the reason continue to escalate.
275
+ const terminal = !lease || leaseStatus === "dead";
276
+ const transient = leaseStatus === "live";
260
277
  let reason = "owner-not-live";
261
278
  if (!lease) {
262
279
  reason = promptAcceptedSeen && !completedSeen ? "owner-exited-after-prompt-acceptance" : "owner-lease-missing";
@@ -281,6 +298,9 @@ async function buildOwnerExitEvidence(root: string, state: SessionState): Promis
281
298
  lastSignal,
282
299
  promptAcceptedSeen,
283
300
  completedSeen,
301
+ terminal,
302
+ transient,
303
+ lastRpcActivityAt,
284
304
  };
285
305
  }
286
306
 
@@ -684,6 +704,7 @@ export default class Harness extends Command {
684
704
  const handle: SessionHandle = {
685
705
  sessionId,
686
706
  harness,
707
+ mode: input.mode === "review" || input.reviewOnly === true ? "review" : "implement",
687
708
  repo: typeof input.repo === "string" ? input.repo : null,
688
709
  workspace,
689
710
  branch: preflight.declaredBranch ?? preflight.actualBranch,
@@ -953,9 +974,27 @@ export default class Harness extends Command {
953
974
  observation: { ...observation, lifecycle: state.lifecycle },
954
975
  retryBudget: budget,
955
976
  });
956
- const vanishReceiptId = await writeVanishReceiptForDecision(root, state, observation, decision.classification);
977
+ // A session persisted as `started` whose owner was never spawned (no lease,
978
+ // no endpoint, no owner-run evidence) is not a vanish — it simply never had
979
+ // an owner. Bootstrap a fresh owner instead of deadlocking on the missing
980
+ // prior endpoint (which `start` without `--detach` never records).
981
+ const ownerNeverStarted =
982
+ state.lifecycle === "started" &&
983
+ !beforeExit.endpointPresent &&
984
+ !beforeExit.promptAcceptedSeen &&
985
+ !beforeExit.completedSeen &&
986
+ beforeExit.lastEventKind === null &&
987
+ observation.risk !== "deleted-worktree";
988
+ // Bootstrapping a never-started owner is not a vanish, so it needs no vanish receipt.
989
+ const vanishReceiptId = ownerNeverStarted
990
+ ? null
991
+ : await writeVanishReceiptForDecision(root, state, observation, decision.classification);
992
+ // A never-started owner has no in-flight work to preserve, so bootstrapping it does not
993
+ // depend on the vanish classifier's `ownerRequired` verdict — that gate exists to protect a
994
+ // vanished owner's worktree. Without this, a session started in a non-git workspace (git
995
+ // delta `unknown` → classifier `human-check` with `ownerRequired: false`) would stay stuck.
957
996
  const restoredOwner =
958
- decision.ownerRequired && beforeExit.endpointPresent
997
+ ownerNeverStarted || (decision.ownerRequired && beforeExit.endpointPresent)
959
998
  ? await this.#spawnDetachedOwner(root, sessionId, state.handle.workspace)
960
999
  : null;
961
1000
  if (restoredOwner?.live) {
@@ -968,7 +1007,7 @@ export default class Harness extends Command {
968
1007
  writeJson(
969
1008
  buildResponse(state, true, {
970
1009
  pending: false,
971
- restoredOwner: true,
1010
+ ...(ownerNeverStarted ? { bootstrappedOwner: true } : { restoredOwner: true }),
972
1011
  decision,
973
1012
  observation: { ...observation, lifecycle: state.lifecycle, ownerLive: true },
974
1013
  ownerExit: beforeExit,
@@ -1,6 +1,7 @@
1
1
  import { Command } from "@gajae-code/utils/cli";
2
2
  import {
3
3
  GJC_SESSION_FILE_ENV,
4
+ GJC_SESSION_ID_ENV,
4
5
  isUltragoalCreateGoalsInvocation,
5
6
  readUltragoalGjcObjective,
6
7
  writeCurrentSessionGoalModeState,
@@ -12,6 +13,7 @@ export default class Ultragoal extends Command {
12
13
  static description = "Run native GJC Ultragoal workflow commands";
13
14
  static strict = false;
14
15
  static examples = ["$ gjc ultragoal status --json"];
16
+ static delegateHelp = true;
15
17
 
16
18
  async run(): Promise<void> {
17
19
  const shouldActivateGoalMode = isUltragoalCreateGoalsInvocation(this.argv);
@@ -27,6 +29,11 @@ export default class Ultragoal extends Command {
27
29
  sessionFile: process.env[GJC_SESSION_FILE_ENV],
28
30
  objective,
29
31
  });
30
- await writePendingGoalModeRequest({ cwd, objective, goalsPath });
32
+ await writePendingGoalModeRequest({
33
+ cwd,
34
+ objective,
35
+ goalsPath,
36
+ sessionId: process.env[GJC_SESSION_ID_ENV],
37
+ });
31
38
  }
32
39
  }
@@ -5,7 +5,7 @@ import { applyChangelogProposals } from "../../commit/changelog";
5
5
  import { detectChangelogBoundaries } from "../../commit/changelog/detect";
6
6
  import { parseUnreleasedSection } from "../../commit/changelog/parse";
7
7
  import { formatCommitMessage } from "../../commit/message";
8
- import { resolvePrimaryModel, resolveSmolModel } from "../../commit/model-selection";
8
+ import { resolvePrimaryModel, resolveSecondaryCommitModel } from "../../commit/model-selection";
9
9
  import type { CommitCommandArgs, ConventionalAnalysis } from "../../commit/types";
10
10
  import { ModelRegistry } from "../../config/model-registry";
11
11
  import { Settings } from "../../config/settings";
@@ -47,7 +47,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
47
47
  const { model: primaryModel, apiKey: primaryApiKey } = primaryModelResult;
48
48
  process.stdout.write(` └─ ${primaryModel.name}\n`);
49
49
 
50
- const { model: agentModel, thinkingLevel: agentThinkingLevel } = await resolveSmolModel(
50
+ const { model: agentModel, thinkingLevel: agentThinkingLevel } = await resolveSecondaryCommitModel(
51
51
  settings,
52
52
  modelRegistry,
53
53
  primaryModel,
@@ -1,14 +1,7 @@
1
1
  import type { ThinkingLevel } from "@gajae-code/agent-core";
2
2
  import type { Api, Model } from "@gajae-code/ai";
3
- import { MODEL_ROLE_IDS } from "../config/model-registry";
4
- import {
5
- type ModelLookupRegistry,
6
- parseModelPattern,
7
- resolveModelRoleValue,
8
- resolveRoleSelection,
9
- } from "../config/model-resolver";
3
+ import { type ModelLookupRegistry, resolveModelRoleValue, resolveRoleSelection } from "../config/model-resolver";
10
4
  import type { Settings } from "../config/settings";
11
- import MODEL_PRIO from "../priority.json" with { type: "json" };
12
5
 
13
6
  export interface ResolvedCommitModel {
14
7
  model: Model<Api>;
@@ -29,7 +22,7 @@ export async function resolvePrimaryModel(
29
22
  const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
30
23
  const resolved = override
31
24
  ? resolveModelRoleValue(override, available, { settings, matchPreferences, modelRegistry })
32
- : resolveRoleSelection(["commit", "smol", ...MODEL_ROLE_IDS], settings, available, modelRegistry);
25
+ : resolveRoleSelection(["default"], settings, available, modelRegistry);
33
26
  const model = resolved?.model;
34
27
  if (!model) {
35
28
  throw new Error("No model available for commit generation");
@@ -41,25 +34,17 @@ export async function resolvePrimaryModel(
41
34
  return { model, apiKey, thinkingLevel: resolved?.thinkingLevel };
42
35
  }
43
36
 
44
- export async function resolveSmolModel(
37
+ export async function resolveSecondaryCommitModel(
45
38
  settings: Settings,
46
39
  modelRegistry: CommitModelRegistry,
47
40
  fallbackModel: Model<Api>,
48
41
  fallbackApiKey: string,
49
42
  ): Promise<ResolvedCommitModel> {
50
43
  const available = modelRegistry.getAvailable();
51
- const resolvedSmol = resolveRoleSelection(["smol"], settings, available, modelRegistry);
52
- if (resolvedSmol?.model) {
53
- const apiKey = await modelRegistry.getApiKey(resolvedSmol.model);
54
- if (apiKey) return { model: resolvedSmol.model, apiKey, thinkingLevel: resolvedSmol.thinkingLevel };
55
- }
56
-
57
- const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
58
- for (const pattern of MODEL_PRIO.smol) {
59
- const candidate = parseModelPattern(pattern, available, matchPreferences, { modelRegistry }).model;
60
- if (!candidate) continue;
61
- const apiKey = await modelRegistry.getApiKey(candidate);
62
- if (apiKey) return { model: candidate, apiKey };
44
+ const resolved = resolveRoleSelection(["default"], settings, available, modelRegistry);
45
+ if (resolved?.model) {
46
+ const apiKey = await modelRegistry.getApiKey(resolved.model);
47
+ if (apiKey) return { model: resolved.model, apiKey, thinkingLevel: resolved.thinkingLevel };
63
48
  }
64
49
 
65
50
  return { model: fallbackModel, apiKey: fallbackApiKey };
@@ -18,7 +18,7 @@ import {
18
18
  import { runChangelogFlow } from "./changelog";
19
19
  import { runMapReduceAnalysis, shouldUseMapReduce } from "./map-reduce";
20
20
  import { formatCommitMessage } from "./message";
21
- import { resolvePrimaryModel, resolveSmolModel } from "./model-selection";
21
+ import { resolvePrimaryModel, resolveSecondaryCommitModel } from "./model-selection";
22
22
  import summaryRetryPrompt from "./prompts/summary-retry.md" with { type: "text" };
23
23
  import typesDescriptionPrompt from "./prompts/types-description.md" with { type: "text" };
24
24
  import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
@@ -56,7 +56,7 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
56
56
  model: smolModel,
57
57
  apiKey: smolApiKey,
58
58
  thinkingLevel: smolThinkingLevel,
59
- } = await resolveSmolModel(settings, modelRegistry, primaryModel, primaryApiKey);
59
+ } = await resolveSecondaryCommitModel(settings, modelRegistry, primaryModel, primaryApiKey);
60
60
 
61
61
  let stagedFiles = await git.diff.changedFiles(cwd, { cached: true });
62
62
  if (stagedFiles.length === 0) {
@@ -62,7 +62,7 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st
62
62
  return Boolean(apiKey) && apiKey !== kNoAuth;
63
63
  }
64
64
 
65
- export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
65
+ export type ModelRole = "default";
66
66
 
67
67
  export interface ModelRoleInfo {
68
68
  tag?: string;
@@ -72,16 +72,9 @@ export interface ModelRoleInfo {
72
72
 
73
73
  export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
74
74
  default: { tag: "DEFAULT", name: "Default", color: "success" },
75
- smol: { tag: "SMOL", name: "Fast", color: "warning" },
76
- slow: { tag: "SLOW", name: "Thinking", color: "accent" },
77
- vision: { tag: "VISION", name: "Vision", color: "error" },
78
- plan: { tag: "PLAN", name: "Architect", color: "muted" },
79
- designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
80
- commit: { tag: "COMMIT", name: "Commit", color: "dim" },
81
- task: { tag: "TASK", name: "Subtask", color: "muted" },
82
75
  };
83
76
 
84
- export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"];
77
+ export const MODEL_ROLE_IDS: ModelRole[] = ["default"];
85
78
 
86
79
  export type GjcModelAssignmentTargetId = "default" | "executor" | "architect" | "planner" | "critic";
87
80
 
@@ -688,6 +681,7 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
688
681
  if (override.reasoning !== undefined) result.reasoning = override.reasoning;
689
682
  if (override.thinking !== undefined) result.thinking = override.thinking as ThinkingConfig;
690
683
  if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
684
+ if (override.output !== undefined) result.output = override.output as ("text" | "image")[];
691
685
  if (override.cacheRetention !== undefined) result.cacheRetention = override.cacheRetention;
692
686
  if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
693
687
  if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
@@ -718,6 +712,7 @@ interface CustomModelDefinitionLike {
718
712
  reasoning?: boolean;
719
713
  thinking?: ThinkingConfig;
720
714
  input?: ("text" | "image")[];
715
+ output?: ("text" | "image")[];
721
716
  cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
722
717
  contextWindow?: number;
723
718
  maxTokens?: number;
@@ -743,6 +738,7 @@ type CustomModelOverlay = {
743
738
  reasoning?: boolean;
744
739
  thinking?: ThinkingConfig;
745
740
  input?: ("text" | "image")[];
741
+ output?: ("text" | "image")[];
746
742
  cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
747
743
  contextWindow?: number;
748
744
  maxTokens?: number;
@@ -818,6 +814,7 @@ function buildCustomModelOverlay(
818
814
  reasoning: modelDef.reasoning,
819
815
  thinking: modelDef.thinking as ThinkingConfig | undefined,
820
816
  input: modelDef.input as ("text" | "image")[] | undefined,
817
+ output: modelDef.output as ("text" | "image")[] | undefined,
821
818
  cost: modelDef.cost,
822
819
  contextWindow: modelDef.contextWindow,
823
820
  maxTokens: modelDef.maxTokens,
@@ -910,6 +907,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
910
907
  reference?.cost ??
911
908
  (options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
912
909
  const input = resolvedModel.input ?? reference?.input ?? (options.useDefaults ? ["text"] : undefined);
910
+ const output = resolvedModel.output ?? reference?.output;
913
911
  return enrichModelThinking({
914
912
  id: resolvedModel.id,
915
913
  name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
@@ -919,6 +917,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
919
917
  reasoning: resolvedModel.reasoning ?? reference?.reasoning ?? (options.useDefaults ? false : undefined),
920
918
  thinking: resolvedModel.thinking ?? reference?.thinking,
921
919
  input: input as ("text" | "image")[],
920
+ output: output as ("text" | "image")[] | undefined,
922
921
  cost,
923
922
  contextWindow:
924
923
  resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : undefined),
@@ -1213,6 +1212,7 @@ export class ModelRegistry {
1213
1212
  reasoning: customModel.reasoning ?? existingModel.reasoning,
1214
1213
  thinking: customModel.thinking ?? existingModel.thinking,
1215
1214
  input: customModel.input ?? existingModel.input,
1215
+ output: customModel.output ?? existingModel.output,
1216
1216
  cost: customModel.cost ?? existingModel.cost,
1217
1217
  contextWindow: customModel.contextWindow ?? existingModel.contextWindow,
1218
1218
  maxTokens: customModel.maxTokens ?? existingModel.maxTokens,
@@ -2325,6 +2325,14 @@ export class ModelRegistry {
2325
2325
  fallback: 3,
2326
2326
  };
2327
2327
  return [...variants].sort((left, right) => {
2328
+ // Prefer vision-capable variants over configured provider order so an
2329
+ // ambiguous canonical id never resolves to a text-only namesake when a
2330
+ // vision-capable variant of the same id is available.
2331
+ const leftVision = left.model.input.includes("image") ? 0 : 1;
2332
+ const rightVision = right.model.input.includes("image") ? 0 : 1;
2333
+ if (leftVision !== rightVision) {
2334
+ return leftVision - rightVision;
2335
+ }
2328
2336
  const leftProviderRank = providerRank.get(left.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
2329
2337
  const rightProviderRank = providerRank.get(right.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
2330
2338
  if (leftProviderRank !== rightProviderRank) {