@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
@@ -11,6 +11,7 @@ import type { OAuthController, OAuthCredentials } from "@gajae-code/ai/utils/oau
11
11
 
12
12
  const DEFAULT_PORT = 3000;
13
13
  const CALLBACK_PATH = "/callback";
14
+ const CALLBACK_BIND_HOSTNAME = "127.0.0.1";
14
15
 
15
16
  function isLoopbackHostname(hostname: string): boolean {
16
17
  return hostname === "localhost" || hostname === "127.0.0.1";
@@ -42,7 +43,7 @@ function getUriPort(uri: URL): number {
42
43
 
43
44
  function validateRedirectConfig(config: MCPOAuthConfig, redirectUri: string | undefined): void {
44
45
  const parsed = parseRedirectUri(redirectUri);
45
- if (!parsed || parsed.protocol !== "https:" || !isLoopbackHostname(parsed.hostname)) {
46
+ if (parsed?.protocol !== "https:" || !isLoopbackHostname(parsed.hostname)) {
46
47
  return;
47
48
  }
48
49
 
@@ -63,7 +64,7 @@ function resolveCallbackPort(callbackPort: number | undefined, redirectUri: stri
63
64
  if (callbackPort !== undefined) return callbackPort;
64
65
 
65
66
  const parsed = parseRedirectUri(redirectUri);
66
- if (!parsed || parsed.protocol !== "http:" || !isLoopbackHostname(parsed.hostname)) {
67
+ if (parsed?.protocol !== "http:" || !isLoopbackHostname(parsed.hostname)) {
67
68
  return DEFAULT_PORT;
68
69
  }
69
70
 
@@ -93,6 +94,7 @@ function resolveCallbackOptions(config: MCPOAuthConfig): OAuthCallbackFlowOption
93
94
  preferredPort: resolveCallbackPort(config.callbackPort, redirectUri),
94
95
  callbackPath: resolveCallbackPath(config.callbackPath, redirectUri),
95
96
  callbackHostname: resolveCallbackHostname(redirectUri),
97
+ callbackBindHostname: CALLBACK_BIND_HOSTNAME,
96
98
  redirectUri,
97
99
  };
98
100
  }
package/src/sdk.ts CHANGED
@@ -294,6 +294,8 @@ export interface CreateAgentSessionOptions {
294
294
  agentId?: string;
295
295
  /** Display name for the agent in IRC. Default: "main" or "sub". */
296
296
  agentDisplayName?: string;
297
+ /** Optional restricted bash command prefixes for read-only role agents. */
298
+ bashAllowedPrefixes?: string[];
297
299
  /** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
298
300
  agentRegistry?: AgentRegistry;
299
301
  /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
@@ -1166,6 +1168,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1166
1168
  return agent?.state.model ?? model;
1167
1169
  },
1168
1170
  getAgentId: () => resolvedAgentId,
1171
+ bashAllowedPrefixes: options.bashAllowedPrefixes,
1169
1172
  getToolByName: name => session?.getToolByName(name),
1170
1173
  agentRegistry,
1171
1174
  getSessionSpawns: () => options.spawns ?? "*",
@@ -1732,6 +1735,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1732
1735
  const preferOpenAICodexWebsockets =
1733
1736
  openaiWebsocketSetting === "on" ? true : openaiWebsocketSetting === "off" ? false : undefined;
1734
1737
  const serviceTierSetting = settings.get("serviceTier");
1738
+ const retrySettings = settings.getGroup("retry");
1735
1739
 
1736
1740
  const initialServiceTier = hasServiceTierEntry
1737
1741
  ? existingSession.serviceTier
@@ -1789,6 +1793,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1789
1793
  repetitionPenalty: settings.get("repetitionPenalty") >= 0 ? settings.get("repetitionPenalty") : undefined,
1790
1794
  serviceTier: initialServiceTier,
1791
1795
  hideThinkingSummary: settings.get("hideThinkingBlock"),
1796
+ maxRetryDelayMs: retrySettings.maxDelayMs,
1797
+ requestMaxRetries: retrySettings.requestMaxRetries,
1798
+ streamMaxRetries: retrySettings.streamMaxRetries,
1792
1799
  kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
1793
1800
  preferWebsockets: preferOpenAICodexWebsockets,
1794
1801
  getToolContext: tc => toolContextStore.getContext(tc),
@@ -629,7 +629,11 @@ function createHandoffFileName(date = new Date()): string {
629
629
  // ============================================================================
630
630
 
631
631
  /** Tools that require user permission before execution when an ACP client is connected. */
632
- const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]);
632
+ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "monitor", "edit", "delete", "move"]);
633
+
634
+ function isShellExecutionPermissionTool(toolName: string): boolean {
635
+ return toolName === "bash" || toolName === "monitor";
636
+ }
633
637
 
634
638
  /** Permission options presented to the client on each gated tool call. */
635
639
  const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
@@ -694,7 +698,7 @@ function getPermissionIntent(
694
698
  args: unknown,
695
699
  ): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined {
696
700
  const a = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
697
- if (toolName === "bash") {
701
+ if (isShellExecutionPermissionTool(toolName)) {
698
702
  const cmd = getStringProperty(a, "command")?.slice(0, 80);
699
703
  return { toolName, title: cmd || toolName, cacheKey: toolName };
700
704
  }
@@ -1497,7 +1501,13 @@ export class AgentSession {
1497
1501
  */
1498
1502
  #cancelOwnAsyncJobs(): void {
1499
1503
  if (!this.#agentId) return;
1500
- AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
1504
+ const manager = AsyncJobManager.instance();
1505
+ if (!manager) return;
1506
+ // Run owner cleanups first so cron timers (and any other owner-scoped
1507
+ // resource cleanup) cannot register fresh jobs while we tear down the
1508
+ // existing ones. Cleanup callbacks are error-isolated inside the manager.
1509
+ manager.runOwnerCleanups({ ownerId: this.#agentId });
1510
+ manager.cancelAll({ ownerId: this.#agentId });
1501
1511
  }
1502
1512
 
1503
1513
  // =========================================================================
@@ -3395,8 +3405,9 @@ export class AgentSession {
3395
3405
  if (!permissionIntent) {
3396
3406
  return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
3397
3407
  }
3408
+ const isShellExecutionTool = isShellExecutionPermissionTool(target.name);
3398
3409
  const command =
3399
- target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
3410
+ isShellExecutionTool && args && typeof args === "object" && !Array.isArray(args)
3400
3411
  ? getStringProperty(args as Record<string, unknown>, "command")
3401
3412
  : undefined;
3402
3413
  const commandContent = command
@@ -3426,7 +3437,7 @@ export class AgentSession {
3426
3437
  toolCallId,
3427
3438
  toolName: target.name,
3428
3439
  title: permissionIntent.title,
3429
- ...(target.name === "bash" ? { kind: "execute" } : {}),
3440
+ ...(isShellExecutionTool ? { kind: "execute" } : {}),
3430
3441
  status: "pending",
3431
3442
  rawInput: args,
3432
3443
  ...(commandContent ? { content: commandContent } : {}),
@@ -58,6 +58,17 @@ export interface OutputSinkOptions {
58
58
  onChunk?: (chunk: string) => void;
59
59
  /** Minimum ms between onChunk calls. 0 = every chunk (default). */
60
60
  chunkThrottleMs?: number;
61
+ /**
62
+ * Unthrottled per-chunk callback fired *after* sanitization but *before*
63
+ * any throttle gating, column capping, or head/tail bookkeeping. Used by
64
+ * background-job substrate to record the complete process stream for the
65
+ * Monitor tool while keeping `onChunk` cheap for UI/progress.
66
+ *
67
+ * Receives the sanitized chunk verbatim; never receives the column-capped
68
+ * or minimized text. Implementations must be fast and side-effect-free
69
+ * relative to the sink (the sink does not catch errors from this callback).
70
+ */
71
+ onRawChunk?: (chunk: string) => void;
61
72
  }
62
73
 
63
74
  export interface TruncationResult {
@@ -672,6 +683,7 @@ export class OutputSink {
672
683
  readonly #spillThreshold: number;
673
684
  readonly #headLimit: number;
674
685
  readonly #onChunk?: (chunk: string) => void;
686
+ readonly #onRawChunk?: (chunk: string) => void;
675
687
  readonly #chunkThrottleMs: number;
676
688
  readonly #maxColumns: number;
677
689
 
@@ -684,6 +696,7 @@ export class OutputSink {
684
696
  maxColumns = 0,
685
697
  onChunk,
686
698
  chunkThrottleMs = 0,
699
+ onRawChunk,
687
700
  } = options ?? {};
688
701
  this.#artifactPath = artifactPath;
689
702
  this.#artifactId = artifactId;
@@ -691,6 +704,7 @@ export class OutputSink {
691
704
  this.#headLimit = Math.max(0, headBytes);
692
705
  this.#maxColumns = Math.max(0, maxColumns);
693
706
  this.#onChunk = onChunk;
707
+ this.#onRawChunk = onRawChunk;
694
708
  this.#chunkThrottleMs = chunkThrottleMs;
695
709
  }
696
710
 
@@ -701,6 +715,13 @@ export class OutputSink {
701
715
  push(chunk: string): void {
702
716
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
703
717
 
718
+ // Unthrottled raw-chunk hook fires before any throttle/cap gating so
719
+ // downstream consumers (e.g. AsyncJobManager.appendOutput) can record
720
+ // the complete process stream while UI/progress callbacks remain throttled.
721
+ if (this.#onRawChunk && chunk.length > 0) {
722
+ this.#onRawChunk(chunk);
723
+ }
724
+
704
725
  // Throttled onChunk: only call the callback when enough time has passed.
705
726
  // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
706
727
  // what reached the sink — the column cap is for the persisted LLM view.
@@ -327,11 +327,33 @@ async function readRawActiveStateForHandoff(filePath: string, strict: boolean):
327
327
  }
328
328
 
329
329
  function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] {
330
- if (!state || !Array.isArray(state.active_skills)) return [];
330
+ if (!state) return [];
331
331
  const out: SkillActiveEntry[] = [];
332
- for (const candidate of state.active_skills) {
333
- const normalized = normalizeEntry(candidate);
334
- if (normalized) out.push(normalized);
332
+ if (Array.isArray(state.active_skills)) {
333
+ for (const candidate of state.active_skills) {
334
+ const normalized = normalizeEntry(candidate);
335
+ if (normalized) out.push(normalized);
336
+ }
337
+ }
338
+ // Legacy top-level fallback: pre-`active_skills` state files persisted a single
339
+ // active workflow as top-level `{ active: true, skill, phase, … }` with no
340
+ // `active_skills` array. `normalizeSkillActiveState` still synthesizes that row,
341
+ // so the raw read used by the HUD, mutation guard, and caller inference must do
342
+ // the same or it would treat a legacy active workflow as absent.
343
+ if (out.length === 0 && state.active === true) {
344
+ const skill = safeString(state.skill).trim();
345
+ if (skill) {
346
+ out.push({
347
+ skill,
348
+ phase: safeString(state.phase).trim() || undefined,
349
+ active: true,
350
+ activated_at: safeString(state.activated_at).trim() || undefined,
351
+ updated_at: safeString(state.updated_at).trim() || undefined,
352
+ session_id: safeString(state.session_id).trim() || undefined,
353
+ thread_id: safeString(state.thread_id).trim() || undefined,
354
+ turn_id: safeString(state.turn_id).trim() || undefined,
355
+ });
356
+ }
335
357
  }
336
358
  return out;
337
359
  }
@@ -345,24 +367,139 @@ function filterRootEntriesForSession(entries: SkillActiveEntry[], sessionId?: st
345
367
  });
346
368
  }
347
369
 
370
+ function entryRecency(entry: SkillActiveEntry): number {
371
+ const stamp = entry.handoff_at || entry.updated_at || entry.activated_at;
372
+ const ms = stamp ? Date.parse(stamp) : Number.NaN;
373
+ // NaN signals "no trustworthy timestamp" so comparisons can refuse to let an
374
+ // unknown-recency row win a tie; callers must treat NaN explicitly.
375
+ return ms;
376
+ }
377
+
378
+ /**
379
+ * Session ownership rank for a row visible to a `sessionId` read. When a concrete
380
+ * session is in scope, a row owned by that exact session outranks a session-less
381
+ * fallback row, which outranks a foreign-session row. Session-less rows are global
382
+ * fallbacks and must never override a session's own state. With no scope session,
383
+ * every row ranks equally.
384
+ */
385
+ function sessionScopeRank(entry: SkillActiveEntry, sessionId?: string): number {
386
+ const scope = safeString(sessionId).trim();
387
+ if (!scope) return 0;
388
+ const entrySession = safeString(entry.session_id).trim();
389
+ if (entrySession === scope) return 2;
390
+ if (entrySession.length === 0) return 1;
391
+ return 0;
392
+ }
393
+
394
+ /**
395
+ * Pick the surviving row for a single skill within a session-scoped visible set.
396
+ * Precedence, highest first:
397
+ * 1. exact-session ownership over a session-less fallback row,
398
+ * 2. a strictly-newer valid timestamp,
399
+ * 3. a valid timestamp over a missing/unparseable one,
400
+ * 4. active over inactive — so an untrustworthy inactive row can never hide an
401
+ * active row — then merge order for a total tie.
402
+ * A genuine handoff demotion still supersedes a stale active row of the same skill
403
+ * because, within one session scope, it carries the newest valid timestamp.
404
+ */
405
+ function moreVisibleEntry(
406
+ incumbent: SkillActiveEntry,
407
+ challenger: SkillActiveEntry,
408
+ sessionId?: string,
409
+ ): SkillActiveEntry {
410
+ const scopeDelta = sessionScopeRank(incumbent, sessionId) - sessionScopeRank(challenger, sessionId);
411
+ if (scopeDelta !== 0) return scopeDelta > 0 ? incumbent : challenger;
412
+ const ri = entryRecency(incumbent);
413
+ const rc = entryRecency(challenger);
414
+ const vi = Number.isFinite(ri);
415
+ const vc = Number.isFinite(rc);
416
+ if (vi && vc && ri !== rc) return ri > rc ? incumbent : challenger;
417
+ if (vi !== vc) return vi ? incumbent : challenger;
418
+ const incumbentActive = incumbent.active !== false;
419
+ const challengerActive = challenger.active !== false;
420
+ if (incumbentActive !== challengerActive) return incumbentActive ? incumbent : challenger;
421
+ return incumbent;
422
+ }
423
+
424
+ /**
425
+ * Collapse the merged, session-scoped entries down to a single row per skill.
426
+ * A handed-off skill can leave more than one row visible to a session — e.g. a
427
+ * row seeded without a session id (rendered globally by
428
+ * `filterRootEntriesForSession`) plus a later, session-scoped handoff demotion
429
+ * of the same skill. Without this collapse the HUD renders the same workflow
430
+ * twice and keeps showing a skill that has already handed control to its
431
+ * successor. `moreVisibleEntry` picks the winner so a handoff demotion supersedes
432
+ * an older stale `active:true` row (and is then dropped by the active filter
433
+ * below) while a session's own active row is never hidden by a session-less or
434
+ * untrustworthy-timestamp row.
435
+ */
436
+ function dedupeVisibleBySkill(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] {
437
+ const winners = new Map<string, SkillActiveEntry>();
438
+ for (const entry of entries) {
439
+ const current = winners.get(entry.skill);
440
+ winners.set(entry.skill, current ? moreVisibleEntry(current, entry, sessionId) : entry);
441
+ }
442
+ return [...winners.values()];
443
+ }
444
+
445
+ /**
446
+ * The planning pipeline advances one stage at a time: `deep-interview →
447
+ * ralplan → ultragoal`. Each stage is activated through its own command path
448
+ * (`gjc deep-interview`, `gjc ralplan`, `gjc ultragoal`), and those activations
449
+ * do not demote the previous stage's row — only the explicit `handoff` verb
450
+ * does. Without this collapse, activating ultragoal while ralplan is still
451
+ * `active:true` would render both stages and keep showing a workflow that has
452
+ * already handed control forward. Keep only the most recently updated pipeline
453
+ * stage so the HUD reflects the single current workflow. `team` is intentionally
454
+ * excluded — it runs alongside ultragoal — and every non-pipeline skill is left
455
+ * untouched.
456
+ *
457
+ * This is a HUD-display policy only. It is applied by the skill HUD renderer and
458
+ * deliberately NOT folded into `readVisibleSkillActiveState`, whose callers (the
459
+ * deep-interview mutation guard and handoff caller inference) must keep seeing
460
+ * every genuinely-active skill rather than the single most-recent pipeline stage.
461
+ */
462
+ const PLANNING_PIPELINE_SKILLS = new Set<string>(["deep-interview", "ralplan", "ultragoal"]);
463
+
464
+ export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]): SkillActiveEntry[] {
465
+ const pipeline = entries.filter(entry => PLANNING_PIPELINE_SKILLS.has(entry.skill));
466
+ if (pipeline.length <= 1) return [...entries];
467
+ let current = pipeline[0];
468
+ let currentRecency = entryRecency(current);
469
+ for (const entry of pipeline) {
470
+ const recency = entryRecency(entry);
471
+ // Prefer a strictly-newer valid timestamp; a valid timestamp also beats a
472
+ // missing/unparseable one. Ties (or all-invalid) keep the first stage
473
+ // deterministically rather than letting an unknown-recency row win.
474
+ const better = Number.isFinite(recency) && (!Number.isFinite(currentRecency) || recency > currentRecency);
475
+ if (better) {
476
+ current = entry;
477
+ currentRecency = recency;
478
+ }
479
+ }
480
+ return entries.filter(entry => !PLANNING_PIPELINE_SKILLS.has(entry.skill) || entry === current);
481
+ }
482
+
348
483
  function mergeVisibleEntries(
349
484
  sessionState: SkillActiveState | null,
350
485
  rootState: SkillActiveState | null,
351
486
  sessionId?: string,
352
487
  ): SkillActiveEntry[] {
353
- const rootEntries = filterRootEntriesForSession(listActiveSkills(rootState), sessionId);
488
+ // Use the raw (active + inactive) rows so a handoff demotion stays visible
489
+ // long enough to supersede a stale same-skill row before the active filter.
490
+ const rootEntries = filterRootEntriesForSession(rawActiveEntries(rootState), sessionId);
354
491
  const merged = new Map(rootEntries.map(entry => [entryKey(entry), entry]));
355
- for (const entry of listActiveSkills(sessionState)) {
492
+ for (const entry of rawActiveEntries(sessionState)) {
356
493
  merged.set(entryKey(entry), entry);
357
494
  }
358
- return [...merged.values()];
495
+ return dedupeVisibleBySkill([...merged.values()], sessionId).filter(entry => entry.active !== false);
359
496
  }
360
497
 
361
498
  export async function readVisibleSkillActiveState(cwd: string, sessionId?: string): Promise<SkillActiveState | null> {
362
499
  const { rootPath, sessionPath } = getSkillActiveStatePaths(cwd, sessionId);
363
500
  const [rootState, sessionState] = await Promise.all([
364
- readStateFile(rootPath),
365
- sessionPath ? readStateFile(sessionPath) : Promise.resolve(null),
501
+ readRawActiveStateForHandoff(rootPath, false),
502
+ sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
366
503
  ]);
367
504
  const activeSkills = mergeVisibleEntries(sessionState, rootState, sessionId);
368
505
  if (activeSkills.length === 0) return null;
@@ -468,11 +605,25 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P
468
605
  const { rootPath, sessionPath } = getSkillActiveStatePaths(options.cwd, sessionId);
469
606
  const readState = (filePath: string) => readRawActiveStateForHandoff(filePath, options.strict === true);
470
607
 
608
+ // A skill can hold more than one visible row in this session's scope — e.g.
609
+ // it was seeded without a session id (rendered globally) and is now handed
610
+ // off under a concrete session id. Supersede every same-session-scope row of
611
+ // the caller and callee skills, not just the exact `skill::session_id` key,
612
+ // so a stale `active:true` row cannot survive the demotion and keep showing
613
+ // in the HUD. Rows owned by other sessions are left untouched.
614
+ const handoffSession = safeString(sessionId).trim();
615
+ const reassignedSkills = new Set([callerEntry.skill, calleeEntry.skill]);
616
+ const supersedesVisible = (entry: SkillActiveEntry): boolean => {
617
+ if (!reassignedSkills.has(entry.skill)) return false;
618
+ const entrySession = safeString(entry.session_id).trim();
619
+ return entrySession.length === 0 || entrySession === handoffSession;
620
+ };
471
621
  const applyEntries = (entries: SkillActiveEntry[]): SkillActiveEntry[] => {
472
622
  const callerKey = entryKey(callerEntry);
473
- const calleeKey = entryKey(calleeEntry);
474
- const priorCaller = entries.find(e => entryKey(e) === callerKey);
475
- const kept = entries.filter(e => entryKey(e) !== callerKey && entryKey(e) !== calleeKey);
623
+ const priorCaller =
624
+ entries.find(e => entryKey(e) === callerKey) ??
625
+ entries.find(e => e.skill === callerEntry.skill && supersedesVisible(e) && Boolean(e.handoff_from));
626
+ const kept = entries.filter(e => !supersedesVisible(e));
476
627
  // Merge prior lineage into the demoted caller so multi-step handoff
477
628
  // chains preserve `handoff_from` from the previous transition while
478
629
  // the new `handoff_to`/`handoff_at` describe this one.
@@ -216,6 +216,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
216
216
  runtime.ctx.editor.setText("");
217
217
  },
218
218
  },
219
+ {
220
+ name: "theme",
221
+ description: "Open theme selector",
222
+ handleTui: (_command, runtime) => {
223
+ runtime.ctx.showThemeSelector();
224
+ runtime.ctx.editor.setText("");
225
+ },
226
+ },
219
227
  {
220
228
  name: "goal",
221
229
  description: "Toggle goal mode (persistent autonomous objective for this session)",
@@ -923,7 +931,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
923
931
  runtime.settings,
924
932
  runtime.session,
925
933
  );
926
- await runtime.output(payload || "Memory payload is empty.");
934
+ await runtime.output(
935
+ payload || "Memory payload is empty; durable memory is unavailable or unconfirmed.",
936
+ );
927
937
  return commandConsumed();
928
938
  }
929
939
  case "clear":
@@ -30,6 +30,7 @@ interface AgentFrontmatter {
30
30
  blocking?: boolean;
31
31
  hide?: boolean;
32
32
  forkContext?: "forbidden" | "allowed";
33
+ bashAllowedPrefixes?: string[];
33
34
  }
34
35
 
35
36
  interface EmbeddedAgentDef {
@@ -1186,6 +1186,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1186
1186
  parentTaskPrefix: id,
1187
1187
  agentId: id,
1188
1188
  agentDisplayName: agent.name,
1189
+ bashAllowedPrefixes: agent.bashAllowedPrefixes,
1189
1190
  enableLsp: lspEnabled,
1190
1191
  skipPythonPreflight,
1191
1192
  enableMCP,
package/src/task/types.ts CHANGED
@@ -181,6 +181,7 @@ export interface AgentDefinition {
181
181
  autoloadSkills?: string[];
182
182
  hide?: boolean;
183
183
  forkContext?: ForkContextPolicy;
184
+ bashAllowedPrefixes?: string[];
184
185
  source: AgentSource;
185
186
  filePath?: string;
186
187
  }
@@ -0,0 +1,169 @@
1
+ export interface BashAllowedPrefixesCheck {
2
+ allowed: boolean;
3
+ reason?: string;
4
+ }
5
+
6
+ const SHELL_CONTROL_CHARS = new Set([";", "|", "&", "<", ">", "(", ")"]);
7
+ const UNSAFE_UNQUOTED_EXPANSION_CHARS = new Set(["$", "*", "?", "[", "]", "{", "}", "~"]);
8
+ const STATE_FLAGS_WITH_VALUES = new Set(["--input", "--mode", "--session-id", "--thread-id", "--turn-id", "--to"]);
9
+ const STATE_ACTIONS = new Set(["read", "write", "clear", "contract", "handoff"]);
10
+ const ALLOWED_STATE_ACTIONS = new Set(["read", "write", "contract"]);
11
+
12
+ function parseShellWords(command: string): { words: string[]; reason?: string } {
13
+ const words: string[] = [];
14
+ let current = "";
15
+ let quote: "single" | "double" | null = null;
16
+
17
+ for (let index = 0; index < command.length; index += 1) {
18
+ const char = command[index]!;
19
+ const next = command[index + 1];
20
+
21
+ if (quote === "single") {
22
+ if (char === "'") {
23
+ quote = null;
24
+ } else {
25
+ current += char;
26
+ }
27
+ continue;
28
+ }
29
+
30
+ if (quote === "double") {
31
+ if (char === '"') {
32
+ quote = null;
33
+ continue;
34
+ }
35
+ if (char === "`" || (char === "$" && next === "(")) {
36
+ return { words, reason: "command substitution is not allowed in restricted bash commands" };
37
+ }
38
+ if (char === "$") {
39
+ return { words, reason: "shell expansion character '$' is not allowed in restricted bash commands" };
40
+ }
41
+ if (char === "\\") {
42
+ return { words, reason: "backslash escapes are not allowed in restricted bash commands" };
43
+ }
44
+ current += char;
45
+ continue;
46
+ }
47
+
48
+ if (char === "'") {
49
+ quote = "single";
50
+ continue;
51
+ }
52
+ if (char === '"') {
53
+ quote = "double";
54
+ continue;
55
+ }
56
+ if (char === "`" || (char === "$" && next === "(")) {
57
+ return { words, reason: "command substitution is not allowed in restricted bash commands" };
58
+ }
59
+ if (char === "\n" || char === "\r") {
60
+ return { words, reason: "multiple shell commands are not allowed in restricted bash mode" };
61
+ }
62
+ if (SHELL_CONTROL_CHARS.has(char)) {
63
+ return { words, reason: `shell control operator '${char}' is not allowed in restricted bash commands` };
64
+ }
65
+ if (UNSAFE_UNQUOTED_EXPANSION_CHARS.has(char)) {
66
+ return { words, reason: `shell expansion character '${char}' is not allowed in restricted bash commands` };
67
+ }
68
+ if (/\s/u.test(char)) {
69
+ if (current.length > 0) {
70
+ words.push(current);
71
+ current = "";
72
+ }
73
+ continue;
74
+ }
75
+ if (char === "\\") {
76
+ return { words, reason: "backslash escapes are not allowed in restricted bash commands" };
77
+ }
78
+ current += char;
79
+ }
80
+
81
+ if (quote !== null) {
82
+ return { words, reason: "unterminated quote in restricted bash command" };
83
+ }
84
+ if (current.length > 0) words.push(current);
85
+ return { words };
86
+ }
87
+
88
+ function prefixWords(prefix: string): string[] {
89
+ return prefix.trim().split(/\s+/u).filter(Boolean);
90
+ }
91
+
92
+ function wordsStartWith(words: readonly string[], prefix: readonly string[]): boolean {
93
+ if (prefix.length === 0 || words.length < prefix.length) return false;
94
+ return prefix.every((word, index) => words[index] === word);
95
+ }
96
+
97
+ function parseStateAction(words: readonly string[]): string | undefined {
98
+ const args = words.slice(2);
99
+ const positional: string[] = [];
100
+ let skipNext = false;
101
+ for (const arg of args) {
102
+ if (skipNext) {
103
+ skipNext = false;
104
+ continue;
105
+ }
106
+ if (STATE_FLAGS_WITH_VALUES.has(arg)) {
107
+ skipNext = true;
108
+ continue;
109
+ }
110
+ if (!arg.startsWith("-")) positional.push(arg);
111
+ }
112
+
113
+ const [first, second, third] = positional;
114
+ if (!first) return "read";
115
+ if (STATE_ACTIONS.has(first)) return second ? undefined : first;
116
+ if (!second) return "read";
117
+ if (!STATE_ACTIONS.has(second)) return undefined;
118
+ return third ? undefined : second;
119
+ }
120
+
121
+ function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixesCheck {
122
+ if (words[0] !== "gjc") return { allowed: true };
123
+
124
+ if (words[1] === "ralplan") {
125
+ if (!words.includes("--write")) {
126
+ return { allowed: false, reason: "restricted role-agent bash only allows `gjc ralplan --write ...`" };
127
+ }
128
+ return { allowed: true };
129
+ }
130
+
131
+ if (words[1] === "state") {
132
+ const action = parseStateAction(words);
133
+ if (!action) {
134
+ return {
135
+ allowed: false,
136
+ reason: "restricted role-agent bash only allows documented `gjc state` action shapes",
137
+ };
138
+ }
139
+ if (!ALLOWED_STATE_ACTIONS.has(action)) {
140
+ return { allowed: false, reason: `restricted role-agent bash does not allow \`gjc state ${action}\`` };
141
+ }
142
+ return { allowed: true };
143
+ }
144
+
145
+ return { allowed: true };
146
+ }
147
+
148
+ export function checkBashAllowedPrefixes(
149
+ command: string,
150
+ allowedPrefixes: readonly string[] | undefined,
151
+ ): BashAllowedPrefixesCheck {
152
+ const normalizedPrefixes = allowedPrefixes?.map(prefix => prefix.trim()).filter(Boolean) ?? [];
153
+ if (normalizedPrefixes.length === 0) return { allowed: true };
154
+
155
+ const parsed = parseShellWords(command.trim());
156
+ if (parsed.reason) return { allowed: false, reason: parsed.reason };
157
+ if (parsed.words.length === 0)
158
+ return { allowed: false, reason: "empty command is not allowed in restricted bash mode" };
159
+
160
+ const matched = normalizedPrefixes.some(prefix => wordsStartWith(parsed.words, prefixWords(prefix)));
161
+ if (!matched) {
162
+ return {
163
+ allowed: false,
164
+ reason: `restricted role-agent bash only allows commands starting with: ${normalizedPrefixes.join(", ")}`,
165
+ };
166
+ }
167
+
168
+ return validateMatchedGjcCommand(parsed.words);
169
+ }