@gajae-code/coding-agent 0.2.3 → 0.2.5

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 (197) hide show
  1. package/CHANGELOG.md +34 -8600
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +61 -0
  4. package/dist/types/cli/update-cli.d.ts +3 -0
  5. package/dist/types/config/settings-schema.d.ts +27 -3
  6. package/dist/types/config/settings.d.ts +1 -1
  7. package/dist/types/defaults/gjc-defaults.d.ts +19 -6
  8. package/dist/types/discovery/helpers.d.ts +1 -0
  9. package/dist/types/exec/bash-executor.d.ts +8 -1
  10. package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +2 -0
  11. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  12. package/dist/types/modes/components/settings-selector.d.ts +4 -0
  13. package/dist/types/modes/components/skill-hud/render.d.ts +1 -1
  14. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +2 -0
  16. package/dist/types/modes/theme/defaults/index.d.ts +45 -9351
  17. package/dist/types/modes/theme/theme.d.ts +6 -5
  18. package/dist/types/modes/types.d.ts +2 -0
  19. package/dist/types/sdk.d.ts +2 -0
  20. package/dist/types/session/streaming-output.d.ts +11 -0
  21. package/dist/types/skill-state/active-state.d.ts +1 -0
  22. package/dist/types/task/types.d.ts +1 -0
  23. package/dist/types/tools/bash-allowed-prefixes.d.ts +5 -0
  24. package/dist/types/tools/bash.d.ts +24 -0
  25. package/dist/types/tools/cron.d.ts +110 -0
  26. package/dist/types/tools/index.d.ts +4 -0
  27. package/dist/types/tools/monitor.d.ts +54 -0
  28. package/dist/types/web/search/index.d.ts +1 -0
  29. package/dist/types/web/search/provider.d.ts +11 -4
  30. package/dist/types/web/search/providers/duckduckgo.d.ts +57 -0
  31. package/dist/types/web/search/types.d.ts +1 -1
  32. package/package.json +7 -7
  33. package/src/async/job-manager.ts +224 -0
  34. package/src/cli/agents-cli.ts +3 -0
  35. package/src/cli/update-cli.ts +67 -16
  36. package/src/config/settings-schema.ts +30 -2
  37. package/src/config/settings.ts +44 -7
  38. package/src/defaults/gjc/skills/deep-interview/SKILL.md +48 -6
  39. package/src/defaults/gjc/skills/deep-interview/auto-answer-uncertain.md +37 -0
  40. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +42 -0
  41. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  42. package/src/defaults/gjc/skills/ultragoal/SKILL.md +9 -6
  43. package/src/defaults/gjc-defaults.ts +68 -16
  44. package/src/discovery/helpers.ts +5 -0
  45. package/src/eval/js/shared/rewrite-imports.ts +1 -2
  46. package/src/exec/bash-executor.ts +20 -9
  47. package/src/gjc-runtime/deep-interview-runtime.ts +44 -0
  48. package/src/gjc-runtime/ralplan-runtime.ts +2 -0
  49. package/src/gjc-runtime/restricted-role-agent-bash.ts +5 -0
  50. package/src/gjc-runtime/state-runtime.ts +3 -2
  51. package/src/goals/tools/goal-tool.ts +5 -1
  52. package/src/hooks/skill-state.ts +1 -1
  53. package/src/internal-urls/docs-index.generated.ts +8 -4
  54. package/src/lsp/render.ts +1 -1
  55. package/src/memories/index.ts +5 -4
  56. package/src/modes/acp/acp-agent.ts +1 -1
  57. package/src/modes/acp/acp-client-bridge.ts +1 -1
  58. package/src/modes/components/agent-dashboard.ts +1 -1
  59. package/src/modes/components/diff.ts +2 -2
  60. package/src/modes/components/settings-selector.ts +25 -14
  61. package/src/modes/components/skill-hud/render.ts +7 -2
  62. package/src/modes/controllers/command-controller.ts +1 -1
  63. package/src/modes/controllers/input-controller.ts +10 -2
  64. package/src/modes/controllers/selector-controller.ts +67 -0
  65. package/src/modes/interactive-mode.ts +34 -3
  66. package/src/modes/theme/defaults/blue-crab.json +126 -0
  67. package/src/modes/theme/defaults/index.ts +2 -196
  68. package/src/modes/theme/theme.ts +75 -36
  69. package/src/modes/types.ts +2 -0
  70. package/src/prompts/agents/architect.md +5 -1
  71. package/src/prompts/agents/critic.md +5 -1
  72. package/src/prompts/agents/frontmatter.md +1 -0
  73. package/src/prompts/agents/planner.md +5 -1
  74. package/src/prompts/memories/unavailable.md +9 -0
  75. package/src/prompts/tools/bash.md +9 -0
  76. package/src/prompts/tools/cron.md +25 -0
  77. package/src/prompts/tools/monitor.md +30 -0
  78. package/src/runtime-mcp/oauth-flow.ts +4 -2
  79. package/src/sdk.ts +7 -0
  80. package/src/session/agent-session.ts +16 -5
  81. package/src/session/streaming-output.ts +21 -0
  82. package/src/skill-state/active-state.ts +163 -12
  83. package/src/slash-commands/builtin-registry.ts +11 -1
  84. package/src/task/agents.ts +1 -0
  85. package/src/task/executor.ts +1 -0
  86. package/src/task/types.ts +1 -0
  87. package/src/tools/bash-allowed-prefixes.ts +169 -0
  88. package/src/tools/bash.ts +190 -29
  89. package/src/tools/browser/tab-worker.ts +1 -1
  90. package/src/tools/cron.ts +665 -0
  91. package/src/tools/index.ts +20 -2
  92. package/src/tools/monitor.ts +136 -0
  93. package/src/vim/engine.ts +3 -3
  94. package/src/web/search/index.ts +31 -18
  95. package/src/web/search/provider.ts +57 -12
  96. package/src/web/search/providers/duckduckgo.ts +279 -0
  97. package/src/web/search/types.ts +2 -0
  98. package/src/modes/theme/dark.json +0 -95
  99. package/src/modes/theme/defaults/alabaster.json +0 -93
  100. package/src/modes/theme/defaults/amethyst.json +0 -96
  101. package/src/modes/theme/defaults/anthracite.json +0 -93
  102. package/src/modes/theme/defaults/basalt.json +0 -91
  103. package/src/modes/theme/defaults/birch.json +0 -95
  104. package/src/modes/theme/defaults/dark-abyss.json +0 -91
  105. package/src/modes/theme/defaults/dark-arctic.json +0 -104
  106. package/src/modes/theme/defaults/dark-aurora.json +0 -95
  107. package/src/modes/theme/defaults/dark-catppuccin.json +0 -107
  108. package/src/modes/theme/defaults/dark-cavern.json +0 -91
  109. package/src/modes/theme/defaults/dark-copper.json +0 -95
  110. package/src/modes/theme/defaults/dark-cosmos.json +0 -90
  111. package/src/modes/theme/defaults/dark-cyberpunk.json +0 -102
  112. package/src/modes/theme/defaults/dark-dracula.json +0 -98
  113. package/src/modes/theme/defaults/dark-eclipse.json +0 -91
  114. package/src/modes/theme/defaults/dark-ember.json +0 -95
  115. package/src/modes/theme/defaults/dark-equinox.json +0 -90
  116. package/src/modes/theme/defaults/dark-forest.json +0 -96
  117. package/src/modes/theme/defaults/dark-github.json +0 -105
  118. package/src/modes/theme/defaults/dark-gruvbox.json +0 -112
  119. package/src/modes/theme/defaults/dark-lavender.json +0 -95
  120. package/src/modes/theme/defaults/dark-lunar.json +0 -89
  121. package/src/modes/theme/defaults/dark-midnight.json +0 -95
  122. package/src/modes/theme/defaults/dark-monochrome.json +0 -94
  123. package/src/modes/theme/defaults/dark-monokai.json +0 -98
  124. package/src/modes/theme/defaults/dark-nebula.json +0 -90
  125. package/src/modes/theme/defaults/dark-nord.json +0 -97
  126. package/src/modes/theme/defaults/dark-ocean.json +0 -101
  127. package/src/modes/theme/defaults/dark-one.json +0 -100
  128. package/src/modes/theme/defaults/dark-poimandres.json +0 -141
  129. package/src/modes/theme/defaults/dark-rainforest.json +0 -91
  130. package/src/modes/theme/defaults/dark-reef.json +0 -91
  131. package/src/modes/theme/defaults/dark-retro.json +0 -92
  132. package/src/modes/theme/defaults/dark-rose-pine.json +0 -96
  133. package/src/modes/theme/defaults/dark-sakura.json +0 -95
  134. package/src/modes/theme/defaults/dark-slate.json +0 -95
  135. package/src/modes/theme/defaults/dark-solarized.json +0 -97
  136. package/src/modes/theme/defaults/dark-solstice.json +0 -90
  137. package/src/modes/theme/defaults/dark-starfall.json +0 -91
  138. package/src/modes/theme/defaults/dark-sunset.json +0 -99
  139. package/src/modes/theme/defaults/dark-swamp.json +0 -90
  140. package/src/modes/theme/defaults/dark-synthwave.json +0 -103
  141. package/src/modes/theme/defaults/dark-taiga.json +0 -91
  142. package/src/modes/theme/defaults/dark-terminal.json +0 -95
  143. package/src/modes/theme/defaults/dark-tokyo-night.json +0 -101
  144. package/src/modes/theme/defaults/dark-tundra.json +0 -91
  145. package/src/modes/theme/defaults/dark-twilight.json +0 -91
  146. package/src/modes/theme/defaults/dark-volcanic.json +0 -91
  147. package/src/modes/theme/defaults/graphite.json +0 -92
  148. package/src/modes/theme/defaults/light-arctic.json +0 -107
  149. package/src/modes/theme/defaults/light-aurora-day.json +0 -91
  150. package/src/modes/theme/defaults/light-canyon.json +0 -91
  151. package/src/modes/theme/defaults/light-catppuccin.json +0 -106
  152. package/src/modes/theme/defaults/light-cirrus.json +0 -90
  153. package/src/modes/theme/defaults/light-coral.json +0 -95
  154. package/src/modes/theme/defaults/light-cyberpunk.json +0 -96
  155. package/src/modes/theme/defaults/light-dawn.json +0 -90
  156. package/src/modes/theme/defaults/light-dunes.json +0 -91
  157. package/src/modes/theme/defaults/light-eucalyptus.json +0 -95
  158. package/src/modes/theme/defaults/light-forest.json +0 -100
  159. package/src/modes/theme/defaults/light-frost.json +0 -95
  160. package/src/modes/theme/defaults/light-github.json +0 -115
  161. package/src/modes/theme/defaults/light-glacier.json +0 -91
  162. package/src/modes/theme/defaults/light-gruvbox.json +0 -108
  163. package/src/modes/theme/defaults/light-haze.json +0 -90
  164. package/src/modes/theme/defaults/light-honeycomb.json +0 -95
  165. package/src/modes/theme/defaults/light-lagoon.json +0 -91
  166. package/src/modes/theme/defaults/light-lavender.json +0 -95
  167. package/src/modes/theme/defaults/light-meadow.json +0 -91
  168. package/src/modes/theme/defaults/light-mint.json +0 -95
  169. package/src/modes/theme/defaults/light-monochrome.json +0 -101
  170. package/src/modes/theme/defaults/light-ocean.json +0 -99
  171. package/src/modes/theme/defaults/light-one.json +0 -99
  172. package/src/modes/theme/defaults/light-opal.json +0 -91
  173. package/src/modes/theme/defaults/light-orchard.json +0 -91
  174. package/src/modes/theme/defaults/light-paper.json +0 -95
  175. package/src/modes/theme/defaults/light-poimandres.json +0 -141
  176. package/src/modes/theme/defaults/light-prism.json +0 -90
  177. package/src/modes/theme/defaults/light-retro.json +0 -98
  178. package/src/modes/theme/defaults/light-sand.json +0 -95
  179. package/src/modes/theme/defaults/light-savanna.json +0 -91
  180. package/src/modes/theme/defaults/light-solarized.json +0 -102
  181. package/src/modes/theme/defaults/light-soleil.json +0 -90
  182. package/src/modes/theme/defaults/light-sunset.json +0 -99
  183. package/src/modes/theme/defaults/light-synthwave.json +0 -98
  184. package/src/modes/theme/defaults/light-tokyo-night.json +0 -111
  185. package/src/modes/theme/defaults/light-wetland.json +0 -91
  186. package/src/modes/theme/defaults/light-zenith.json +0 -89
  187. package/src/modes/theme/defaults/limestone.json +0 -94
  188. package/src/modes/theme/defaults/mahogany.json +0 -97
  189. package/src/modes/theme/defaults/marble.json +0 -93
  190. package/src/modes/theme/defaults/obsidian.json +0 -91
  191. package/src/modes/theme/defaults/onyx.json +0 -91
  192. package/src/modes/theme/defaults/pearl.json +0 -93
  193. package/src/modes/theme/defaults/porcelain.json +0 -91
  194. package/src/modes/theme/defaults/quartz.json +0 -96
  195. package/src/modes/theme/defaults/sandstone.json +0 -95
  196. package/src/modes/theme/defaults/titanium.json +0 -90
  197. package/src/modes/theme/light.json +0 -93
