@gajae-code/coding-agent 0.2.4 → 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 (179) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +61 -0
  4. package/dist/types/config/settings-schema.d.ts +7 -3
  5. package/dist/types/config/settings.d.ts +1 -1
  6. package/dist/types/discovery/helpers.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +8 -1
  8. package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +2 -0
  9. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  10. package/dist/types/modes/components/skill-hud/render.d.ts +1 -1
  11. package/dist/types/modes/interactive-mode.d.ts +1 -0
  12. package/dist/types/modes/theme/defaults/index.d.ts +45 -9477
  13. package/dist/types/modes/theme/theme.d.ts +1 -5
  14. package/dist/types/modes/types.d.ts +1 -0
  15. package/dist/types/sdk.d.ts +2 -0
  16. package/dist/types/session/streaming-output.d.ts +11 -0
  17. package/dist/types/skill-state/active-state.d.ts +1 -0
  18. package/dist/types/task/types.d.ts +1 -0
  19. package/dist/types/tools/bash-allowed-prefixes.d.ts +5 -0
  20. package/dist/types/tools/bash.d.ts +24 -0
  21. package/dist/types/tools/cron.d.ts +110 -0
  22. package/dist/types/tools/index.d.ts +4 -0
  23. package/dist/types/tools/monitor.d.ts +54 -0
  24. package/dist/types/web/search/index.d.ts +1 -0
  25. package/dist/types/web/search/provider.d.ts +11 -4
  26. package/dist/types/web/search/providers/duckduckgo.d.ts +57 -0
  27. package/dist/types/web/search/types.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/async/job-manager.ts +224 -0
  30. package/src/cli/agents-cli.ts +3 -0
  31. package/src/config/settings-schema.ts +8 -2
  32. package/src/config/settings.ts +44 -7
  33. package/src/defaults/gjc/skills/deep-interview/SKILL.md +9 -2
  34. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  35. package/src/discovery/helpers.ts +5 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +1 -2
  37. package/src/exec/bash-executor.ts +20 -9
  38. package/src/gjc-runtime/ralplan-runtime.ts +2 -0
  39. package/src/gjc-runtime/restricted-role-agent-bash.ts +5 -0
  40. package/src/hooks/skill-state.ts +1 -1
  41. package/src/internal-urls/docs-index.generated.ts +5 -3
  42. package/src/lsp/render.ts +1 -1
  43. package/src/modes/acp/acp-agent.ts +1 -1
  44. package/src/modes/acp/acp-client-bridge.ts +1 -1
  45. package/src/modes/components/agent-dashboard.ts +1 -1
  46. package/src/modes/components/diff.ts +2 -2
  47. package/src/modes/components/skill-hud/render.ts +7 -2
  48. package/src/modes/controllers/input-controller.ts +10 -2
  49. package/src/modes/controllers/selector-controller.ts +1 -1
  50. package/src/modes/interactive-mode.ts +20 -2
  51. package/src/modes/theme/defaults/index.ts +0 -196
  52. package/src/modes/theme/theme.ts +35 -35
  53. package/src/modes/types.ts +1 -0
  54. package/src/prompts/agents/architect.md +5 -1
  55. package/src/prompts/agents/critic.md +5 -1
  56. package/src/prompts/agents/frontmatter.md +1 -0
  57. package/src/prompts/agents/planner.md +5 -1
  58. package/src/prompts/tools/bash.md +9 -0
  59. package/src/prompts/tools/cron.md +25 -0
  60. package/src/prompts/tools/monitor.md +30 -0
  61. package/src/runtime-mcp/oauth-flow.ts +4 -2
  62. package/src/sdk.ts +3 -0
  63. package/src/session/agent-session.ts +16 -5
  64. package/src/session/streaming-output.ts +21 -0
  65. package/src/skill-state/active-state.ts +163 -12
  66. package/src/task/agents.ts +1 -0
  67. package/src/task/executor.ts +1 -0
  68. package/src/task/types.ts +1 -0
  69. package/src/tools/bash-allowed-prefixes.ts +169 -0
  70. package/src/tools/bash.ts +190 -29
  71. package/src/tools/browser/tab-worker.ts +1 -1
  72. package/src/tools/cron.ts +665 -0
  73. package/src/tools/index.ts +20 -2
  74. package/src/tools/monitor.ts +136 -0
  75. package/src/vim/engine.ts +3 -3
  76. package/src/web/search/index.ts +31 -18
  77. package/src/web/search/provider.ts +57 -12
  78. package/src/web/search/providers/duckduckgo.ts +279 -0
  79. package/src/web/search/types.ts +2 -0
  80. package/src/modes/theme/dark.json +0 -95
  81. package/src/modes/theme/defaults/alabaster.json +0 -93
  82. package/src/modes/theme/defaults/amethyst.json +0 -96
  83. package/src/modes/theme/defaults/anthracite.json +0 -93
  84. package/src/modes/theme/defaults/basalt.json +0 -91
  85. package/src/modes/theme/defaults/birch.json +0 -95
  86. package/src/modes/theme/defaults/dark-abyss.json +0 -91
  87. package/src/modes/theme/defaults/dark-arctic.json +0 -104
  88. package/src/modes/theme/defaults/dark-aurora.json +0 -95
  89. package/src/modes/theme/defaults/dark-catppuccin.json +0 -107
  90. package/src/modes/theme/defaults/dark-cavern.json +0 -91
  91. package/src/modes/theme/defaults/dark-copper.json +0 -95
  92. package/src/modes/theme/defaults/dark-cosmos.json +0 -90
  93. package/src/modes/theme/defaults/dark-cyberpunk.json +0 -102
  94. package/src/modes/theme/defaults/dark-dracula.json +0 -98
  95. package/src/modes/theme/defaults/dark-eclipse.json +0 -91
  96. package/src/modes/theme/defaults/dark-ember.json +0 -95
  97. package/src/modes/theme/defaults/dark-equinox.json +0 -90
  98. package/src/modes/theme/defaults/dark-forest.json +0 -96
  99. package/src/modes/theme/defaults/dark-github.json +0 -105
  100. package/src/modes/theme/defaults/dark-gruvbox.json +0 -112
  101. package/src/modes/theme/defaults/dark-lavender.json +0 -95
  102. package/src/modes/theme/defaults/dark-lunar.json +0 -89
  103. package/src/modes/theme/defaults/dark-midnight.json +0 -95
  104. package/src/modes/theme/defaults/dark-monochrome.json +0 -94
  105. package/src/modes/theme/defaults/dark-monokai.json +0 -98
  106. package/src/modes/theme/defaults/dark-nebula.json +0 -90
  107. package/src/modes/theme/defaults/dark-nord.json +0 -97
  108. package/src/modes/theme/defaults/dark-ocean.json +0 -101
  109. package/src/modes/theme/defaults/dark-one.json +0 -100
  110. package/src/modes/theme/defaults/dark-poimandres.json +0 -141
  111. package/src/modes/theme/defaults/dark-rainforest.json +0 -91
  112. package/src/modes/theme/defaults/dark-reef.json +0 -91
  113. package/src/modes/theme/defaults/dark-retro.json +0 -92
  114. package/src/modes/theme/defaults/dark-rose-pine.json +0 -96
  115. package/src/modes/theme/defaults/dark-sakura.json +0 -95
  116. package/src/modes/theme/defaults/dark-slate.json +0 -95
  117. package/src/modes/theme/defaults/dark-solarized.json +0 -97
  118. package/src/modes/theme/defaults/dark-solstice.json +0 -90
  119. package/src/modes/theme/defaults/dark-starfall.json +0 -91
  120. package/src/modes/theme/defaults/dark-sunset.json +0 -99
  121. package/src/modes/theme/defaults/dark-swamp.json +0 -90
  122. package/src/modes/theme/defaults/dark-synthwave.json +0 -103
  123. package/src/modes/theme/defaults/dark-taiga.json +0 -91
  124. package/src/modes/theme/defaults/dark-terminal.json +0 -95
  125. package/src/modes/theme/defaults/dark-tokyo-night.json +0 -101
  126. package/src/modes/theme/defaults/dark-tundra.json +0 -91
  127. package/src/modes/theme/defaults/dark-twilight.json +0 -91
  128. package/src/modes/theme/defaults/dark-volcanic.json +0 -91
  129. package/src/modes/theme/defaults/graphite.json +0 -92
  130. package/src/modes/theme/defaults/light-arctic.json +0 -107
  131. package/src/modes/theme/defaults/light-aurora-day.json +0 -91
  132. package/src/modes/theme/defaults/light-canyon.json +0 -91
  133. package/src/modes/theme/defaults/light-catppuccin.json +0 -106
  134. package/src/modes/theme/defaults/light-cirrus.json +0 -90
  135. package/src/modes/theme/defaults/light-coral.json +0 -95
  136. package/src/modes/theme/defaults/light-cyberpunk.json +0 -96
  137. package/src/modes/theme/defaults/light-dawn.json +0 -90
  138. package/src/modes/theme/defaults/light-dunes.json +0 -91
  139. package/src/modes/theme/defaults/light-eucalyptus.json +0 -95
  140. package/src/modes/theme/defaults/light-forest.json +0 -100
  141. package/src/modes/theme/defaults/light-frost.json +0 -95
  142. package/src/modes/theme/defaults/light-github.json +0 -115
  143. package/src/modes/theme/defaults/light-glacier.json +0 -91
  144. package/src/modes/theme/defaults/light-gruvbox.json +0 -108
  145. package/src/modes/theme/defaults/light-haze.json +0 -90
  146. package/src/modes/theme/defaults/light-honeycomb.json +0 -95
  147. package/src/modes/theme/defaults/light-lagoon.json +0 -91
  148. package/src/modes/theme/defaults/light-lavender.json +0 -95
  149. package/src/modes/theme/defaults/light-meadow.json +0 -91
  150. package/src/modes/theme/defaults/light-mint.json +0 -95
  151. package/src/modes/theme/defaults/light-monochrome.json +0 -101
  152. package/src/modes/theme/defaults/light-ocean.json +0 -99
  153. package/src/modes/theme/defaults/light-one.json +0 -99
  154. package/src/modes/theme/defaults/light-opal.json +0 -91
  155. package/src/modes/theme/defaults/light-orchard.json +0 -91
  156. package/src/modes/theme/defaults/light-paper.json +0 -95
  157. package/src/modes/theme/defaults/light-poimandres.json +0 -141
  158. package/src/modes/theme/defaults/light-prism.json +0 -90
  159. package/src/modes/theme/defaults/light-retro.json +0 -98
  160. package/src/modes/theme/defaults/light-sand.json +0 -95
  161. package/src/modes/theme/defaults/light-savanna.json +0 -91
  162. package/src/modes/theme/defaults/light-solarized.json +0 -102
  163. package/src/modes/theme/defaults/light-soleil.json +0 -90
  164. package/src/modes/theme/defaults/light-sunset.json +0 -99
  165. package/src/modes/theme/defaults/light-synthwave.json +0 -98
  166. package/src/modes/theme/defaults/light-tokyo-night.json +0 -111
  167. package/src/modes/theme/defaults/light-wetland.json +0 -91
  168. package/src/modes/theme/defaults/light-zenith.json +0 -89
  169. package/src/modes/theme/defaults/limestone.json +0 -94
  170. package/src/modes/theme/defaults/mahogany.json +0 -97
  171. package/src/modes/theme/defaults/marble.json +0 -93
  172. package/src/modes/theme/defaults/obsidian.json +0 -91
  173. package/src/modes/theme/defaults/onyx.json +0 -91
  174. package/src/modes/theme/defaults/pearl.json +0 -93
  175. package/src/modes/theme/defaults/porcelain.json +0 -91
  176. package/src/modes/theme/defaults/quartz.json +0 -96
  177. package/src/modes/theme/defaults/sandstone.json +0 -95
  178. package/src/modes/theme/defaults/titanium.json +0 -90
  179. package/src/modes/theme/light.json +0 -93
