@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.
- package/CHANGELOG.md +34 -8600
- package/README.md +1 -1
- package/dist/types/async/job-manager.d.ts +61 -0
- package/dist/types/cli/update-cli.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +27 -3
- package/dist/types/config/settings.d.ts +1 -1
- package/dist/types/defaults/gjc-defaults.d.ts +19 -6
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/exec/bash-executor.d.ts +8 -1
- package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +2 -0
- package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
- package/dist/types/modes/components/settings-selector.d.ts +4 -0
- package/dist/types/modes/components/skill-hud/render.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/theme/defaults/index.d.ts +45 -9351
- package/dist/types/modes/theme/theme.d.ts +6 -5
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/streaming-output.d.ts +11 -0
- package/dist/types/skill-state/active-state.d.ts +1 -0
- package/dist/types/task/types.d.ts +1 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +24 -0
- package/dist/types/tools/cron.d.ts +110 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/monitor.d.ts +54 -0
- package/dist/types/web/search/index.d.ts +1 -0
- package/dist/types/web/search/provider.d.ts +11 -4
- package/dist/types/web/search/providers/duckduckgo.d.ts +57 -0
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +224 -0
- package/src/cli/agents-cli.ts +3 -0
- package/src/cli/update-cli.ts +67 -16
- package/src/config/settings-schema.ts +30 -2
- package/src/config/settings.ts +44 -7
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +48 -6
- package/src/defaults/gjc/skills/deep-interview/auto-answer-uncertain.md +37 -0
- package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +42 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +9 -6
- package/src/defaults/gjc-defaults.ts +68 -16
- package/src/discovery/helpers.ts +5 -0
- package/src/eval/js/shared/rewrite-imports.ts +1 -2
- package/src/exec/bash-executor.ts +20 -9
- package/src/gjc-runtime/deep-interview-runtime.ts +44 -0
- package/src/gjc-runtime/ralplan-runtime.ts +2 -0
- package/src/gjc-runtime/restricted-role-agent-bash.ts +5 -0
- package/src/gjc-runtime/state-runtime.ts +3 -2
- package/src/goals/tools/goal-tool.ts +5 -1
- package/src/hooks/skill-state.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +8 -4
- package/src/lsp/render.ts +1 -1
- package/src/memories/index.ts +5 -4
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/acp/acp-client-bridge.ts +1 -1
- package/src/modes/components/agent-dashboard.ts +1 -1
- package/src/modes/components/diff.ts +2 -2
- package/src/modes/components/settings-selector.ts +25 -14
- package/src/modes/components/skill-hud/render.ts +7 -2
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +10 -2
- package/src/modes/controllers/selector-controller.ts +67 -0
- package/src/modes/interactive-mode.ts +34 -3
- package/src/modes/theme/defaults/blue-crab.json +126 -0
- package/src/modes/theme/defaults/index.ts +2 -196
- package/src/modes/theme/theme.ts +75 -36
- package/src/modes/types.ts +2 -0
- package/src/prompts/agents/architect.md +5 -1
- package/src/prompts/agents/critic.md +5 -1
- package/src/prompts/agents/frontmatter.md +1 -0
- package/src/prompts/agents/planner.md +5 -1
- package/src/prompts/memories/unavailable.md +9 -0
- package/src/prompts/tools/bash.md +9 -0
- package/src/prompts/tools/cron.md +25 -0
- package/src/prompts/tools/monitor.md +30 -0
- package/src/runtime-mcp/oauth-flow.ts +4 -2
- package/src/sdk.ts +7 -0
- package/src/session/agent-session.ts +16 -5
- package/src/session/streaming-output.ts +21 -0
- package/src/skill-state/active-state.ts +163 -12
- package/src/slash-commands/builtin-registry.ts +11 -1
- package/src/task/agents.ts +1 -0
- package/src/task/executor.ts +1 -0
- package/src/task/types.ts +1 -0
- package/src/tools/bash-allowed-prefixes.ts +169 -0
- package/src/tools/bash.ts +190 -29
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/cron.ts +665 -0
- package/src/tools/index.ts +20 -2
- package/src/tools/monitor.ts +136 -0
- package/src/vim/engine.ts +3 -3
- package/src/web/search/index.ts +31 -18
- package/src/web/search/provider.ts +57 -12
- package/src/web/search/providers/duckduckgo.ts +279 -0
- package/src/web/search/types.ts +2 -0
- package/src/modes/theme/dark.json +0 -95
- package/src/modes/theme/defaults/alabaster.json +0 -93
- package/src/modes/theme/defaults/amethyst.json +0 -96
- package/src/modes/theme/defaults/anthracite.json +0 -93
- package/src/modes/theme/defaults/basalt.json +0 -91
- package/src/modes/theme/defaults/birch.json +0 -95
- package/src/modes/theme/defaults/dark-abyss.json +0 -91
- package/src/modes/theme/defaults/dark-arctic.json +0 -104
- package/src/modes/theme/defaults/dark-aurora.json +0 -95
- package/src/modes/theme/defaults/dark-catppuccin.json +0 -107
- package/src/modes/theme/defaults/dark-cavern.json +0 -91
- package/src/modes/theme/defaults/dark-copper.json +0 -95
- package/src/modes/theme/defaults/dark-cosmos.json +0 -90
- package/src/modes/theme/defaults/dark-cyberpunk.json +0 -102
- package/src/modes/theme/defaults/dark-dracula.json +0 -98
- package/src/modes/theme/defaults/dark-eclipse.json +0 -91
- package/src/modes/theme/defaults/dark-ember.json +0 -95
- package/src/modes/theme/defaults/dark-equinox.json +0 -90
- package/src/modes/theme/defaults/dark-forest.json +0 -96
- package/src/modes/theme/defaults/dark-github.json +0 -105
- package/src/modes/theme/defaults/dark-gruvbox.json +0 -112
- package/src/modes/theme/defaults/dark-lavender.json +0 -95
- package/src/modes/theme/defaults/dark-lunar.json +0 -89
- package/src/modes/theme/defaults/dark-midnight.json +0 -95
- package/src/modes/theme/defaults/dark-monochrome.json +0 -94
- package/src/modes/theme/defaults/dark-monokai.json +0 -98
- package/src/modes/theme/defaults/dark-nebula.json +0 -90
- package/src/modes/theme/defaults/dark-nord.json +0 -97
- package/src/modes/theme/defaults/dark-ocean.json +0 -101
- package/src/modes/theme/defaults/dark-one.json +0 -100
- package/src/modes/theme/defaults/dark-poimandres.json +0 -141
- package/src/modes/theme/defaults/dark-rainforest.json +0 -91
- package/src/modes/theme/defaults/dark-reef.json +0 -91
- package/src/modes/theme/defaults/dark-retro.json +0 -92
- package/src/modes/theme/defaults/dark-rose-pine.json +0 -96
- package/src/modes/theme/defaults/dark-sakura.json +0 -95
- package/src/modes/theme/defaults/dark-slate.json +0 -95
- package/src/modes/theme/defaults/dark-solarized.json +0 -97
- package/src/modes/theme/defaults/dark-solstice.json +0 -90
- package/src/modes/theme/defaults/dark-starfall.json +0 -91
- package/src/modes/theme/defaults/dark-sunset.json +0 -99
- package/src/modes/theme/defaults/dark-swamp.json +0 -90
- package/src/modes/theme/defaults/dark-synthwave.json +0 -103
- package/src/modes/theme/defaults/dark-taiga.json +0 -91
- package/src/modes/theme/defaults/dark-terminal.json +0 -95
- package/src/modes/theme/defaults/dark-tokyo-night.json +0 -101
- package/src/modes/theme/defaults/dark-tundra.json +0 -91
- package/src/modes/theme/defaults/dark-twilight.json +0 -91
- package/src/modes/theme/defaults/dark-volcanic.json +0 -91
- package/src/modes/theme/defaults/graphite.json +0 -92
- package/src/modes/theme/defaults/light-arctic.json +0 -107
- package/src/modes/theme/defaults/light-aurora-day.json +0 -91
- package/src/modes/theme/defaults/light-canyon.json +0 -91
- package/src/modes/theme/defaults/light-catppuccin.json +0 -106
- package/src/modes/theme/defaults/light-cirrus.json +0 -90
- package/src/modes/theme/defaults/light-coral.json +0 -95
- package/src/modes/theme/defaults/light-cyberpunk.json +0 -96
- package/src/modes/theme/defaults/light-dawn.json +0 -90
- package/src/modes/theme/defaults/light-dunes.json +0 -91
- package/src/modes/theme/defaults/light-eucalyptus.json +0 -95
- package/src/modes/theme/defaults/light-forest.json +0 -100
- package/src/modes/theme/defaults/light-frost.json +0 -95
- package/src/modes/theme/defaults/light-github.json +0 -115
- package/src/modes/theme/defaults/light-glacier.json +0 -91
- package/src/modes/theme/defaults/light-gruvbox.json +0 -108
- package/src/modes/theme/defaults/light-haze.json +0 -90
- package/src/modes/theme/defaults/light-honeycomb.json +0 -95
- package/src/modes/theme/defaults/light-lagoon.json +0 -91
- package/src/modes/theme/defaults/light-lavender.json +0 -95
- package/src/modes/theme/defaults/light-meadow.json +0 -91
- package/src/modes/theme/defaults/light-mint.json +0 -95
- package/src/modes/theme/defaults/light-monochrome.json +0 -101
- package/src/modes/theme/defaults/light-ocean.json +0 -99
- package/src/modes/theme/defaults/light-one.json +0 -99
- package/src/modes/theme/defaults/light-opal.json +0 -91
- package/src/modes/theme/defaults/light-orchard.json +0 -91
- package/src/modes/theme/defaults/light-paper.json +0 -95
- package/src/modes/theme/defaults/light-poimandres.json +0 -141
- package/src/modes/theme/defaults/light-prism.json +0 -90
- package/src/modes/theme/defaults/light-retro.json +0 -98
- package/src/modes/theme/defaults/light-sand.json +0 -95
- package/src/modes/theme/defaults/light-savanna.json +0 -91
- package/src/modes/theme/defaults/light-solarized.json +0 -102
- package/src/modes/theme/defaults/light-soleil.json +0 -90
- package/src/modes/theme/defaults/light-sunset.json +0 -99
- package/src/modes/theme/defaults/light-synthwave.json +0 -98
- package/src/modes/theme/defaults/light-tokyo-night.json +0 -111
- package/src/modes/theme/defaults/light-wetland.json +0 -91
- package/src/modes/theme/defaults/light-zenith.json +0 -89
- package/src/modes/theme/defaults/limestone.json +0 -94
- package/src/modes/theme/defaults/mahogany.json +0 -97
- package/src/modes/theme/defaults/marble.json +0 -93
- package/src/modes/theme/defaults/obsidian.json +0 -91
- package/src/modes/theme/defaults/onyx.json +0 -91
- package/src/modes/theme/defaults/pearl.json +0 -93
- package/src/modes/theme/defaults/porcelain.json +0 -91
- package/src/modes/theme/defaults/quartz.json +0 -96
- package/src/modes/theme/defaults/sandstone.json +0 -95
- package/src/modes/theme/defaults/titanium.json +0 -90
- 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 (
|
|
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 (
|
|
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
|
|
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()
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
|
330
|
+
if (!state) return [];
|
|
331
331
|
const out: SkillActiveEntry[] = [];
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
365
|
-
sessionPath ?
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
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(
|
|
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":
|
package/src/task/agents.ts
CHANGED
package/src/task/executor.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|