@gajae-code/coding-agent 0.7.10 → 0.8.0
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 +58 -0
- package/README.md +2 -2
- package/dist/types/capability/mcp.d.ts +2 -0
- package/dist/types/config/keybindings.d.ts +11 -5
- package/dist/types/config/settings-schema.d.ts +25 -2
- package/dist/types/defaults/gjc-defaults.d.ts +7 -0
- package/dist/types/discovery/cursor.d.ts +4 -1
- package/dist/types/discovery/index.d.ts +0 -1
- package/dist/types/discovery/windsurf.d.ts +3 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +3 -2
- package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +1 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +5 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/skill-message.d.ts +1 -0
- package/dist/types/modes/components/status-line.d.ts +8 -0
- package/dist/types/modes/components/two-column-body.d.ts +9 -0
- package/dist/types/modes/components/welcome.d.ts +9 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/input-controller.d.ts +5 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
- package/dist/types/modes/slash-command-visibility.d.ts +1 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
- package/dist/types/runtime-mcp/config.d.ts +2 -0
- package/dist/types/runtime-mcp/loader.d.ts +2 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/types.d.ts +6 -0
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +8 -5
- package/dist/types/slash-commands/types.d.ts +2 -0
- package/dist/types/system-prompt.d.ts +7 -0
- package/dist/types/utils/cmux-workspace.d.ts +30 -1
- package/dist/types/web/search/index.d.ts +3 -1
- package/dist/types/web/search/provider.d.ts +9 -0
- package/dist/types/web/search/providers/utils.d.ts +42 -13
- package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
- package/package.json +10 -10
- package/src/capability/mcp.ts +2 -0
- package/src/cli/update-cli.ts +24 -0
- package/src/cli/web-search-cli.ts +2 -5
- package/src/cli.ts +1 -1
- package/src/commands/launch.ts +1 -1
- package/src/commands/ralplan.ts +1 -0
- package/src/config/keybindings.ts +14 -5
- package/src/config/mcp-schema.json +5 -1
- package/src/config/model-profiles.ts +16 -0
- package/src/config/settings-schema.ts +18 -2
- package/src/coordinator-mcp/server.ts +15 -3
- package/src/defaults/gjc/skills/ralplan/SKILL.md +16 -14
- package/src/defaults/gjc-defaults.ts +16 -0
- package/src/discovery/builtin.ts +12 -0
- package/src/discovery/cursor.ts +5 -84
- package/src/discovery/gemini.ts +3 -89
- package/src/discovery/index.ts +4 -1
- package/src/discovery/mcp-json.ts +11 -0
- package/src/discovery/opencode.ts +5 -122
- package/src/discovery/windsurf.ts +4 -82
- package/src/edit/read-file.ts +9 -1
- package/src/extensibility/extensions/runner.ts +2 -1
- package/src/gjc-runtime/launch-tmux.ts +164 -42
- package/src/gjc-runtime/ralplan-runtime.ts +22 -6
- package/src/gjc-runtime/restricted-role-agent-bash.ts +1 -0
- package/src/gjc-runtime/session-state-sidecar.ts +167 -24
- package/src/gjc-runtime/state-runtime.ts +8 -5
- package/src/gjc-runtime/state-writer.ts +5 -5
- package/src/gjc-runtime/team-runtime.ts +56 -51
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/main.ts +2 -2
- package/src/migrate/executor.ts +23 -6
- package/src/modes/components/agent-dashboard.ts +1 -34
- package/src/modes/components/custom-editor.ts +26 -0
- package/src/modes/components/extensions/extension-dashboard.ts +2 -48
- package/src/modes/components/model-selector.ts +64 -4
- package/src/modes/components/session-selector.ts +19 -9
- package/src/modes/components/settings-selector.ts +13 -2
- package/src/modes/components/skill-message.ts +18 -10
- package/src/modes/components/status-line.ts +215 -86
- package/src/modes/components/tree-selector.ts +28 -8
- package/src/modes/components/two-column-body.ts +36 -0
- package/src/modes/components/welcome.ts +171 -35
- package/src/modes/controllers/command-controller.ts +23 -0
- package/src/modes/controllers/event-controller.ts +4 -0
- package/src/modes/controllers/input-controller.ts +192 -39
- package/src/modes/controllers/selector-controller.ts +25 -28
- package/src/modes/interactive-mode.ts +36 -30
- package/src/modes/prompt-action-autocomplete.ts +23 -1
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +9 -3
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
- package/src/modes/slash-command-visibility.ts +9 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/hotkeys-markdown.ts +5 -2
- package/src/modes/utils/ui-helpers.ts +76 -3
- package/src/notifications/index.ts +20 -1
- package/src/notifications/lifecycle-control-runtime.ts +12 -3
- package/src/notifications/telegram-daemon.ts +4 -4
- package/src/prompts/agents/architect.md +7 -4
- package/src/prompts/agents/critic.md +15 -6
- package/src/prompts/agents/planner.md +8 -3
- package/src/prompts/system/system-prompt.md +15 -1
- package/src/prompts/tools/bash.md +1 -1
- package/src/runtime-mcp/config-writer.ts +28 -0
- package/src/runtime-mcp/config.ts +7 -0
- package/src/runtime-mcp/loader.ts +3 -0
- package/src/runtime-mcp/manager.ts +3 -0
- package/src/runtime-mcp/types.ts +6 -0
- package/src/sdk.ts +7 -6
- package/src/session/agent-session.ts +81 -18
- package/src/session/session-manager.ts +58 -18
- package/src/slash-commands/builtin-registry.ts +30 -10
- package/src/slash-commands/types.ts +2 -0
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +5 -0
- package/src/tools/ast-grep.ts +10 -7
- package/src/tools/bash.ts +39 -14
- package/src/tools/index.ts +8 -1
- package/src/utils/cmux-workspace.ts +134 -7
- package/src/utils/title-generator.ts +1 -1
- package/src/web/kagi.ts +1 -1
- package/src/web/parallel.ts +2 -2
- package/src/web/search/index.ts +118 -45
- package/src/web/search/provider.ts +65 -0
- package/src/web/search/providers/anthropic.ts +1 -1
- package/src/web/search/providers/brave.ts +1 -1
- package/src/web/search/providers/codex.ts +1 -1
- package/src/web/search/providers/duckduckgo.ts +1 -1
- package/src/web/search/providers/exa.ts +1 -1
- package/src/web/search/providers/gemini.ts +7 -3
- package/src/web/search/providers/jina.ts +1 -1
- package/src/web/search/providers/kimi.ts +6 -1
- package/src/web/search/providers/openai-compatible.ts +1 -1
- package/src/web/search/providers/parallel.ts +1 -1
- package/src/web/search/providers/perplexity.ts +2 -2
- package/src/web/search/providers/searxng.ts +1 -1
- package/src/web/search/providers/synthetic.ts +1 -1
- package/src/web/search/providers/tavily.ts +1 -1
- package/src/web/search/providers/utils.ts +67 -17
- package/src/web/search/providers/xai.ts +1 -1
- package/src/web/search/providers/zai.ts +1 -1
- package/src/workflow/workflow-intent-diff.ts +199 -0
- package/dist/types/discovery/vscode.d.ts +0 -1
- package/src/discovery/vscode.ts +0 -105
package/src/tools/bash.ts
CHANGED
|
@@ -8,7 +8,10 @@ import { AsyncJobManager } from "../async";
|
|
|
8
8
|
import { type BashResult, executeBash } from "../exec/bash-executor";
|
|
9
9
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
10
10
|
import { buildGjcRuntimeSessionEnv } from "../gjc-runtime/goal-mode-request";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
GJC_RALPLAN_ARTIFACT_ENV,
|
|
13
|
+
GJC_RESTRICTED_ROLE_AGENT_BASH_ENV,
|
|
14
|
+
} from "../gjc-runtime/restricted-role-agent-bash";
|
|
12
15
|
import { InternalUrlRouter } from "../internal-urls";
|
|
13
16
|
import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
14
17
|
import { highlightCode, type Theme } from "../modes/theme/theme";
|
|
@@ -530,13 +533,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
530
533
|
|
|
531
534
|
const rawCommand = input.command;
|
|
532
535
|
const allowedPrefixes = this.session.bashAllowedPrefixes;
|
|
536
|
+
const isRestrictedRalplanArtifactEnv =
|
|
537
|
+
allowedPrefixes &&
|
|
538
|
+
allowedPrefixes.length > 0 &&
|
|
539
|
+
this.session.bashRestrictionProfile !== "read-only" &&
|
|
540
|
+
env &&
|
|
541
|
+
Object.keys(env).length === 1 &&
|
|
542
|
+
Object.hasOwn(env, GJC_RALPLAN_ARTIFACT_ENV) &&
|
|
543
|
+
rawCommand.includes(`--artifact-env ${GJC_RALPLAN_ARTIFACT_ENV}`);
|
|
533
544
|
if (
|
|
534
545
|
(this.session.bashRestrictionProfile === "read-only" || (allowedPrefixes && allowedPrefixes.length > 0)) &&
|
|
535
546
|
env &&
|
|
536
|
-
Object.keys(env).length > 0
|
|
547
|
+
Object.keys(env).length > 0 &&
|
|
548
|
+
!isRestrictedRalplanArtifactEnv
|
|
537
549
|
) {
|
|
538
550
|
const mode = this.session.bashRestrictionProfile === "read-only" ? "Read-only" : "Restricted role-agent";
|
|
539
|
-
throw new ToolError(
|
|
551
|
+
throw new ToolError(
|
|
552
|
+
`${mode} bash only allows the ${GJC_RALPLAN_ARTIFACT_ENV} env override for --artifact-env.`,
|
|
553
|
+
);
|
|
540
554
|
}
|
|
541
555
|
if (allowedPrefixes && allowedPrefixes.length > 0) {
|
|
542
556
|
const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
|
|
@@ -597,11 +611,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
597
611
|
await Promise.all(
|
|
598
612
|
Object.entries(env).map(async ([key, value]) => [
|
|
599
613
|
key,
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
614
|
+
key === GJC_RALPLAN_ARTIFACT_ENV
|
|
615
|
+
? value
|
|
616
|
+
: await expandInternalUrls(value, {
|
|
617
|
+
...internalUrlOptions,
|
|
618
|
+
ensureLocalParentDirs: true,
|
|
619
|
+
noEscape: true,
|
|
620
|
+
}),
|
|
605
621
|
]),
|
|
606
622
|
),
|
|
607
623
|
)
|
|
@@ -817,9 +833,22 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
817
833
|
});
|
|
818
834
|
}
|
|
819
835
|
|
|
836
|
+
// Route through the client terminal when the client advertises the terminal capability.
|
|
837
|
+
// Skip when pty=true (PTY needs the local terminal UI).
|
|
838
|
+
const clientBridge =
|
|
839
|
+
this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
|
|
840
|
+
const clientTerminalActive = Boolean(clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty);
|
|
841
|
+
|
|
820
842
|
const autoBgManager = AsyncJobManager.instance();
|
|
821
|
-
|
|
822
|
-
|
|
843
|
+
// Run non-PTY bash through the managed job path so Ctrl+B-twice fold-on-demand works
|
|
844
|
+
// even when auto-background is disabled. When a client terminal will handle the
|
|
845
|
+
// command, keep the existing bridge path unless auto-background is enabled.
|
|
846
|
+
if (!pty && autoBgManager && (this.#autoBackgroundEnabled || !clientTerminalActive)) {
|
|
847
|
+
// With auto-background off, wait past the command's own timeout so the job only
|
|
848
|
+
// leaves the foreground on an explicit Ctrl+B fold, never on an auto-background timer.
|
|
849
|
+
const autoBackgroundWaitMs = this.#autoBackgroundEnabled
|
|
850
|
+
? this.#resolveAutoBackgroundWaitMs(timeoutMs)
|
|
851
|
+
: timeoutMs + 1_000;
|
|
823
852
|
const startBackgrounded = autoBackgroundWaitMs === 0;
|
|
824
853
|
const job = this.#startManagedBashJob({
|
|
825
854
|
command,
|
|
@@ -875,10 +904,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
875
904
|
});
|
|
876
905
|
}
|
|
877
906
|
|
|
878
|
-
// Route through the client terminal when the client advertises the terminal capability.
|
|
879
|
-
// Skip when pty=true (PTY needs the local terminal UI).
|
|
880
|
-
const clientBridge =
|
|
881
|
-
this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
|
|
882
907
|
if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
|
|
883
908
|
const handle = await clientBridge.createTerminal({
|
|
884
909
|
command,
|
package/src/tools/index.ts
CHANGED
|
@@ -325,7 +325,14 @@ export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool |
|
|
|
325
325
|
export type BuiltinToolLoadMode = "essential" | "discoverable";
|
|
326
326
|
|
|
327
327
|
/** Default essential tool names when tools.essentialOverride is empty. */
|
|
328
|
-
export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = [
|
|
328
|
+
export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = [
|
|
329
|
+
"read",
|
|
330
|
+
"bash",
|
|
331
|
+
"edit",
|
|
332
|
+
"write",
|
|
333
|
+
"search",
|
|
334
|
+
"find",
|
|
335
|
+
] as const;
|
|
329
336
|
|
|
330
337
|
/**
|
|
331
338
|
* Resolve the active essential built-in tool names from settings.
|
|
@@ -2,14 +2,25 @@ import { logger } from "@gajae-code/utils";
|
|
|
2
2
|
|
|
3
3
|
const CMUX_COMMAND = "cmux";
|
|
4
4
|
const CMUX_WORKSPACE_ID_ENV = "CMUX_WORKSPACE_ID";
|
|
5
|
+
const CMUX_NO_RENAME_ENV = "GJC_NO_CMUX_RENAME";
|
|
5
6
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
7
|
+
const CMUX_WORKSPACE_TITLE_PREFIX = "GJC: ";
|
|
6
8
|
const CMUX_WORKSPACE_RENAME_TIMEOUT_MS = 1500;
|
|
9
|
+
const CMUX_WORKSPACE_LIST_TIMEOUT_MS = 1500;
|
|
7
10
|
|
|
8
11
|
export interface CmuxWorkspaceRenameCommand {
|
|
9
12
|
command: string;
|
|
10
13
|
args: string[];
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
/** Current ownership state of a cmux workspace, read back from `cmux workspace list`. */
|
|
17
|
+
export interface CmuxWorkspaceOwnership {
|
|
18
|
+
/** cmux marks a workspace `has_custom_title` once an explicit title is set (by the user or by GJC). */
|
|
19
|
+
hasCustomTitle: boolean;
|
|
20
|
+
/** The workspace's current display title. */
|
|
21
|
+
title: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export interface CmuxWorkspaceRenameProcess {
|
|
14
25
|
exited: Promise<number>;
|
|
15
26
|
kill(): void;
|
|
@@ -24,6 +35,12 @@ export interface CmuxWorkspaceTitleSyncOptions {
|
|
|
24
35
|
command: string[],
|
|
25
36
|
options: { env: NodeJS.ProcessEnv; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" },
|
|
26
37
|
) => CmuxWorkspaceRenameProcess;
|
|
38
|
+
/** Reads the current ownership state of `workspaceId`. Injectable for tests. */
|
|
39
|
+
readOwnership?: (
|
|
40
|
+
cmuxCommand: string,
|
|
41
|
+
workspaceId: string,
|
|
42
|
+
env: NodeJS.ProcessEnv,
|
|
43
|
+
) => Promise<CmuxWorkspaceOwnership | null>;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
function defaultSpawn(
|
|
@@ -33,12 +50,24 @@ function defaultSpawn(
|
|
|
33
50
|
return Bun.spawn(command, options);
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
function isEnvSet(value: string | undefined): boolean {
|
|
54
|
+
if (!value) return false;
|
|
55
|
+
const normalized = value.trim().toLowerCase();
|
|
56
|
+
return normalized !== "" && normalized !== "0" && normalized !== "false";
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export function sanitizeCmuxWorkspaceTitle(title: string | undefined): string | undefined {
|
|
37
60
|
if (!title) return undefined;
|
|
38
61
|
const sanitized = title.replace(CONTROL_CHARS, " ").replace(/\s+/g, " ").trim();
|
|
39
62
|
return sanitized || undefined;
|
|
40
63
|
}
|
|
41
64
|
|
|
65
|
+
export function formatCmuxWorkspaceTitle(title: string | undefined): string | undefined {
|
|
66
|
+
const sanitized = sanitizeCmuxWorkspaceTitle(title);
|
|
67
|
+
if (!sanitized) return undefined;
|
|
68
|
+
return sanitized.startsWith(CMUX_WORKSPACE_TITLE_PREFIX) ? sanitized : `${CMUX_WORKSPACE_TITLE_PREFIX}${sanitized}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
export function buildCmuxWorkspaceRenameCommand(
|
|
43
72
|
sessionName: string | undefined,
|
|
44
73
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -46,7 +75,7 @@ export function buildCmuxWorkspaceRenameCommand(
|
|
|
46
75
|
const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
|
|
47
76
|
if (!workspaceId) return null;
|
|
48
77
|
|
|
49
|
-
const title =
|
|
78
|
+
const title = formatCmuxWorkspaceTitle(sessionName);
|
|
50
79
|
if (!title) return null;
|
|
51
80
|
|
|
52
81
|
return {
|
|
@@ -55,27 +84,125 @@ export function buildCmuxWorkspaceRenameCommand(
|
|
|
55
84
|
};
|
|
56
85
|
}
|
|
57
86
|
|
|
58
|
-
|
|
87
|
+
/** Parse `cmux workspace list --json --id-format both` output and return the
|
|
88
|
+
* ownership state of the workspace matching `workspaceId` (by UUID `id` or `ref`). */
|
|
89
|
+
export function parseCmuxWorkspaceOwnership(jsonText: string, workspaceId: string): CmuxWorkspaceOwnership | null {
|
|
90
|
+
const target = workspaceId.trim().toLowerCase();
|
|
91
|
+
if (!target) return null;
|
|
92
|
+
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(jsonText);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const workspaces = (parsed as { workspaces?: unknown }).workspaces;
|
|
101
|
+
if (!Array.isArray(workspaces)) return null;
|
|
102
|
+
|
|
103
|
+
for (const entry of workspaces) {
|
|
104
|
+
if (!entry || typeof entry !== "object") continue;
|
|
105
|
+
const record = entry as Record<string, unknown>;
|
|
106
|
+
const id = typeof record.id === "string" ? record.id.toLowerCase() : "";
|
|
107
|
+
const ref = typeof record.ref === "string" ? record.ref.toLowerCase() : "";
|
|
108
|
+
if (id !== target && ref !== target) continue;
|
|
109
|
+
return {
|
|
110
|
+
hasCustomTitle: record.has_custom_title === true,
|
|
111
|
+
title: typeof record.title === "string" ? record.title : "",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Only rename when GJC owns the name:
|
|
118
|
+
* - unknown ownership (read failed) → skip (fail safe, never clobber)
|
|
119
|
+
* - already the desired title → skip (no-op)
|
|
120
|
+
* - workspace still on its default title → rename
|
|
121
|
+
* - any custom title (user- or peer-set) → skip
|
|
122
|
+
* This makes GJC name a fresh workspace once and then leave it alone, so it
|
|
123
|
+
* never overwrites a user-pinned name and multiple sessions sharing one
|
|
124
|
+
* CMUX_WORKSPACE_ID do not thrash the workspace title. */
|
|
125
|
+
export function shouldRenameCmuxWorkspace(ownership: CmuxWorkspaceOwnership | null, desiredTitle: string): boolean {
|
|
126
|
+
if (!ownership) return false;
|
|
127
|
+
if ((sanitizeCmuxWorkspaceTitle(ownership.title) ?? "") === desiredTitle) return false;
|
|
128
|
+
return !ownership.hasCustomTitle;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function defaultReadOwnership(
|
|
132
|
+
cmuxCommand: string,
|
|
133
|
+
workspaceId: string,
|
|
134
|
+
env: NodeJS.ProcessEnv,
|
|
135
|
+
): Promise<CmuxWorkspaceOwnership | null> {
|
|
136
|
+
try {
|
|
137
|
+
const proc = Bun.spawn([cmuxCommand, "workspace", "list", "--json", "--id-format", "both"], {
|
|
138
|
+
env: { ...env, CMUX_QUIET: "1" },
|
|
139
|
+
stdin: "ignore",
|
|
140
|
+
stdout: "pipe",
|
|
141
|
+
stderr: "ignore",
|
|
142
|
+
});
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
try {
|
|
145
|
+
proc.kill();
|
|
146
|
+
} catch {}
|
|
147
|
+
}, CMUX_WORKSPACE_LIST_TIMEOUT_MS);
|
|
148
|
+
timer.unref?.();
|
|
149
|
+
const text = await new Response(proc.stdout).text();
|
|
150
|
+
await proc.exited;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
return parseCmuxWorkspaceOwnership(text, workspaceId);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logger.debug("cmux workspace list failed", { error: String(error) });
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Best-effort sync of the containing cmux workspace title to the current GJC
|
|
161
|
+
* session name. Ownership-guarded: GJC reads the current workspace title and
|
|
162
|
+
* only renames a workspace that still has its default title, so it never
|
|
163
|
+
* overwrites a name the user pinned or a name a peer session (sharing the same
|
|
164
|
+
* CMUX_WORKSPACE_ID) set. Opt out with GJC_NO_CMUX_RENAME.
|
|
165
|
+
*/
|
|
166
|
+
export async function syncCmuxWorkspaceTitle(
|
|
59
167
|
sessionName: string | undefined,
|
|
60
168
|
options: CmuxWorkspaceTitleSyncOptions = {},
|
|
61
|
-
): void {
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
const env = options.env ?? process.env;
|
|
171
|
+
if (isEnvSet(env[CMUX_NO_RENAME_ENV])) return;
|
|
172
|
+
|
|
62
173
|
const isTty = options.isTty ?? process.stdout.isTTY === true;
|
|
63
174
|
if (!isTty) return;
|
|
64
175
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
176
|
+
const workspaceId = env[CMUX_WORKSPACE_ID_ENV]?.trim();
|
|
177
|
+
if (!workspaceId) return;
|
|
178
|
+
|
|
179
|
+
const desired = formatCmuxWorkspaceTitle(sessionName);
|
|
180
|
+
if (!desired) return;
|
|
68
181
|
|
|
69
182
|
const which = options.which ?? Bun.which;
|
|
70
183
|
let resolvedCommand: string | null;
|
|
71
184
|
try {
|
|
72
|
-
resolvedCommand = which(
|
|
185
|
+
resolvedCommand = which(CMUX_COMMAND);
|
|
73
186
|
} catch (error) {
|
|
74
187
|
logger.debug("cmux workspace rename command lookup failed", { error: String(error) });
|
|
75
188
|
return;
|
|
76
189
|
}
|
|
77
190
|
if (!resolvedCommand) return;
|
|
78
191
|
|
|
192
|
+
let ownership: CmuxWorkspaceOwnership | null;
|
|
193
|
+
try {
|
|
194
|
+
const readOwnership = options.readOwnership ?? defaultReadOwnership;
|
|
195
|
+
ownership = await readOwnership(resolvedCommand, workspaceId, env);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.debug("cmux workspace ownership read failed", { error: String(error) });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!shouldRenameCmuxWorkspace(ownership, desired)) return;
|
|
202
|
+
|
|
203
|
+
const plan = buildCmuxWorkspaceRenameCommand(sessionName, env);
|
|
204
|
+
if (!plan) return;
|
|
205
|
+
|
|
79
206
|
const spawn = options.spawn ?? defaultSpawn;
|
|
80
207
|
try {
|
|
81
208
|
const proc = spawn([resolvedCommand, ...plan.args], {
|
|
@@ -219,7 +219,7 @@ export function setTerminalTitle(title: string): void {
|
|
|
219
219
|
|
|
220
220
|
export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
|
|
221
221
|
setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
|
|
222
|
-
syncCmuxWorkspaceTitle(sessionName);
|
|
222
|
+
void syncCmuxWorkspaceTitle(sessionName);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
/**
|
package/src/web/kagi.ts
CHANGED
|
@@ -147,7 +147,7 @@ export async function searchWithKagi(
|
|
|
147
147
|
|
|
148
148
|
const response = await fetch(requestUrl, {
|
|
149
149
|
headers: getAuthHeaders(apiKey),
|
|
150
|
-
signal: withHardTimeout(options.signal),
|
|
150
|
+
signal: withHardTimeout(options.signal, "api"),
|
|
151
151
|
});
|
|
152
152
|
if (!response.ok) {
|
|
153
153
|
throw parseKagiErrorResponse(response.status, await response.text());
|
package/src/web/parallel.ts
CHANGED
|
@@ -306,7 +306,7 @@ export async function searchWithParallel(
|
|
|
306
306
|
max_chars_per_result: options.maxCharsPerResult ?? 10_000,
|
|
307
307
|
},
|
|
308
308
|
}),
|
|
309
|
-
signal: withHardTimeout(options.signal),
|
|
309
|
+
signal: withHardTimeout(options.signal, "api"),
|
|
310
310
|
});
|
|
311
311
|
if (!response.ok) {
|
|
312
312
|
throw parseParallelErrorResponse(response.status, await response.text());
|
|
@@ -338,7 +338,7 @@ export async function extractWithParallel(
|
|
|
338
338
|
excerpts: options.excerpts ?? true,
|
|
339
339
|
full_content: options.fullContent ?? false,
|
|
340
340
|
}),
|
|
341
|
-
signal: withHardTimeout(options.signal),
|
|
341
|
+
signal: withHardTimeout(options.signal, "api"),
|
|
342
342
|
});
|
|
343
343
|
if (!response.ok) {
|
|
344
344
|
throw parseParallelErrorResponse(response.status, await response.text());
|
package/src/web/search/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { discoverAuthStorage } from "../../sdk";
|
|
|
16
16
|
import type { ToolSession } from "../../tools";
|
|
17
17
|
import { formatAge } from "../../tools/render-utils";
|
|
18
18
|
import { throwIfAborted } from "../../tools/tool-errors";
|
|
19
|
-
import { getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
|
|
19
|
+
import { getSearchProviderLabel, prewarmSearchProviders, resolveProviderChain, type SearchProvider } from "./provider";
|
|
20
20
|
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
|
|
21
21
|
import type { ActiveSearchModelContext, SearchProviderId, SearchResponse } from "./types";
|
|
22
22
|
import { SearchProviderError } from "./types";
|
|
@@ -148,6 +148,22 @@ interface ExecuteSearchOptions {
|
|
|
148
148
|
activeModelContext?: ActiveSearchModelContext;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Delay before the keyless DuckDuckGo hedge fires while a slower primary is
|
|
153
|
+
* still in flight. Chosen to be comfortably above DDG's typical ~1-2s
|
|
154
|
+
* round-trip cost budget yet far below LLM-mediated primary latency, so a
|
|
155
|
+
* failing primary can fall back to an already-completed hedge result instead
|
|
156
|
+
* of paying primary-failure time plus DDG time sequentially.
|
|
157
|
+
*/
|
|
158
|
+
const DDG_HEDGE_DELAY_MS = 3_000;
|
|
159
|
+
|
|
160
|
+
let ddgHedgeDelayMs = DDG_HEDGE_DELAY_MS;
|
|
161
|
+
|
|
162
|
+
/** Override the hedge delay (tests). Non-finite/non-positive resets to default. */
|
|
163
|
+
export function setDdgHedgeDelayMs(ms: number | undefined): void {
|
|
164
|
+
ddgHedgeDelayMs = typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : DDG_HEDGE_DELAY_MS;
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
/** Execute web search */
|
|
152
168
|
async function executeSearch(
|
|
153
169
|
_toolCallId: string,
|
|
@@ -166,52 +182,91 @@ async function executeSearch(
|
|
|
166
182
|
activeModelContext,
|
|
167
183
|
});
|
|
168
184
|
|
|
185
|
+
const baseSearchParams = {
|
|
186
|
+
query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
|
|
187
|
+
limit: params.limit,
|
|
188
|
+
recency: params.recency,
|
|
189
|
+
systemPrompt: webSearchSystemPrompt,
|
|
190
|
+
maxOutputTokens: params.max_tokens,
|
|
191
|
+
numSearchResults: params.num_search_results,
|
|
192
|
+
temperature: params.temperature,
|
|
193
|
+
xaiSearchMode: params.xai_search_mode,
|
|
194
|
+
allowedDomains: params.allowed_domains,
|
|
195
|
+
excludedDomains: params.excluded_domains,
|
|
196
|
+
allowedXHandles: params.allowed_x_handles,
|
|
197
|
+
excludedXHandles: params.excluded_x_handles,
|
|
198
|
+
fromDate: params.from_date,
|
|
199
|
+
toDate: params.to_date,
|
|
200
|
+
enableImageUnderstanding: params.enable_image_understanding,
|
|
201
|
+
enableImageSearch: params.enable_image_search,
|
|
202
|
+
enableVideoUnderstanding: params.enable_video_understanding,
|
|
203
|
+
noInlineCitations: params.no_inline_citations,
|
|
204
|
+
authStorage,
|
|
205
|
+
sessionId,
|
|
206
|
+
activeModelContext,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Hedged fallback: when DuckDuckGo (keyless, cheap) is a non-primary member
|
|
210
|
+
// of the chain, start it in the background after a short delay. If the
|
|
211
|
+
// primary succeeds first the hedge is aborted; if the primary fails, the
|
|
212
|
+
// hedge result is typically already available, collapsing the fallback
|
|
213
|
+
// latency from `t(primary failure) + t(ddg)` to `max(t(primary failure), t(ddg))`.
|
|
214
|
+
const ddgIndex = providers.findIndex(p => p.id === "duckduckgo");
|
|
215
|
+
const hedgeCtl = new AbortController();
|
|
216
|
+
const onUpstreamAbort = () => hedgeCtl.abort();
|
|
217
|
+
signal?.addEventListener("abort", onUpstreamAbort, { once: true });
|
|
218
|
+
let hedgePromise: Promise<SearchResponse> | undefined;
|
|
219
|
+
let hedgeTimer: NodeJS.Timeout | undefined;
|
|
220
|
+
if (ddgIndex > 0) {
|
|
221
|
+
const ddg = providers[ddgIndex]!;
|
|
222
|
+
hedgeTimer = setTimeout(() => {
|
|
223
|
+
hedgePromise = ddg.search({ ...baseSearchParams, signal: hedgeCtl.signal });
|
|
224
|
+
// Failures are consumed when (and if) the loop reaches DuckDuckGo;
|
|
225
|
+
// never let a losing hedge become an unhandled rejection.
|
|
226
|
+
hedgePromise.catch(() => {});
|
|
227
|
+
}, ddgHedgeDelayMs);
|
|
228
|
+
}
|
|
229
|
+
const cancelHedge = () => {
|
|
230
|
+
if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
|
|
231
|
+
hedgeCtl.abort();
|
|
232
|
+
signal?.removeEventListener("abort", onUpstreamAbort);
|
|
233
|
+
};
|
|
234
|
+
|
|
169
235
|
const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
|
|
170
236
|
let lastProvider = providers[0];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
|
|
204
|
-
details: { response, ...(warning ? { warning } : {}) },
|
|
205
|
-
};
|
|
206
|
-
} catch (error) {
|
|
207
|
-
// Surface user-initiated cancellation immediately so the session sees
|
|
208
|
-
// a clean abort instead of a generic "all providers failed" message.
|
|
209
|
-
// Without this, an AbortError from `fetch()` is treated as a provider
|
|
210
|
-
// failure and the loop falls through to the next provider (or to the
|
|
211
|
-
// summary error), masking the cancellation.
|
|
212
|
-
throwIfAborted(signal);
|
|
213
|
-
failures.push({ provider, error });
|
|
237
|
+
try {
|
|
238
|
+
for (const provider of providers) {
|
|
239
|
+
lastProvider = provider;
|
|
240
|
+
try {
|
|
241
|
+
let response: SearchResponse;
|
|
242
|
+
if (provider.id === "duckduckgo" && hedgePromise) {
|
|
243
|
+
// Reuse the in-flight (often already-settled) hedge request.
|
|
244
|
+
if (hedgeTimer !== undefined) clearTimeout(hedgeTimer);
|
|
245
|
+
response = await hedgePromise;
|
|
246
|
+
} else {
|
|
247
|
+
if (provider.id === "duckduckgo" && hedgeTimer !== undefined) clearTimeout(hedgeTimer);
|
|
248
|
+
response = await provider.search({ ...baseSearchParams, signal });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const text = formatForLLM(response);
|
|
252
|
+
const warning = failures.length > 0 ? formatFallbackWarning(failures, provider) : undefined;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text" as const, text: warning ? `Warning: ${warning}\n\n${text}` : text }],
|
|
256
|
+
details: { response, ...(warning ? { warning } : {}) },
|
|
257
|
+
};
|
|
258
|
+
} catch (error) {
|
|
259
|
+
// Surface user-initiated cancellation immediately so the session sees
|
|
260
|
+
// a clean abort instead of a generic "all providers failed" message.
|
|
261
|
+
// Without this, an AbortError from `fetch()` is treated as a provider
|
|
262
|
+
// failure and the loop falls through to the next provider (or to the
|
|
263
|
+
// summary error), masking the cancellation.
|
|
264
|
+
throwIfAborted(signal);
|
|
265
|
+
failures.push({ provider, error });
|
|
266
|
+
}
|
|
214
267
|
}
|
|
268
|
+
} finally {
|
|
269
|
+
cancelHedge();
|
|
215
270
|
}
|
|
216
271
|
|
|
217
272
|
const lastFailure = failures[failures.length - 1];
|
|
@@ -273,6 +328,24 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
|
|
|
273
328
|
constructor(session: ToolSession) {
|
|
274
329
|
this.#session = session;
|
|
275
330
|
this.description = prompt.render(webSearchDescription);
|
|
331
|
+
// Prewarm: resolve the provider chain once in the background so the
|
|
332
|
+
// provider modules are imported and the chain cache is primed before
|
|
333
|
+
// the first user-visible search. Best-effort only.
|
|
334
|
+
const authStorage = session.authStorage;
|
|
335
|
+
if (authStorage) {
|
|
336
|
+
try {
|
|
337
|
+
const activeModelContext = session.model
|
|
338
|
+
? session.modelRegistry?.getActiveSearchModelContext(session.model)
|
|
339
|
+
: undefined;
|
|
340
|
+
prewarmSearchProviders({
|
|
341
|
+
authStorage,
|
|
342
|
+
sessionId: session.getSessionId?.() ?? undefined,
|
|
343
|
+
activeModelContext,
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
// Never let prewarming break tool construction.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
276
349
|
}
|
|
277
350
|
|
|
278
351
|
async execute(
|
|
@@ -334,6 +407,6 @@ export {
|
|
|
334
407
|
setPreferredSearchProvider,
|
|
335
408
|
setSearchFallbackProviders,
|
|
336
409
|
} from "./provider";
|
|
337
|
-
export { setSearchHardTimeoutMs } from "./providers/utils";
|
|
410
|
+
export { applyConfiguredSearchTimeout, setSearchHardTimeoutMs } from "./providers/utils";
|
|
338
411
|
export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
|
|
339
412
|
export { isConfigurableSearchProviderId, isSearchProviderPreference } from "./types";
|
|
@@ -141,12 +141,63 @@ const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
|
|
|
141
141
|
let preferredProvId: SearchProviderId | "auto" = "auto";
|
|
142
142
|
let fallbackProvIds: SearchProviderId[] = [];
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolved-chain cache. Chain resolution probes provider availability
|
|
146
|
+
* (credential lookups, potentially an OAuth refresh through the broker) on
|
|
147
|
+
* every web_search call even though the result only changes when the active
|
|
148
|
+
* model, provider preference, or credentials change. Cache the resolved
|
|
149
|
+
* provider-id list per AuthStorage instance for a short TTL.
|
|
150
|
+
*
|
|
151
|
+
* Keyed by AuthStorage identity (WeakMap) so tests and short-lived CLI calls
|
|
152
|
+
* with fresh storage handles never see another handle's chain. The TTL keeps
|
|
153
|
+
* a login/logout mid-session from being masked for more than a minute.
|
|
154
|
+
*/
|
|
155
|
+
const CHAIN_CACHE_TTL_MS = 60_000;
|
|
156
|
+
let chainCache = new WeakMap<AuthStorage, Map<string, { ids: SearchProviderId[]; expires: number }>>();
|
|
157
|
+
|
|
158
|
+
/** Drop every cached resolved chain (settings changed, tests). */
|
|
159
|
+
export function clearResolvedChainCache(): void {
|
|
160
|
+
chainCache = new WeakMap();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function chainCacheKey(
|
|
164
|
+
preferred: SearchProviderId | "auto" | undefined,
|
|
165
|
+
fallbacks: readonly SearchProviderId[],
|
|
166
|
+
ctx: ActiveSearchModelContext | undefined,
|
|
167
|
+
authStorage: AuthStorage,
|
|
168
|
+
): string {
|
|
169
|
+
// Include the AuthStorage generation so credential changes (login/logout,
|
|
170
|
+
// key rotation) invalidate cached chains immediately instead of being
|
|
171
|
+
// masked for up to the TTL. Stubs without getGeneration() key as 0.
|
|
172
|
+
const generation = (authStorage as { getGeneration?: () => number }).getGeneration?.() ?? 0;
|
|
173
|
+
return JSON.stringify([
|
|
174
|
+
generation,
|
|
175
|
+
preferred ?? null,
|
|
176
|
+
fallbacks,
|
|
177
|
+
ctx
|
|
178
|
+
? [ctx.provider, ctx.modelId, ctx.wireModelId ?? null, ctx.api, ctx.baseUrl ?? null, ctx.webSearch ?? null]
|
|
179
|
+
: null,
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void {
|
|
145
184
|
preferredProvId = provider;
|
|
185
|
+
clearResolvedChainCache();
|
|
146
186
|
}
|
|
147
187
|
|
|
148
188
|
export function setSearchFallbackProviders(ids: readonly string[]): void {
|
|
149
189
|
fallbackProvIds = ids.filter(isConfigurableSearchProviderId);
|
|
190
|
+
clearResolvedChainCache();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fire-and-forget warm-up: resolve the provider chain once so the provider
|
|
195
|
+
* modules are imported and the chain cache is primed before the first
|
|
196
|
+
* user-visible search. Errors are intentionally swallowed — prewarming must
|
|
197
|
+
* never surface a failure the real search path would handle itself.
|
|
198
|
+
*/
|
|
199
|
+
export function prewarmSearchProviders(options: ResolveProviderChainOptions): void {
|
|
200
|
+
void resolveProviderChain(options).catch(() => {});
|
|
150
201
|
}
|
|
151
202
|
|
|
152
203
|
export interface SearchProviderPreferenceSource {
|
|
@@ -357,6 +408,16 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
|
|
|
357
408
|
activeModelContext,
|
|
358
409
|
fallbackProviders = fallbackProvIds,
|
|
359
410
|
} = options;
|
|
411
|
+
|
|
412
|
+
const cacheKey = chainCacheKey(preferredProvider, fallbackProviders, activeModelContext, authStorage);
|
|
413
|
+
const perStorage = chainCache.get(authStorage);
|
|
414
|
+
const cached = perStorage?.get(cacheKey);
|
|
415
|
+
if (cached && cached.expires > Date.now()) {
|
|
416
|
+
const cachedProviders: SearchProvider[] = [];
|
|
417
|
+
for (const id of cached.ids) cachedProviders.push(await getSearchProvider(id));
|
|
418
|
+
return cachedProviders;
|
|
419
|
+
}
|
|
420
|
+
|
|
360
421
|
const chain: SearchProviderId[] = [];
|
|
361
422
|
|
|
362
423
|
// A forced primary is honored only when it is a user-configurable provider.
|
|
@@ -392,6 +453,10 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
|
|
|
392
453
|
}
|
|
393
454
|
appendDeduped(chain, "duckduckgo");
|
|
394
455
|
|
|
456
|
+
const storageEntry = chainCache.get(authStorage) ?? new Map<string, { ids: SearchProviderId[]; expires: number }>();
|
|
457
|
+
storageEntry.set(cacheKey, { ids: [...chain], expires: Date.now() + CHAIN_CACHE_TTL_MS });
|
|
458
|
+
chainCache.set(authStorage, storageEntry);
|
|
459
|
+
|
|
395
460
|
const providers: SearchProvider[] = [];
|
|
396
461
|
for (const id of chain) providers.push(await getSearchProvider(id));
|
|
397
462
|
return providers;
|