@@ -78,6 +78,56 @@ export interface AsyncJobFilter {
78
78
  ownerId?: string;
79
79
  }
80
80
 
81
+ function sliceTextFromUtf8ByteOffset(text: string, offsetBytes: number): string {
82
+ if (offsetBytes <= 0) return text;
83
+ let consumedBytes = 0;
84
+ let codeUnitIndex = 0;
85
+ for (const char of text) {
86
+ const charBytes = Buffer.byteLength(char, "utf8");
87
+ if (consumedBytes + charBytes > offsetBytes) break;
88
+ consumedBytes += charBytes;
89
+ codeUnitIndex += char.length;
90
+ }
91
+ return text.slice(codeUnitIndex);
92
+ }
93
+
94
+ /**
95
+ * A slice of process-stream output for a background job, as recorded by
96
+ * `appendOutput` / read by `readOutputSince`.
97
+ *
98
+ * The cursor model is monotonic UTF-8 byte offsets. `nextOffset` is the offset
99
+ * to pass to the next read to receive only fresh bytes; `startOffset` is the
100
+ * first byte the manager still retains for this job. When the requested offset
101
+ * is older than `startOffset`, the manager returns the retained tail and sets
102
+ * `truncated: true`.
103
+ */
104
+ export interface AsyncJobOutputSlice {
105
+ jobId: string;
106
+ status: AsyncJob["status"];
107
+ text: string;
108
+ startOffset: number;
109
+ nextOffset: number;
110
+ truncated: boolean;
111
+ }
112
+
113
+ /** Internal: a single chunk of captured stdout/stderr keyed by its byte range. */
114
+ interface AsyncJobOutputChunk {
115
+ startByte: number;
116
+ endByte: number;
117
+ text: string;
118
+ }
119
+
120
+ interface AsyncJobOutputState {
121
+ chunks: AsyncJobOutputChunk[];
122
+ startOffset: number;
123
+ nextOffset: number;
124
+ retainedBytes: number;
125
+ }
126
+
127
+ /** Default retention cap for per-job captured output. ~512 KiB matches the
128
+ * bash tail-buffer order of magnitude without dominating session memory. */
129
+ export const DEFAULT_JOB_OUTPUT_RETENTION_BYTES = 512 * 1024;
130
+
81
131
  export class AsyncJobManager {
82
132
  static #instance: AsyncJobManager | undefined;
83
133
 
@@ -102,6 +152,9 @@ export class AsyncJobManager {
102
152
  readonly #suppressedDeliveries = new Set<string>();
103
153
  readonly #watchedJobs = new Set<string>();
104
154
  readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
155
+ readonly #outputState = new Map<string, AsyncJobOutputState>();
156
+ readonly #ownerCleanups = new Map<string, Set<() => void>>();
157
+ readonly #outputRetentionBytes = DEFAULT_JOB_OUTPUT_RETENTION_BYTES;
105
158
  readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
106
159
  readonly #maxRunningJobs: number;
107
160
  readonly #retentionMs: number;
@@ -237,6 +290,169 @@ export class AsyncJobManager {
237
290
  return this.#filterJobs(this.#jobs.values(), filter);
238
291
  }
239
292
 
293
+ /**
294
+ * Append a sanitized process-stream chunk for a background job. Called from
295
+ * the unthrottled bash-executor capture hook (`onRawChunk`) so monitor sees
296
+ * every chunk even when preview/progress callbacks are throttled.
297
+ *
298
+ * Offsets are in UTF-8 bytes. Storing chunk metadata avoids unsafe byte
299
+ * slicing across multibyte characters at read time. The retention window is
300
+ * a per-job rolling cap (`DEFAULT_JOB_OUTPUT_RETENTION_BYTES`); when it
301
+ * overflows, oldest whole chunks are evicted and `startOffset` advances —
302
+ * subsequent reads from a stale offset get `truncated: true`.
303
+ */
304
+ appendOutput(jobId: string, chunk: string): void {
305
+ if (this.#disposed) return;
306
+ if (!chunk) return;
307
+ if (!this.#jobs.has(jobId)) return;
308
+
309
+ const state = this.#outputState.get(jobId) ?? {
310
+ chunks: [],
311
+ startOffset: 0,
312
+ nextOffset: 0,
313
+ retainedBytes: 0,
314
+ };
315
+
316
+ const byteLength = Buffer.byteLength(chunk, "utf8");
317
+ if (byteLength === 0) return;
318
+
319
+ const startByte = state.nextOffset;
320
+ const endByte = startByte + byteLength;
321
+ state.chunks.push({ startByte, endByte, text: chunk });
322
+ state.retainedBytes += byteLength;
323
+ state.nextOffset = endByte;
324
+
325
+ while (state.retainedBytes > this.#outputRetentionBytes && state.chunks.length > 0) {
326
+ const dropped = state.chunks.shift();
327
+ if (!dropped) break;
328
+ const droppedBytes = dropped.endByte - dropped.startByte;
329
+ state.retainedBytes -= droppedBytes;
330
+ state.startOffset = dropped.endByte;
331
+ }
332
+
333
+ this.#outputState.set(jobId, state);
334
+ }
335
+
336
+ /**
337
+ * Read fresh process-stream output for a job since `offset` (in UTF-8
338
+ * bytes). Returns `undefined` when the job does not exist or when an
339
+ * `ownerId` filter is set and the job belongs to a different owner — this
340
+ * mirrors the manager-level "not found" pattern used by `cancel`.
341
+ *
342
+ * - `offset < startOffset` returns the retained tail with `truncated: true`.
343
+ * - `offset > nextOffset` clamps to `nextOffset` and returns an empty text
344
+ * slice with `truncated: false`.
345
+ * - Assembled text slices the leading retained chunk at a UTF-8 codepoint
346
+ * boundary when needed, so multibyte characters cannot be split.
347
+ */
348
+ readOutputSince(jobId: string, offset: number, filter?: AsyncJobFilter): AsyncJobOutputSlice | undefined {
349
+ const job = this.#jobs.get(jobId);
350
+ if (!job) return undefined;
351
+ if (filter?.ownerId && job.ownerId !== filter.ownerId) return undefined;
352
+
353
+ const state = this.#outputState.get(jobId);
354
+ if (!state) {
355
+ return {
356
+ jobId,
357
+ status: job.status,
358
+ text: "",
359
+ startOffset: 0,
360
+ nextOffset: 0,
361
+ truncated: false,
362
+ };
363
+ }
364
+
365
+ const requestedOffset = Math.max(0, Math.floor(offset));
366
+ if (requestedOffset >= state.nextOffset) {
367
+ return {
368
+ jobId,
369
+ status: job.status,
370
+ text: "",
371
+ startOffset: state.startOffset,
372
+ nextOffset: state.nextOffset,
373
+ truncated: false,
374
+ };
375
+ }
376
+
377
+ const truncated = requestedOffset < state.startOffset;
378
+ const effectiveOffset = truncated ? state.startOffset : requestedOffset;
379
+ const parts: string[] = [];
380
+ for (const chunk of state.chunks) {
381
+ if (chunk.endByte <= effectiveOffset) continue;
382
+ if (effectiveOffset > chunk.startByte) {
383
+ parts.push(sliceTextFromUtf8ByteOffset(chunk.text, effectiveOffset - chunk.startByte));
384
+ continue;
385
+ }
386
+ parts.push(chunk.text);
387
+ }
388
+
389
+ return {
390
+ jobId,
391
+ status: job.status,
392
+ text: parts.join(""),
393
+ startOffset: state.startOffset,
394
+ nextOffset: state.nextOffset,
395
+ truncated,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Register an owner-scoped cleanup callback. Returns an unregister function.
401
+ *
402
+ * Used by Cron* tools to clear session-scoped timers when the owning agent
403
+ * is torn down. Invoked by `runOwnerCleanups({ ownerId })` before
404
+ * `cancelAll({ ownerId })` so timers cannot register new jobs during
405
+ * teardown.
406
+ */
407
+ registerOwnerCleanup(ownerId: string, cleanup: () => void): () => void {
408
+ if (!ownerId) {
409
+ throw new Error("registerOwnerCleanup requires a non-empty ownerId");
410
+ }
411
+ let bag = this.#ownerCleanups.get(ownerId);
412
+ if (!bag) {
413
+ bag = new Set();
414
+ this.#ownerCleanups.set(ownerId, bag);
415
+ }
416
+ bag.add(cleanup);
417
+ return () => {
418
+ const current = this.#ownerCleanups.get(ownerId);
419
+ if (!current) return;
420
+ current.delete(cleanup);
421
+ if (current.size === 0) this.#ownerCleanups.delete(ownerId);
422
+ };
423
+ }
424
+
425
+ /**
426
+ * Run and clear every registered cleanup for the given filter. Idempotent
427
+ * and error-isolated: a throwing cleanup does not prevent siblings from
428
+ * running and never escalates to the caller.
429
+ */
430
+ runOwnerCleanups(filter?: AsyncJobFilter): void {
431
+ const ownerId = filter?.ownerId;
432
+ const targets: Array<[string, Set<() => void>]> = [];
433
+ if (ownerId) {
434
+ const bag = this.#ownerCleanups.get(ownerId);
435
+ if (bag) targets.push([ownerId, bag]);
436
+ } else {
437
+ for (const entry of this.#ownerCleanups.entries()) targets.push(entry);
438
+ }
439
+ for (const [id, bag] of targets) {
440
+ const callbacks = Array.from(bag);
441
+ bag.clear();
442
+ this.#ownerCleanups.delete(id);
443
+ for (const cleanup of callbacks) {
444
+ try {
445
+ cleanup();
446
+ } catch (error) {
447
+ logger.warn("Async job owner cleanup failed", {
448
+ ownerId: id,
449
+ error: error instanceof Error ? error.message : String(error),
450
+ });
451
+ }
452
+ }
453
+ }
454
+ }
455
+
240
456
  getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState {
241
457
  const deliveries = this.#filterDeliveries(filter);
242
458
  const inFlightDeliveries = this.#filterInFlightDeliveries(filter);
@@ -357,6 +573,10 @@ export class AsyncJobManager {
357
573
  async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
358
574
  this.#disposed = true;
359
575
  this.#clearEvictionTimers();
576
+ // Run-and-clear any remaining owner cleanups before tearing down jobs so
577
+ // late-arriving timers cannot register fresh work against a disposed
578
+ // manager. Errors in cleanup callbacks are logged but never escalated.
579
+ this.runOwnerCleanups();
360
580
  this.cancelAll();
361
581
  await this.waitForAll();
362
582
  const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
@@ -366,6 +586,8 @@ export class AsyncJobManager {
366
586
  this.#inFlightDeliveries.length = 0;
367
587
  this.#suppressedDeliveries.clear();
368
588
  this.#watchedJobs.clear();
589
+ this.#outputState.clear();
590
+ this.#ownerCleanups.clear();
369
591
  return drained;
370
592
  }
371
593
 
@@ -399,6 +621,7 @@ export class AsyncJobManager {
399
621
  this.#jobs.delete(jobId);
400
622
  this.#suppressedDeliveries.delete(jobId);
401
623
  this.#watchedJobs.delete(jobId);
624
+ this.#outputState.delete(jobId);
402
625
  return;
403
626
  }
404
627
  const existing = this.#evictionTimers.get(jobId);
@@ -410,6 +633,7 @@ export class AsyncJobManager {
410
633
  this.#jobs.delete(jobId);
411
634
  this.#suppressedDeliveries.delete(jobId);
412
635
  this.#watchedJobs.delete(jobId);
636
+ this.#outputState.delete(jobId);
413
637
  }, this.#retentionMs);
414
638
  timer.unref();
415
639
  this.#evictionTimers.set(jobId, timer);
@@ -64,6 +64,9 @@ function toFrontmatter(agent: AgentDefinition): Record<string, unknown> {
64
64
  if (agent.thinkingLevel) frontmatter.thinkingLevel = agent.thinkingLevel;
65
65
  if (agent.output !== undefined) frontmatter.output = agent.output;
66
66
  if (agent.blocking) frontmatter.blocking = true;
67
+ if (agent.bashAllowedPrefixes && agent.bashAllowedPrefixes.length > 0) {
68
+ frontmatter.bashAllowedPrefixes = agent.bashAllowedPrefixes;
69
+ }
67
70
 
68
71
  return frontmatter;
69
72
  }
@@ -12,7 +12,7 @@ import { $ } from "bun";
12
12
  import chalk from "chalk";
13
13
  import { theme } from "../modes/theme/theme";
14
14
 
15
- const REPO = "can1357/gajae-code";
15
+ const RELEASE_REPO = "Yeachan-Heo/gajae-code";
16
16
  const PACKAGE = "@gajae-code/coding-agent";
17
17
 
18
18
  interface ReleaseInfo {
@@ -122,7 +122,7 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
122
122
 
123
123
  if (bunBinDir) return { method: "bun" };
124
124
 
125
- throw new Error(`Could not resolve ${APP_NAME} binary path in PATH`);
125
+ throw new Error(formatUnsupportedTargetMessage(`Could not resolve ${APP_NAME} binary path in PATH`));
126
126
  }
127
127
 
128
128
  /**
@@ -166,10 +166,7 @@ function compareVersions(a: string, b: string): number {
166
166
  /**
167
167
  * Get the appropriate binary name for this platform.
168
168
  */
169
- function getBinaryName(): string {
170
- const platform = process.platform;
171
- const arch = process.arch;
172
-
169
+ function getBinaryName(platform: NodeJS.Platform = process.platform, arch: string = process.arch): string {
173
170
  let os: string;
174
171
  switch (platform) {
175
172
  case "linux":
@@ -182,7 +179,7 @@ function getBinaryName(): string {
182
179
  os = "windows";
183
180
  break;
184
181
  default:
185
- throw new Error(`Unsupported platform: ${platform}`);
182
+ throw new Error(formatUnsupportedTargetMessage(`Unsupported platform: ${platform}`));
186
183
  }
187
184
 
188
185
  let archName: string;
@@ -194,7 +191,7 @@ function getBinaryName(): string {
194
191
  archName = "arm64";
195
192
  break;
196
193
  default:
197
- throw new Error(`Unsupported architecture: ${arch}`);
194
+ throw new Error(formatUnsupportedTargetMessage(`Unsupported architecture: ${arch}`));
198
195
  }
199
196
 
200
197
  if (os === "windows") {
@@ -233,6 +230,65 @@ function printVerifiedVersion(expectedVersion: string): void {
233
230
  console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
234
231
  }
235
232
 
233
+ function formatBinaryInstallInstruction(platform: NodeJS.Platform = process.platform): string {
234
+ if (platform === "win32") {
235
+ return `For a supported binary install, reinstall with PowerShell: irm https://raw.githubusercontent.com/${RELEASE_REPO}/main/scripts/install.ps1 | iex`;
236
+ }
237
+ return `For a supported binary install, reinstall with: curl -fsSL https://raw.githubusercontent.com/${RELEASE_REPO}/main/scripts/install.sh | sh -s -- --binary`;
238
+ }
239
+
240
+ function formatManualUpdateInstructions(platform: NodeJS.Platform = process.platform): string {
241
+ return [
242
+ `If ${APP_NAME} was installed with Bun, run: bun install -g ${PACKAGE}@latest`,
243
+ `If ${APP_NAME} was installed with npm, pnpm, or another package manager, update it with that same manager.`,
244
+ formatBinaryInstallInstruction(platform),
245
+ ].join("\n");
246
+ }
247
+
248
+ function formatUnsupportedTargetMessage(reason: string, platform: NodeJS.Platform = process.platform): string {
249
+ return `${reason}.\n${formatManualUpdateInstructions(platform)}`;
250
+ }
251
+
252
+ function buildReleaseBinaryUrl(
253
+ version: string,
254
+ platform: NodeJS.Platform = process.platform,
255
+ arch: string = process.arch,
256
+ ): string {
257
+ const binaryName = getBinaryName(platform, arch);
258
+ const tag = `v${version}`;
259
+ return `https://github.com/${RELEASE_REPO}/releases/download/${tag}/${binaryName}`;
260
+ }
261
+
262
+ function formatBinaryDownloadFailureMessage(
263
+ binaryName: string,
264
+ url: string,
265
+ status: string | number,
266
+ platform: NodeJS.Platform = process.platform,
267
+ ): string {
268
+ return `Download failed for ${binaryName} from ${url}: ${status}.\n${formatManualUpdateInstructions(platform)}`;
269
+ }
270
+
271
+ export function formatBinaryDownloadFailureMessageForTest(
272
+ binaryName: string,
273
+ url: string,
274
+ status: string | number,
275
+ platform: NodeJS.Platform = process.platform,
276
+ ): string {
277
+ return formatBinaryDownloadFailureMessage(binaryName, url, status, platform);
278
+ }
279
+
280
+ export function buildReleaseBinaryUrlForTest(
281
+ version: string,
282
+ platform: NodeJS.Platform = process.platform,
283
+ arch: string = process.arch,
284
+ ): string {
285
+ return buildReleaseBinaryUrl(version, platform, arch);
286
+ }
287
+
288
+ export function formatManualUpdateInstructionsForTest(platform: NodeJS.Platform = process.platform): string {
289
+ return formatManualUpdateInstructions(platform);
290
+ }
291
+
236
292
  function formatVerificationFailure(result: InstalledVersionVerification, expectedVersion: string): string {
237
293
  if (result.actual) {
238
294
  return `${APP_NAME} at ${result.path} still reports ${result.actual} (expected ${expectedVersion})`;
@@ -250,11 +306,7 @@ async function printVerification(expectedVersion: string): Promise<void> {
250
306
  return;
251
307
  }
252
308
  console.log(chalk.yellow(`\nWarning: ${formatVerificationFailure(result, expectedVersion)}`));
253
- console.log(
254
- chalk.yellow(
255
- `You may need to reinstall: curl -fsSL https://raw.githubusercontent.com/can1357/gajae-code/main/scripts/install.sh | sh`,
256
- ),
257
- );
309
+ console.log(chalk.yellow(formatManualUpdateInstructions()));
258
310
  }
259
311
 
260
312
  async function unlinkIfExists(filePath: string): Promise<void> {
@@ -314,8 +366,7 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
314
366
  */
315
367
  async function updateViaBinaryAt(targetPath: string, expectedVersion: string): Promise<void> {
316
368
  const binaryName = getBinaryName();
317
- const tag = `v${expectedVersion}`;
318
- const url = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
369
+ const url = buildReleaseBinaryUrl(expectedVersion);
319
370
 
320
371
  const tempPath = `${targetPath}.new`;
321
372
  const backupPath = `${targetPath}.bak`;
@@ -323,7 +374,7 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
323
374
 
324
375
  const response = await fetch(url, { redirect: "follow" });
325
376
  if (!response.ok || !response.body) {
326
- throw new Error(`Download failed: ${response.statusText}`);
377
+ throw new Error(formatBinaryDownloadFailureMessage(binaryName, url, response.statusText || response.status));
327
378
  }
328
379
  const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
329
380
  await pipeline(response.body, fileStream);
@@ -337,7 +337,7 @@ export const SETTINGS_SCHEMA = {
337
337
 
338
338
  "theme.light": {
339
339
  type: "string",
340
- default: "light",
340
+ default: "blue-crab",
341
341
  ui: {
342
342
  tab: "appearance",
343
343
  label: "Light Theme",
@@ -843,6 +843,26 @@ export const SETTINGS_SCHEMA = {
843
843
  "Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows).",
844
844
  },
845
845
  },
846
+ "retry.requestMaxRetries": {
847
+ type: "number",
848
+ default: 5,
849
+ ui: {
850
+ tab: "model",
851
+ label: "Provider Request Retries",
852
+ description:
853
+ "Maximum provider request retries before a stream is established. Counts retries, not the first attempt. Set to 0 to disable provider request retries.",
854
+ },
855
+ },
856
+ "retry.streamMaxRetries": {
857
+ type: "number",
858
+ default: 5,
859
+ ui: {
860
+ tab: "model",
861
+ label: "Provider Stream Retries",
862
+ description:
863
+ "Maximum provider stream replay retries for replay-safe transient stream failures. Counts retries, not the first attempt. Set to 0 to disable provider stream retries.",
864
+ },
865
+ },
846
866
  "retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
847
867
  "retry.fallbackRevertPolicy": {
848
868
  type: "enum",
@@ -2510,6 +2530,7 @@ export const SETTINGS_SCHEMA = {
2510
2530
  type: "enum",
2511
2531
  values: [
2512
2532
  "auto",
2533
+ "duckduckgo",
2513
2534
  "exa",
2514
2535
  "brave",
2515
2536
  "jina",
@@ -2534,7 +2555,12 @@ export const SETTINGS_SCHEMA = {
2534
2555
  {
2535
2556
  value: "auto",
2536
2557
  label: "Auto",
2537
- description: "Preferred web-search provider",
2558
+ description: "Active model's native search if its creds exist, else keyless DuckDuckGo",
2559
+ },
2560
+ {
2561
+ value: "duckduckgo",
2562
+ label: "DuckDuckGo",
2563
+ description: "Keyless default — no API key or OAuth required",
2538
2564
  },
2539
2565
  { value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set" },
2540
2566
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
@@ -2833,6 +2859,8 @@ export interface RetrySettings {
2833
2859
  maxRetries: number;
2834
2860
  baseDelayMs: number;
2835
2861
  maxDelayMs: number;
2862
+ requestMaxRetries: number;
2863
+ streamMaxRetries: number;
2836
2864
  }
2837
2865
 
2838
2866
  export interface MemoriesSettings {
@@ -5,7 +5,7 @@
5
5
  * import { settings } from "./settings";
6
6
  *
7
7
  * const enabled = settings.get("compaction.enabled"); // sync read
8
- * settings.set("theme.dark", "titanium"); // sync write, saves in background
8
+ * settings.set("theme.dark", "red-claw"); // sync write, saves in background
9
9
  *
10
10
  * For tests:
11
11
  * const isolated = Settings.isolated({ "compaction.enabled": false });
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import {
18
18
  getAgentDbPath,
19
19
  getAgentDir,
20
+ getCustomThemesDir,
20
21
  getProjectDir,
21
22
  isEnoent,
22
23
  logger,
@@ -100,6 +101,14 @@ function setByPath(obj: RawSettings, segments: string[], value: unknown): void {
100
101
  }
101
102
 
102
103
  const PATH_SCOPED_ARRAY_SETTINGS = new Set<SettingPath>(["enabledModels", "disabledProviders"]);
104
+ const LEGACY_THEME_NAME_REPLACEMENTS = {
105
+ dark: "red-claw",
106
+ light: "blue-crab",
107
+ } as const;
108
+
109
+ function isLegacyThemeName(name: string): name is keyof typeof LEGACY_THEME_NAME_REPLACEMENTS {
110
+ return name === "dark" || name === "light";
111
+ }
103
112
 
104
113
  type PathScopedStringArrayEntry = {
105
114
  path?: unknown;
@@ -587,6 +596,25 @@ export class Settings {
587
596
  }
588
597
  }
589
598
 
599
+ #hasCustomThemeFile(name: string): boolean {
600
+ try {
601
+ return fs.existsSync(path.join(getCustomThemesDir(this.#agentDir), `${name}.json`));
602
+ } catch {
603
+ return false;
604
+ }
605
+ }
606
+
607
+ #migrateLegacyBuiltInThemeName(name: string): string {
608
+ if (isLegacyThemeName(name) && !this.#hasCustomThemeFile(name)) {
609
+ return LEGACY_THEME_NAME_REPLACEMENTS[name];
610
+ }
611
+ return name;
612
+ }
613
+
614
+ #getThemeSlotForName(name: string): "dark" | "light" {
615
+ return isLightTheme(name, this.#agentDir) ? "light" : "dark";
616
+ }
617
+
590
618
  /** Apply schema migrations to raw settings */
591
619
  #migrateRawSettings(raw: RawSettings): RawSettings {
592
620
  // queueMode -> steeringMode
@@ -606,13 +634,22 @@ export class Settings {
606
634
  // Migrate old flat "theme" string to nested theme.dark/theme.light
607
635
  if (typeof raw.theme === "string") {
608
636
  const oldTheme = raw.theme;
609
- if (oldTheme === "light" || oldTheme === "dark") {
610
- // Built-in defaults just remove, let new defaults apply
611
- delete raw.theme;
637
+ const migratedTheme = this.#migrateLegacyBuiltInThemeName(oldTheme);
638
+ if (oldTheme === "dark" && migratedTheme === "red-claw") {
639
+ raw.theme = { dark: migratedTheme };
640
+ } else if (oldTheme === "light" && migratedTheme === "blue-crab") {
641
+ raw.theme = { light: migratedTheme };
612
642
  } else {
613
- // Custom theme — detect luminance to place in correct slot
614
- const slot = isLightTheme(oldTheme) ? "light" : "dark";
615
- raw.theme = { [slot]: oldTheme };
643
+ const slot = this.#getThemeSlotForName(migratedTheme);
644
+ raw.theme = { [slot]: migratedTheme };
645
+ }
646
+ } else if (raw.theme && typeof raw.theme === "object" && !Array.isArray(raw.theme)) {
647
+ const themeObj = raw.theme as Record<string, unknown>;
648
+ if (typeof themeObj.dark === "string") {
649
+ themeObj.dark = this.#migrateLegacyBuiltInThemeName(themeObj.dark);
650
+ }
651
+ if (typeof themeObj.light === "string") {
652
+ themeObj.light = this.#migrateLegacyBuiltInThemeName(themeObj.light);
616
653
  }
617
654
  }
618
655