@@ -0,0 +1,25 @@
1
+ Schedule a prompt to fire on a recurring cron schedule, or one-shot at the next match. Cron tasks let you re-run a prompt automatically on an interval — poll a deployment, babysit a PR, check back on a long-running build, or remind yourself to do something later in the session.
2
+
3
+ `CronCreate` accepts a standard 5-field cron expression in your local timezone, the prompt to run, and whether the job recurs or fires once. It returns an 8-character job id you can pass to `CronDelete`. Each session can hold up to 50 scheduled tasks. Recurring tasks auto-expire 7 days after creation; one-shot tasks self-delete after firing.
4
+
5
+ `CronList` enumerates every scheduled task in the session. `CronDelete` cancels a task by id.
6
+
7
+ ## Cron expressions
8
+
9
+ `CronCreate` accepts 5-field cron: `minute hour day-of-month month day-of-week`. All fields support `*`, single values (`5`), steps (`*/15`), ranges (`1-5`), and comma lists (`1,15,30`). Day-of-week uses `0`/`7` for Sunday through `6` for Saturday. Extended syntax like `L`, `W`, `?`, or month/weekday names is not supported.
10
+
11
+ | Example | Meaning |
12
+ | :------------ | :--------------------------- |
13
+ | `*/5 * * * *` | Every 5 minutes |
14
+ | `0 * * * *` | Every hour on the hour |
15
+ | `0 9 * * *` | Every day at 9am local |
16
+ | `0 9 * * 1-5` | Weekdays at 9am local |
17
+
18
+ ## Lifecycle
19
+
20
+ - Tasks fire between turns, never mid-response.
21
+ - All times are interpreted in the local timezone.
22
+ - Recurring tasks fire with up to 30 minutes of deterministic jitter (or up to half their interval for sub-hourly tasks). One-shot tasks scheduled for `:00` or `:30` may fire up to 90 s early. Pick an off-minute if exact timing matters.
23
+ - Closing or replacing the session clears every scheduled task.
24
+
25
+ Disable the scheduler entirely via `CLAUDE_CODE_DISABLE_CRON=1`.
@@ -0,0 +1,30 @@
1
+ Start a background monitor that streams events from a long-running script. Each stdout line is an event — you keep working and notifications arrive in the chat. Events arrive on their own schedule and are not replies from the user, even if one lands while you're waiting for the user to answer a question.
2
+
3
+ Pick by how many notifications you need:
4
+ - **One** ("tell me when the server is ready / the build finishes") → use `bash` with `async: true`. That returns a single completion notification when the command exits.
5
+ - **Many ongoing events** (logs, polling, file watching) → use `monitor`. The script keeps running and every new line of stdout becomes one event delivered into the conversation between turns.
6
+
7
+ `monitor` uses the same permission rules as `bash`. To stop a monitor, cancel its background task via `job` with the returned `task_id`, or end the session.
8
+
9
+ ## When to reach for `monitor`
10
+
11
+ - Tail a log file and flag errors as they appear (`tail -F server.log | grep -i error`).
12
+ - Poll a PR or CI job and report when its status changes.
13
+ - Watch a directory for file changes (`fswatch -r dist/`).
14
+ - Track output from any long-running script you point it at.
15
+
16
+ ## Inputs
17
+
18
+ - `command` (required): shell command to run as a background monitor. Each stdout line is delivered as a separate task-notification event.
19
+ - `kind` (required): one of `"log"`, `"poll"`, `"watch"`, `"other"`. Describes the monitoring strategy so listings can surface useful categories.
20
+ - `description` (required): short human-readable description of what is being monitored. Appears in task listings.
21
+ - `timeout` (optional): maximum wall-clock seconds the monitor may run before automatic shutdown. Omit for the session lifetime.
22
+ - `persistent` (optional, default `false`): keep the monitor running past the current turn. Persistent monitors survive until session end or until cancelled via `job`.
23
+
24
+ ## Output
25
+
26
+ Returns `Monitor started · task <task_id>` plus a task entry visible via `job({op:"list"})`. Each stdout line of the monitored command becomes a `<task-notification>` event delivered between turns.
27
+
28
+ ## Cancellation
29
+
30
+ There is no separate `monitor` kill tool. Cancel a running monitor with `job` using the returned `task_id`. Disposing the session also cancels every monitor the calling agent started.
@@ -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 ?? "*",
@@ -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.
@@ -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
+ }