@gajae-code/coding-agent 0.7.6 → 0.7.8
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 +26 -1
- package/README.md +8 -0
- package/dist/types/cli/local-provider-smoke.d.ts +59 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/update-cli.d.ts +24 -1
- package/dist/types/commands/local-provider.d.ts +34 -0
- package/dist/types/config/model-profiles.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +8 -3
- package/dist/types/config/models-config-schema.d.ts +5 -5
- package/dist/types/config/settings-schema.d.ts +123 -9
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +8 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
- package/dist/types/gjc-runtime/state-renderer.d.ts +5 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +37 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +80 -0
- package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +4 -1
- package/dist/types/modes/components/status-line/types.d.ts +5 -7
- package/dist/types/modes/components/status-line.d.ts +4 -3
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
- package/dist/types/modes/tmux-scroll.d.ts +18 -0
- package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
- package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
- package/dist/types/tools/computer-gc.d.ts +23 -0
- package/dist/types/tools/cron.d.ts +36 -61
- package/dist/types/tools/index.d.ts +0 -1
- package/dist/types/tools/resource-gc.d.ts +54 -0
- package/dist/types/web/search/index.d.ts +1 -0
- package/dist/types/web/search/providers/utils.d.ts +11 -4
- package/package.json +7 -7
- package/src/cli/args.ts +0 -1
- package/src/cli/fast-help.ts +0 -1
- package/src/cli/local-provider-smoke.ts +632 -0
- package/src/cli/notify-cli.ts +4 -2
- package/src/cli/update-cli.ts +155 -13
- package/src/cli/web-search-cli.ts +5 -0
- package/src/cli.ts +18 -0
- package/src/commands/launch.ts +46 -1
- package/src/commands/local-provider.ts +62 -0
- package/src/commands/notify.ts +1 -1
- package/src/config/model-profiles.ts +3 -4
- package/src/config/model-registry.ts +58 -7
- package/src/config/models-config-schema.ts +10 -1
- package/src/config/settings-schema.ts +127 -10
- package/src/config/settings.ts +11 -0
- package/src/coordinator-mcp/server.ts +276 -34
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -12
- package/src/gjc-runtime/launch-tmux.ts +34 -8
- package/src/gjc-runtime/launch-worktree.ts +16 -2
- package/src/gjc-runtime/state-renderer.ts +13 -0
- package/src/gjc-runtime/team-runtime.ts +21 -6
- package/src/gjc-runtime/tmux-common.ts +16 -0
- package/src/gjc-runtime/tmux-sessions.ts +13 -9
- package/src/gjc-runtime/ultragoal-guard.ts +167 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +221 -1
- package/src/goals/tools/goal-tool.ts +14 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/modes/components/agent-dashboard.ts +2 -1
- package/src/modes/components/custom-model-preset-wizard.ts +30 -189
- package/src/modes/components/model-selector.ts +182 -5
- package/src/modes/components/session-observer-overlay.ts +3 -3
- package/src/modes/components/settings-selector.ts +491 -5
- package/src/modes/components/status-line/segments.ts +7 -18
- package/src/modes/components/status-line/types.ts +6 -2
- package/src/modes/components/status-line.ts +69 -35
- package/src/modes/controllers/event-controller.ts +98 -4
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/input-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +18 -4
- package/src/modes/prompt-action-autocomplete.ts +8 -0
- package/src/modes/tmux-scroll.ts +92 -0
- package/src/modes/utils/terminal-bell.ts +48 -0
- package/src/notifications/telegram-daemon-cli.ts +112 -9
- package/src/notifications/telegram-daemon.ts +4 -0
- package/src/prompts/system/system-prompt.md +2 -2
- package/src/prompts/tools/cron.md +5 -3
- package/src/prompts/tools/read.md +1 -1
- package/src/sdk.ts +5 -0
- package/src/session/agent-session.ts +118 -5
- package/src/slash-commands/builtin-registry.ts +106 -13
- package/src/tools/browser/tab-supervisor.ts +86 -2
- package/src/tools/computer-gc.ts +66 -0
- package/src/tools/computer.ts +2 -0
- package/src/tools/cron.ts +75 -112
- package/src/tools/index.ts +2 -8
- package/src/tools/read.ts +25 -55
- package/src/tools/renderers.ts +0 -2
- package/src/tools/resource-gc.ts +291 -0
- package/src/tools/ultragoal-ask-guard.ts +7 -1
- package/src/web/search/index.ts +1 -0
- package/src/web/search/providers/utils.ts +22 -5
- package/vendor/insane-search/MANIFEST.json +3 -1
- package/vendor/insane-search/engine/__init__.py +14 -0
- package/vendor/insane-search/engine/content_safety.py +151 -0
- package/vendor/insane-search/engine/fetch_chain.py +32 -0
- package/vendor/insane-search/engine/tests/test_u8.py +216 -0
- package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
- package/dist/types/tools/inspect-image.d.ts +0 -31
- package/src/prompts/tools/inspect-image-system.md +0 -20
- package/src/prompts/tools/inspect-image.md +0 -32
- package/src/tools/inspect-image-renderer.ts +0 -103
- package/src/tools/inspect-image.ts +0 -172
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { YAML } from "bun";
|
|
4
|
+
import type { Settings } from "../config/settings";
|
|
4
5
|
import { getNotificationConfig, isGloballyConfigured } from "./config";
|
|
5
6
|
import { daemonPaths, TelegramNotificationDaemon } from "./telegram-daemon";
|
|
6
7
|
import { clearTelegramControlRequest, readTelegramControlRequest } from "./telegram-daemon-control";
|
|
7
8
|
|
|
8
9
|
export interface RunDaemonInternalDeps {
|
|
9
|
-
SettingsImpl?: Pick<
|
|
10
|
+
SettingsImpl?: { init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir">> };
|
|
10
11
|
DaemonImpl?: typeof TelegramNotificationDaemon;
|
|
11
12
|
processPid?: number;
|
|
13
|
+
pidAlive?: (pid: number) => boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
function argValue(argv: string[], name: string): string | undefined {
|
|
@@ -16,9 +18,105 @@ function argValue(argv: string[], name: string): string | undefined {
|
|
|
16
18
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
function getByPath(obj: unknown, pathSegments: string[]): unknown {
|
|
22
|
+
let current = obj;
|
|
23
|
+
for (const segment of pathSegments) {
|
|
24
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
|
|
25
|
+
current = (current as Record<string, unknown>)[segment];
|
|
26
|
+
}
|
|
27
|
+
return current;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function asString(value: unknown): string | undefined {
|
|
31
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
35
|
+
return typeof value === "boolean" ? value : fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function asIdleTimeoutMs(value: unknown): number {
|
|
39
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 60_000;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createLightweightDaemonSettings(input: {
|
|
43
|
+
agentDir: string;
|
|
44
|
+
rawConfig?: unknown;
|
|
45
|
+
}): Pick<Settings, "get" | "getAgentDir"> {
|
|
46
|
+
const rawConfig = input.rawConfig && typeof input.rawConfig === "object" ? input.rawConfig : {};
|
|
47
|
+
return {
|
|
48
|
+
get(pathName: string): unknown {
|
|
49
|
+
const value = getByPath(rawConfig, pathName.split("."));
|
|
50
|
+
switch (pathName) {
|
|
51
|
+
case "notifications.enabled":
|
|
52
|
+
return asBoolean(value, false);
|
|
53
|
+
case "notifications.telegram.botToken":
|
|
54
|
+
case "notifications.telegram.chatId":
|
|
55
|
+
case "notifications.discord.botToken":
|
|
56
|
+
case "notifications.discord.channelId":
|
|
57
|
+
case "notifications.slack.botToken":
|
|
58
|
+
case "notifications.slack.channelId":
|
|
59
|
+
return asString(value);
|
|
60
|
+
case "notifications.redact":
|
|
61
|
+
return asBoolean(value, false);
|
|
62
|
+
case "notifications.verbosity":
|
|
63
|
+
return value === "verbose" ? "verbose" : "lean";
|
|
64
|
+
case "notifications.daemon.idleTimeoutMs":
|
|
65
|
+
return asIdleTimeoutMs(value);
|
|
66
|
+
default:
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
getAgentDir(): string {
|
|
71
|
+
return input.agentDir;
|
|
72
|
+
},
|
|
73
|
+
} as Pick<Settings, "get" | "getAgentDir">;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function loadLightweightDaemonSettings(agentDir: string): Promise<Pick<Settings, "get" | "getAgentDir">> {
|
|
77
|
+
const configPath = path.join(agentDir, "config.yml");
|
|
78
|
+
let rawConfig: unknown = {};
|
|
79
|
+
try {
|
|
80
|
+
rawConfig = YAML.parse(await fs.promises.readFile(configPath, "utf8"));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
83
|
+
}
|
|
84
|
+
return createLightweightDaemonSettings({ agentDir, rawConfig });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveDaemonSettings(
|
|
88
|
+
agentDir: string,
|
|
89
|
+
deps: RunDaemonInternalDeps,
|
|
90
|
+
): Promise<Pick<Settings, "get" | "getAgentDir">> {
|
|
91
|
+
if (deps.SettingsImpl) return await deps.SettingsImpl.init({ agentDir });
|
|
92
|
+
return await loadLightweightDaemonSettings(agentDir);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ownerPidFromOwnerId(ownerId: string): number | undefined {
|
|
96
|
+
const match = /^(\d+)(?:-|$)/.exec(ownerId);
|
|
97
|
+
if (!match) return undefined;
|
|
98
|
+
const pid = Number(match[1]);
|
|
99
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function defaultPidAlive(pid: number): boolean {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, 0);
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ownerProcessIsAlive(ownerId: string, deps: RunDaemonInternalDeps): boolean {
|
|
112
|
+
const ownerPid = ownerPidFromOwnerId(ownerId);
|
|
113
|
+
if (ownerPid === undefined) return true;
|
|
114
|
+
return (deps.pidAlive ?? defaultPidAlive)(ownerPid);
|
|
115
|
+
}
|
|
116
|
+
|
|
19
117
|
export async function runDaemonSmoke(opts: { agentDir?: string } = {}): Promise<void> {
|
|
20
118
|
const agentDir = opts.agentDir ?? fs.mkdtempSync(path.join(process.cwd(), ".telegram-daemon-smoke-"));
|
|
21
|
-
const settings =
|
|
119
|
+
const settings = createLightweightDaemonSettings({ agentDir, rawConfig: {} });
|
|
22
120
|
const paths = daemonPaths(agentDir);
|
|
23
121
|
await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
24
122
|
const tempLock = `${paths.lock}.smoke.${process.pid}`;
|
|
@@ -34,12 +132,17 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
|
|
|
34
132
|
if (smoke) return runDaemonSmoke({ agentDir });
|
|
35
133
|
const ownerId = argValue(argv, "--owner-id");
|
|
36
134
|
if (!ownerId) throw new Error("missing --owner-id");
|
|
37
|
-
|
|
38
|
-
|
|
135
|
+
if (!ownerProcessIsAlive(ownerId, deps)) {
|
|
136
|
+
process.stderr.write(`GJC notify daemon exiting: owner process from --owner-id ${ownerId} is not alive.\n`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const resolvedAgentDir = agentDir ?? process.env.GJC_CODING_AGENT_DIR ?? path.join(process.cwd(), ".gjc", "agent");
|
|
140
|
+
const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
|
|
141
|
+
const cfg = getNotificationConfig(settings as Settings);
|
|
39
142
|
if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return;
|
|
40
143
|
const Daemon = deps.DaemonImpl ?? TelegramNotificationDaemon;
|
|
41
144
|
const daemon = new Daemon({
|
|
42
|
-
settings,
|
|
145
|
+
settings: settings as Settings,
|
|
43
146
|
ownerId,
|
|
44
147
|
botToken: cfg.botToken,
|
|
45
148
|
chatId: cfg.chatId,
|
|
@@ -47,15 +150,15 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
|
|
|
47
150
|
pid: deps.processPid ?? process.pid,
|
|
48
151
|
control: {
|
|
49
152
|
shouldStop: async owner => {
|
|
50
|
-
const req = await readTelegramControlRequest(settings);
|
|
153
|
+
const req = await readTelegramControlRequest(settings as Settings);
|
|
51
154
|
return Boolean(req && (!req.ownerId || req.ownerId === owner));
|
|
52
155
|
},
|
|
53
156
|
clear: async owner => {
|
|
54
|
-
const req = await readTelegramControlRequest(settings);
|
|
157
|
+
const req = await readTelegramControlRequest(settings as Settings);
|
|
55
158
|
// Only clear a request that targets this daemon owner, so an exiting
|
|
56
159
|
// daemon never erases a newer request meant for a different owner.
|
|
57
160
|
if (req && (!req.ownerId || req.ownerId === owner)) {
|
|
58
|
-
await clearTelegramControlRequest(settings, req.requestId);
|
|
161
|
+
await clearTelegramControlRequest(settings as Settings, req.requestId);
|
|
59
162
|
}
|
|
60
163
|
},
|
|
61
164
|
},
|
|
@@ -2005,6 +2005,10 @@ export class TelegramNotificationDaemon {
|
|
|
2005
2005
|
{ command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
|
|
2006
2006
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
2007
2007
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
2008
|
+
{ command: "session_create", description: "Create a GJC session: path, worktree, or dir" },
|
|
2009
|
+
{ command: "session_recent", description: "List recent GJC sessions" },
|
|
2010
|
+
{ command: "session_close", description: "Close a GJC-managed session" },
|
|
2011
|
+
{ command: "session_resume", description: "Resume or reattach a session" },
|
|
2008
2012
|
],
|
|
2009
2013
|
});
|
|
2010
2014
|
} catch {
|
|
@@ -194,9 +194,9 @@ Delegate by default for multi-file changes, refactors, new features, tests, and
|
|
|
194
194
|
</detached-subagents>
|
|
195
195
|
{{/has}}
|
|
196
196
|
|
|
197
|
-
{{#has tools "
|
|
197
|
+
{{#has tools "read"}}
|
|
198
198
|
<images>
|
|
199
|
-
For image understanding,
|
|
199
|
+
For image understanding, call `{{toolRefs.read}}` on the image path; the image is returned inline for direct visual inspection.
|
|
200
200
|
</images>
|
|
201
201
|
{{/has}}
|
|
202
202
|
|
|
@@ -1,12 +1,14 @@
|
|
|
1
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
2
|
|
|
3
|
-
|
|
3
|
+
Use a single `op` field to select the operation:
|
|
4
4
|
|
|
5
|
-
`
|
|
5
|
+
- `op: "create"` accepts a standard 5-field `cron_expression` in your local timezone, the `prompt` to run, and `recurring` (whether the job recurs or fires once). It returns an 8-character job id you can pass to `op: "delete"`. Each session can hold up to 50 scheduled tasks. Recurring tasks auto-expire 7 days after creation; one-shot tasks self-delete after firing.
|
|
6
|
+
- `op: "list"` enumerates every scheduled task in the session.
|
|
7
|
+
- `op: "delete"` cancels a task by `id`.
|
|
6
8
|
|
|
7
9
|
## Cron expressions
|
|
8
10
|
|
|
9
|
-
`
|
|
11
|
+
`op: "create"` 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
12
|
|
|
11
13
|
| Example | Meaning |
|
|
12
14
|
| :------------ | :--------------------------- |
|
|
@@ -46,7 +46,7 @@ Extracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipy
|
|
|
46
46
|
|
|
47
47
|
# Images
|
|
48
48
|
|
|
49
|
-
Reading an image path returns
|
|
49
|
+
Reading an image path returns the image itself for visual inspection by a vision-capable model.
|
|
50
50
|
|
|
51
51
|
# Archives
|
|
52
52
|
|
package/src/sdk.ts
CHANGED
|
@@ -140,6 +140,7 @@ import {
|
|
|
140
140
|
setPreferredImageProvider,
|
|
141
141
|
setPreferredSearchProvider,
|
|
142
142
|
setSearchFallbackProviders,
|
|
143
|
+
setSearchHardTimeoutMs,
|
|
143
144
|
type Tool,
|
|
144
145
|
type ToolSession,
|
|
145
146
|
WebSearchTool,
|
|
@@ -941,6 +942,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
941
942
|
webSearchFallback.filter(value => typeof value === "string" && isConfigurableSearchProviderId(value)),
|
|
942
943
|
);
|
|
943
944
|
}
|
|
945
|
+
const webSearchTimeout = settings.get("web_search.timeout");
|
|
946
|
+
if (typeof webSearchTimeout === "number" && Number.isFinite(webSearchTimeout) && webSearchTimeout > 0) {
|
|
947
|
+
setSearchHardTimeoutMs(webSearchTimeout * 1000);
|
|
948
|
+
}
|
|
944
949
|
|
|
945
950
|
const imageProvider = settings.get("providers.image");
|
|
946
951
|
if (
|
|
@@ -250,6 +250,7 @@ import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
|
|
|
250
250
|
import type { CheckpointState } from "../tools/checkpoint";
|
|
251
251
|
import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
|
|
252
252
|
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
|
253
|
+
import { registerResourceGcSession } from "../tools/resource-gc";
|
|
253
254
|
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
|
|
254
255
|
import { ToolAbortError, ToolError } from "../tools/tool-errors";
|
|
255
256
|
import { clampTimeout } from "../tools/tool-timeouts";
|
|
@@ -525,6 +526,15 @@ export interface SessionStats {
|
|
|
525
526
|
type RetryFallbackChains = Record<string, string[]>;
|
|
526
527
|
|
|
527
528
|
type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
|
|
529
|
+
type RetryErrorClassification =
|
|
530
|
+
| "none"
|
|
531
|
+
| "overflow"
|
|
532
|
+
| "terminal"
|
|
533
|
+
| "usage_limit"
|
|
534
|
+
| "first_event_timeout"
|
|
535
|
+
| "transient"
|
|
536
|
+
| "local_unavailable"
|
|
537
|
+
| "unknown";
|
|
528
538
|
|
|
529
539
|
interface RetryFallbackSelector {
|
|
530
540
|
raw: string;
|
|
@@ -562,6 +572,37 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
|
|
|
562
572
|
return `${selector.provider}/${selector.id}`;
|
|
563
573
|
}
|
|
564
574
|
|
|
575
|
+
function isLocalModelEndpoint(model: Model | undefined): boolean {
|
|
576
|
+
if (!model) return false;
|
|
577
|
+
if (model.provider === "ollama" || model.provider === "lm-studio" || model.provider === "llama.cpp") {
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const hostname = new URL(model.baseUrl).hostname.toLowerCase();
|
|
582
|
+
if (
|
|
583
|
+
hostname === "localhost" ||
|
|
584
|
+
hostname === "0.0.0.0" ||
|
|
585
|
+
hostname === "::1" ||
|
|
586
|
+
hostname === "[::1]" ||
|
|
587
|
+
hostname.endsWith(".local")
|
|
588
|
+
) {
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
if (/^127\./.test(hostname) || /^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
const private172 = /^172\.(\d{1,2})\./.exec(hostname);
|
|
595
|
+
if (private172) {
|
|
596
|
+
const secondOctet = Number(private172[1]);
|
|
597
|
+
return secondOctet >= 16 && secondOctet <= 31;
|
|
598
|
+
}
|
|
599
|
+
} catch {
|
|
600
|
+
// A malformed base URL is configuration, not availability. Keep it visible.
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
|
|
565
606
|
const IRC_REPLY_MAX_BYTES = 4096;
|
|
566
607
|
|
|
567
608
|
/**
|
|
@@ -964,6 +1005,8 @@ export class AgentSession {
|
|
|
964
1005
|
// Python execution state
|
|
965
1006
|
#evalAbortControllers = new Set<AbortController>();
|
|
966
1007
|
#evalKernelOwnerId: string;
|
|
1008
|
+
/** Idempotent unregister handle for this session's resource-GC registration. */
|
|
1009
|
+
#unregisterResourceGc?: () => void;
|
|
967
1010
|
/**
|
|
968
1011
|
* AsyncJobManager owned by this session (top-level only). Subagents leave
|
|
969
1012
|
* this undefined and **MUST NOT** dispose the global instance on teardown.
|
|
@@ -1180,6 +1223,15 @@ export class AgentSession {
|
|
|
1180
1223
|
this.sessionManager = config.sessionManager;
|
|
1181
1224
|
this.settings = config.settings;
|
|
1182
1225
|
this.taskDepth = config.taskDepth ?? 0;
|
|
1226
|
+
// Register this session with the process-wide resource GC (idle/RSS browser-tab eviction
|
|
1227
|
+
// + stale screenshot cleanup). Session-keyed so concurrent sessions share one timer safely.
|
|
1228
|
+
const resourceGcSessionId = this.sessionManager.getSessionId();
|
|
1229
|
+
if (resourceGcSessionId) {
|
|
1230
|
+
this.#unregisterResourceGc = registerResourceGcSession({
|
|
1231
|
+
sessionId: resourceGcSessionId,
|
|
1232
|
+
settings: this.settings,
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1183
1235
|
// Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
|
|
1184
1236
|
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
|
|
1185
1237
|
this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
|
|
@@ -3285,6 +3337,8 @@ export class AgentSession {
|
|
|
3285
3337
|
// browsers disconnect, headless close gracefully). Scoped by the session id the
|
|
3286
3338
|
// browser tool tagged tabs with, so other live sessions' tabs are untouched.
|
|
3287
3339
|
// No-op when this session opened no tabs. Failure is logged, not thrown.
|
|
3340
|
+
this.#unregisterResourceGc?.();
|
|
3341
|
+
this.#unregisterResourceGc = undefined;
|
|
3288
3342
|
await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
|
|
3289
3343
|
logger.warn("session dispose: releaseTabsForOwner failed", { error }),
|
|
3290
3344
|
);
|
|
@@ -3324,6 +3378,8 @@ export class AgentSession {
|
|
|
3324
3378
|
async disposeChildSubprocesses(timeoutMs = SIGNAL_TEARDOWN_TIMEOUT_MS): Promise<void> {
|
|
3325
3379
|
const sessionId = this.sessionManager.getSessionId();
|
|
3326
3380
|
const kernelOwnerId = this.#evalKernelOwnerId;
|
|
3381
|
+
this.#unregisterResourceGc?.();
|
|
3382
|
+
this.#unregisterResourceGc = undefined;
|
|
3327
3383
|
const work = Promise.allSettled([
|
|
3328
3384
|
// kill:true so a forced exit also reaps spawned-app Chrome we own (headless
|
|
3329
3385
|
// always closes; connected/attached browsers only disconnect — never killed).
|
|
@@ -5231,6 +5287,19 @@ export class AgentSession {
|
|
|
5231
5287
|
attribution: "user",
|
|
5232
5288
|
timestamp: Date.now(),
|
|
5233
5289
|
});
|
|
5290
|
+
// A live agent loop polls the steering queue at every tool/turn boundary
|
|
5291
|
+
// and consumes this message on its own. But when a steer is queued while no
|
|
5292
|
+
// loop is actively running — e.g. the session still reports busy only
|
|
5293
|
+
// because a finished prompt is unwinding (deferred agent_end / post-prompt
|
|
5294
|
+
// work) — nothing delivers it until the next explicit prompt or a
|
|
5295
|
+
// user-interrupt abort, so it stalls until the user presses Esc. Schedule a
|
|
5296
|
+
// continue so the steer is delivered promptly. A live loop (or an
|
|
5297
|
+
// already-drained queue) makes the scheduled continue a no-op.
|
|
5298
|
+
if (this.#canAutoContinueForSteer()) {
|
|
5299
|
+
this.#scheduleAgentContinue({
|
|
5300
|
+
shouldContinue: () => this.#canAutoContinueForSteer() && this.agent.hasQueuedSteering(),
|
|
5301
|
+
});
|
|
5302
|
+
}
|
|
5234
5303
|
}
|
|
5235
5304
|
|
|
5236
5305
|
/**
|
|
@@ -5275,6 +5344,21 @@ export class AgentSession {
|
|
|
5275
5344
|
return last?.role === "assistant";
|
|
5276
5345
|
}
|
|
5277
5346
|
|
|
5347
|
+
/**
|
|
5348
|
+
* Gate for idle / winding-down steer auto-continue. Unlike the follow-up gate
|
|
5349
|
+
* this checks `agent.state.isStreaming` (a live agent loop) rather than the
|
|
5350
|
+
* public `isStreaming` (which stays true while a finished prompt unwinds), so a
|
|
5351
|
+
* steer queued during the unwind window is still delivered. A live loop returns
|
|
5352
|
+
* false here because it polls the steering queue itself.
|
|
5353
|
+
*/
|
|
5354
|
+
#canAutoContinueForSteer(): boolean {
|
|
5355
|
+
if (this.agent.state.isStreaming) return false;
|
|
5356
|
+
if (this.isRetrying) return false;
|
|
5357
|
+
const messages = this.agent.state.messages;
|
|
5358
|
+
const last = messages[messages.length - 1];
|
|
5359
|
+
return last?.role === "assistant";
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5278
5362
|
queueDeferredMessage(message: CustomMessage): void {
|
|
5279
5363
|
this.#queueHiddenNextTurnMessage(message, true);
|
|
5280
5364
|
}
|
|
@@ -8177,6 +8261,12 @@ export class AgentSession {
|
|
|
8177
8261
|
*/
|
|
8178
8262
|
#isRetryableError(message: AssistantMessage): boolean {
|
|
8179
8263
|
const classification = this.#classifyErrorForRetry(message);
|
|
8264
|
+
if (classification === "local_unavailable") {
|
|
8265
|
+
return this.#hasRetryFallbackCandidate({
|
|
8266
|
+
currentSelector: this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined,
|
|
8267
|
+
requireNonLocal: true,
|
|
8268
|
+
});
|
|
8269
|
+
}
|
|
8180
8270
|
return (
|
|
8181
8271
|
classification === "usage_limit" ||
|
|
8182
8272
|
classification === "transient" ||
|
|
@@ -8216,6 +8306,12 @@ export class AgentSession {
|
|
|
8216
8306
|
return /timed?\s*out while waiting for the first event|timeout waiting for first/i.test(errorMessage);
|
|
8217
8307
|
}
|
|
8218
8308
|
|
|
8309
|
+
#isLocalProviderAvailabilityErrorMessage(errorMessage: string): boolean {
|
|
8310
|
+
return /connection.?refused|econnrefused|timed?\s*out|timeout|fetch failed|network.?error|socket hang up|terminated|service.?unavailable|server.?error|internal.?error|503|model_not_found|model not found|no such model|unknown model|model .*not.*(found|available|loaded)|not ready|not.?ready|warming|loading|currently loading|try again|out of memory|\boom\b|memory guard|insufficient memory|not enough memory|failed to allocate|kv.?cache|malformed (stream|streaming|sse)|invalid (stream|streaming|sse)|stream envelope error|unexpected end of (json|input)|unterminated json|no error details in response/i.test(
|
|
8311
|
+
errorMessage,
|
|
8312
|
+
);
|
|
8313
|
+
}
|
|
8314
|
+
|
|
8219
8315
|
/**
|
|
8220
8316
|
* Whether a first-event timeout on the error's provider should fail closed —
|
|
8221
8317
|
* i.e. retry a bounded number of times (capped at retry.maxRetries) and then
|
|
@@ -8259,13 +8355,14 @@ export class AgentSession {
|
|
|
8259
8355
|
* -> usage_limit (rotation) -> first_event_timeout (bounded retry) ->
|
|
8260
8356
|
* transient (retry) -> unknown (retry).
|
|
8261
8357
|
*/
|
|
8262
|
-
#classifyErrorForRetry(
|
|
8263
|
-
message: AssistantMessage,
|
|
8264
|
-
): "none" | "overflow" | "terminal" | "usage_limit" | "first_event_timeout" | "transient" | "unknown" {
|
|
8358
|
+
#classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
|
|
8265
8359
|
if (message.stopReason !== "error" || !message.errorMessage) return "none";
|
|
8266
8360
|
const contextWindow = this.model?.contextWindow ?? 0;
|
|
8267
8361
|
if (isContextOverflow(message, contextWindow)) return "overflow";
|
|
8268
8362
|
const err = message.errorMessage;
|
|
8363
|
+
if (isLocalModelEndpoint(this.model) && this.#isLocalProviderAvailabilityErrorMessage(err)) {
|
|
8364
|
+
return "local_unavailable";
|
|
8365
|
+
}
|
|
8269
8366
|
// Stream-envelope errors are only transient in the pre-message_start
|
|
8270
8367
|
// variant; any other envelope failure is structural and must surface.
|
|
8271
8368
|
if (/anthropic stream envelope error:/i.test(err)) {
|
|
@@ -8400,6 +8497,20 @@ export class AgentSession {
|
|
|
8400
8497
|
return chain;
|
|
8401
8498
|
}
|
|
8402
8499
|
|
|
8500
|
+
#hasRetryFallbackCandidate(options: { currentSelector: string | undefined; requireNonLocal?: boolean }): boolean {
|
|
8501
|
+
if (!options.currentSelector) return false;
|
|
8502
|
+
const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(options.currentSelector);
|
|
8503
|
+
if (!role) return false;
|
|
8504
|
+
for (const selector of this.#findRetryFallbackCandidates(role, options.currentSelector)) {
|
|
8505
|
+
if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
|
|
8506
|
+
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
|
|
8507
|
+
if (!candidate) continue;
|
|
8508
|
+
if (options.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
|
|
8509
|
+
return true;
|
|
8510
|
+
}
|
|
8511
|
+
return false;
|
|
8512
|
+
}
|
|
8513
|
+
|
|
8403
8514
|
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
|
|
8404
8515
|
const chain = this.#getRetryFallbackEffectiveChain(role);
|
|
8405
8516
|
if (chain.length <= 1) return [];
|
|
@@ -8453,7 +8564,7 @@ export class AgentSession {
|
|
|
8453
8564
|
});
|
|
8454
8565
|
}
|
|
8455
8566
|
|
|
8456
|
-
async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
|
|
8567
|
+
async #tryRetryModelFallback(currentSelector: string, options?: { requireNonLocal?: boolean }): Promise<boolean> {
|
|
8457
8568
|
const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
|
|
8458
8569
|
if (!role) return false;
|
|
8459
8570
|
|
|
@@ -8461,6 +8572,7 @@ export class AgentSession {
|
|
|
8461
8572
|
if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
|
|
8462
8573
|
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
|
|
8463
8574
|
if (!candidate) continue;
|
|
8575
|
+
if (options?.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
|
|
8464
8576
|
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
|
8465
8577
|
if (!apiKey) continue;
|
|
8466
8578
|
await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
|
|
@@ -8571,6 +8683,7 @@ export class AgentSession {
|
|
|
8571
8683
|
if (!retrySettings.enabled) return false;
|
|
8572
8684
|
const retryClassification = this.#classifyErrorForRetry(message);
|
|
8573
8685
|
const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
|
|
8686
|
+
const localUnavailable = retryClassification === "local_unavailable";
|
|
8574
8687
|
|
|
8575
8688
|
const generation = this.#promptGeneration;
|
|
8576
8689
|
this.#retryAttempt++;
|
|
@@ -8624,7 +8737,7 @@ export class AgentSession {
|
|
|
8624
8737
|
const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
|
|
8625
8738
|
if (!switchedCredential && currentSelector) {
|
|
8626
8739
|
this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
|
|
8627
|
-
switchedModel = await this.#tryRetryModelFallback(currentSelector);
|
|
8740
|
+
switchedModel = await this.#tryRetryModelFallback(currentSelector, { requireNonLocal: localUnavailable });
|
|
8628
8741
|
if (switchedModel) {
|
|
8629
8742
|
delayMs = 0;
|
|
8630
8743
|
} else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
|
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
GJC_MODEL_ASSIGNMENT_TARGETS,
|
|
13
13
|
type GjcModelAssignmentTargetId,
|
|
14
14
|
} from "../config/model-registry";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
extractExplicitThinkingSelector,
|
|
17
|
+
formatModelSelectorValue,
|
|
18
|
+
parseModelPattern,
|
|
19
|
+
parseModelString,
|
|
20
|
+
} from "../config/model-resolver";
|
|
16
21
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js";
|
|
17
22
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
18
23
|
import { DynamicBorder } from "../modes/components/dynamic-border";
|
|
@@ -169,11 +174,32 @@ function splitExplicitThinkingSelector(selector: string): { baseSelector: string
|
|
|
169
174
|
return thinkingLevel ? { baseSelector: trimmed.slice(0, colonIndex), thinkingLevel } : { baseSelector: trimmed };
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
interface ModelCommandSelection {
|
|
178
|
+
model: Model;
|
|
179
|
+
selector: string;
|
|
180
|
+
thinkingLevel?: ThinkingLevel;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ModelCommandResolutionFailure {
|
|
184
|
+
message: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type ModelCommandResolution =
|
|
188
|
+
| { ok: true; selection: ModelCommandSelection }
|
|
189
|
+
| { ok: false; failure: ModelCommandResolutionFailure };
|
|
190
|
+
|
|
191
|
+
function parseProviderQualifiedSelector(selector: string): { provider: string; modelId: string } | undefined {
|
|
192
|
+
const splitSelector = splitExplicitThinkingSelector(selector);
|
|
193
|
+
const parsed = parseModelString(splitSelector.baseSelector);
|
|
194
|
+
if (!parsed) return undefined;
|
|
195
|
+
return { provider: parsed.provider, modelId: parsed.id };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function resolveModelCommandSelectionFromAvailable(
|
|
173
199
|
runtime: SlashCommandRuntime,
|
|
174
200
|
selector: string,
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
availableModels: Model[],
|
|
202
|
+
): ModelCommandSelection | undefined {
|
|
177
203
|
const matchPreferences = { usageOrder: runtime.settings.getStorage()?.getModelUsageOrder() };
|
|
178
204
|
const resolved = parseModelPattern(selector, availableModels, matchPreferences, {
|
|
179
205
|
modelRegistry: runtime.session.modelRegistry,
|
|
@@ -198,6 +224,78 @@ function resolveModelCommandSelection(
|
|
|
198
224
|
};
|
|
199
225
|
}
|
|
200
226
|
|
|
227
|
+
function formatDiscoverableProviderFailure(
|
|
228
|
+
selector: string,
|
|
229
|
+
provider: string,
|
|
230
|
+
modelId: string,
|
|
231
|
+
runtime: SlashCommandRuntime,
|
|
232
|
+
): string {
|
|
233
|
+
const state = runtime.session.modelRegistry.getProviderDiscoveryState?.(provider);
|
|
234
|
+
const discovered = state?.models ?? [];
|
|
235
|
+
const base = `Unknown model: ${selector}.`;
|
|
236
|
+
if (!modelId.trim()) {
|
|
237
|
+
return `${base} Local provider model selectors must use provider/model-id syntax with a non-empty model id.`;
|
|
238
|
+
}
|
|
239
|
+
if (!state) {
|
|
240
|
+
return `${base} Provider ${provider} is configured for discovery but has not reported models yet.`;
|
|
241
|
+
}
|
|
242
|
+
if (state.status === "unavailable") {
|
|
243
|
+
const details = state.error ? ` (${state.error})` : "";
|
|
244
|
+
return `${base} Provider ${provider} discovery is unavailable${details}. Check the local endpoint and run /model again.`;
|
|
245
|
+
}
|
|
246
|
+
if (state.status === "unauthenticated") {
|
|
247
|
+
return `${base} Provider ${provider} requires authentication before model discovery.`;
|
|
248
|
+
}
|
|
249
|
+
if (state.status === "empty") {
|
|
250
|
+
return `${base} Provider ${provider} discovery succeeded but returned no models.`;
|
|
251
|
+
}
|
|
252
|
+
if (discovered.length > 0) {
|
|
253
|
+
const preview = discovered.slice(0, 8).join(", ");
|
|
254
|
+
const suffix = discovered.length > 8 ? ", …" : "";
|
|
255
|
+
return `${base} Provider ${provider} did not report model ${modelId}. Available local models: ${preview}${suffix}.`;
|
|
256
|
+
}
|
|
257
|
+
return `${base} Provider ${provider} did not report model ${modelId}.`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function resolveModelCommandSelection(
|
|
261
|
+
runtime: SlashCommandRuntime,
|
|
262
|
+
selector: string,
|
|
263
|
+
): Promise<ModelCommandResolution> {
|
|
264
|
+
let availableModels = runtime.session.getAvailableModels?.() ?? [];
|
|
265
|
+
const initialSelection = resolveModelCommandSelectionFromAvailable(runtime, selector, availableModels as Model[]);
|
|
266
|
+
if (initialSelection) {
|
|
267
|
+
return { ok: true, selection: initialSelection };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const providerRef = parseProviderQualifiedSelector(selector);
|
|
271
|
+
const discoverableProviders = runtime.session.modelRegistry?.getDiscoverableProviders?.() ?? [];
|
|
272
|
+
if (providerRef && discoverableProviders.includes(providerRef.provider)) {
|
|
273
|
+
await runtime.session.modelRegistry.refreshProvider?.(providerRef.provider, "online");
|
|
274
|
+
availableModels = runtime.session.getAvailableModels?.() ?? [];
|
|
275
|
+
const refreshedSelection = resolveModelCommandSelectionFromAvailable(
|
|
276
|
+
runtime,
|
|
277
|
+
selector,
|
|
278
|
+
availableModels as Model[],
|
|
279
|
+
);
|
|
280
|
+
if (refreshedSelection) {
|
|
281
|
+
return { ok: true, selection: refreshedSelection };
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
failure: {
|
|
286
|
+
message: formatDiscoverableProviderFailure(selector, providerRef.provider, providerRef.modelId, runtime),
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
failure: {
|
|
294
|
+
message: `Unknown model: ${selector}. Configure or login to a provider first, then list/select models with /model.`,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
201
299
|
function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: string): string {
|
|
202
300
|
return [
|
|
203
301
|
currentModelLine,
|
|
@@ -282,16 +380,11 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
282
380
|
runtime,
|
|
283
381
|
);
|
|
284
382
|
}
|
|
285
|
-
const
|
|
286
|
-
if (!
|
|
287
|
-
return usage(
|
|
288
|
-
modelSelectionUsage(
|
|
289
|
-
runtime,
|
|
290
|
-
`Unknown model: ${modelId}. Configure or login to a provider first, then list/select models with /model.`,
|
|
291
|
-
),
|
|
292
|
-
runtime,
|
|
293
|
-
);
|
|
383
|
+
const resolution = await resolveModelCommandSelection(runtime, modelId);
|
|
384
|
+
if (!resolution.ok) {
|
|
385
|
+
return usage(modelSelectionUsage(runtime, resolution.failure.message), runtime);
|
|
294
386
|
}
|
|
387
|
+
const { selection } = resolution;
|
|
295
388
|
try {
|
|
296
389
|
const persistedSelector = formatModelSelectorValue(selection.selector, selection.thinkingLevel);
|
|
297
390
|
if (parsedArgs.targetId === "default") {
|