@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +8 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -2
  8. package/dist/types/config/model-registry.d.ts +8 -3
  9. package/dist/types/config/models-config-schema.d.ts +5 -5
  10. package/dist/types/config/settings-schema.d.ts +123 -9
  11. package/dist/types/config/settings.d.ts +7 -0
  12. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  13. package/dist/types/export/html/template.generated.d.ts +1 -1
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
  15. package/dist/types/gjc-runtime/state-renderer.d.ts +5 -0
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  17. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  18. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +37 -1
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +80 -0
  20. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  24. package/dist/types/modes/components/status-line.d.ts +4 -3
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  26. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  28. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  29. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  30. package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
  31. package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
  32. package/dist/types/tools/computer-gc.d.ts +23 -0
  33. package/dist/types/tools/cron.d.ts +36 -61
  34. package/dist/types/tools/index.d.ts +0 -1
  35. package/dist/types/tools/resource-gc.d.ts +54 -0
  36. package/dist/types/web/search/index.d.ts +1 -0
  37. package/dist/types/web/search/providers/utils.d.ts +11 -4
  38. package/package.json +7 -7
  39. package/src/cli/args.ts +0 -1
  40. package/src/cli/fast-help.ts +0 -1
  41. package/src/cli/local-provider-smoke.ts +632 -0
  42. package/src/cli/notify-cli.ts +4 -2
  43. package/src/cli/update-cli.ts +155 -13
  44. package/src/cli/web-search-cli.ts +5 -0
  45. package/src/cli.ts +18 -0
  46. package/src/commands/launch.ts +46 -1
  47. package/src/commands/local-provider.ts +62 -0
  48. package/src/commands/notify.ts +1 -1
  49. package/src/config/model-profiles.ts +3 -4
  50. package/src/config/model-registry.ts +58 -7
  51. package/src/config/models-config-schema.ts +10 -1
  52. package/src/config/settings-schema.ts +127 -10
  53. package/src/config/settings.ts +11 -0
  54. package/src/coordinator-mcp/server.ts +276 -34
  55. package/src/export/html/template.generated.ts +1 -1
  56. package/src/export/html/template.js +0 -12
  57. package/src/gjc-runtime/launch-tmux.ts +34 -8
  58. package/src/gjc-runtime/launch-worktree.ts +16 -2
  59. package/src/gjc-runtime/state-renderer.ts +13 -0
  60. package/src/gjc-runtime/team-runtime.ts +21 -6
  61. package/src/gjc-runtime/tmux-common.ts +16 -0
  62. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  63. package/src/gjc-runtime/ultragoal-guard.ts +167 -0
  64. package/src/gjc-runtime/ultragoal-runtime.ts +221 -1
  65. package/src/goals/tools/goal-tool.ts +14 -1
  66. package/src/internal-urls/docs-index.generated.ts +7 -7
  67. package/src/modes/components/agent-dashboard.ts +2 -1
  68. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  69. package/src/modes/components/model-selector.ts +182 -5
  70. package/src/modes/components/session-observer-overlay.ts +3 -3
  71. package/src/modes/components/settings-selector.ts +491 -5
  72. package/src/modes/components/status-line/segments.ts +7 -18
  73. package/src/modes/components/status-line/types.ts +6 -2
  74. package/src/modes/components/status-line.ts +69 -35
  75. package/src/modes/controllers/event-controller.ts +98 -4
  76. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  77. package/src/modes/controllers/input-controller.ts +15 -0
  78. package/src/modes/controllers/selector-controller.ts +18 -4
  79. package/src/modes/prompt-action-autocomplete.ts +8 -0
  80. package/src/modes/tmux-scroll.ts +92 -0
  81. package/src/modes/utils/terminal-bell.ts +48 -0
  82. package/src/notifications/telegram-daemon-cli.ts +112 -9
  83. package/src/notifications/telegram-daemon.ts +4 -0
  84. package/src/prompts/system/system-prompt.md +2 -2
  85. package/src/prompts/tools/cron.md +5 -3
  86. package/src/prompts/tools/read.md +1 -1
  87. package/src/sdk.ts +5 -0
  88. package/src/session/agent-session.ts +118 -5
  89. package/src/slash-commands/builtin-registry.ts +106 -13
  90. package/src/tools/browser/tab-supervisor.ts +86 -2
  91. package/src/tools/computer-gc.ts +66 -0
  92. package/src/tools/computer.ts +2 -0
  93. package/src/tools/cron.ts +75 -112
  94. package/src/tools/index.ts +2 -8
  95. package/src/tools/read.ts +25 -55
  96. package/src/tools/renderers.ts +0 -2
  97. package/src/tools/resource-gc.ts +291 -0
  98. package/src/tools/ultragoal-ask-guard.ts +7 -1
  99. package/src/web/search/index.ts +1 -0
  100. package/src/web/search/providers/utils.ts +22 -5
  101. package/vendor/insane-search/MANIFEST.json +3 -1
  102. package/vendor/insane-search/engine/__init__.py +14 -0
  103. package/vendor/insane-search/engine/content_safety.py +151 -0
  104. package/vendor/insane-search/engine/fetch_chain.py +32 -0
  105. package/vendor/insane-search/engine/tests/test_u8.py +216 -0
  106. package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
  107. package/dist/types/tools/inspect-image.d.ts +0 -31
  108. package/src/prompts/tools/inspect-image-system.md +0 -20
  109. package/src/prompts/tools/inspect-image.md +0 -32
  110. package/src/tools/inspect-image-renderer.ts +0 -103
  111. 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 { Settings } from "../config/settings";
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<typeof Settings, "init">;
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 = Settings.isolated({});
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
- const settings = await (deps.SettingsImpl ?? Settings).init(agentDir ? { agentDir } : {});
38
- const cfg = getNotificationConfig(settings);
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 "inspect_image"}}
197
+ {{#has tools "read"}}
198
198
  <images>
199
- For image understanding, use `{{toolRefs.inspect_image}}` with a specific question instead of reading raw image metadata only.
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
- `CronCreate` accepts a standard 5-field cron expression in your local timezone, the prompt to run, and whether the job recurs or fires once. It returns an 8-character job id you can pass to `CronDelete`. Each session can hold up to 50 scheduled tasks. Recurring tasks auto-expire 7 days after creation; one-shot tasks self-delete after firing.
3
+ Use a single `op` field to select the operation:
4
4
 
5
- `CronList` enumerates every scheduled task in the session. `CronDelete` cancels a task by id.
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
- `CronCreate` accepts 5-field cron: `minute hour day-of-month month day-of-week`. All fields support `*`, single values (`5`), steps (`*/15`), ranges (`1-5`), and comma lists (`1,15,30`). Day-of-week uses `0`/`7` for Sunday through `6` for Saturday. Extended syntax like `L`, `W`, `?`, or month/weekday names is not supported.
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 metadata (mime, bytes, dimensions, channels, alpha). For actual visual analysis, call `inspect_image` with the path and a question describing what to inspect.
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 { extractExplicitThinkingSelector, formatModelSelectorValue, parseModelPattern } from "../config/model-resolver";
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
- function resolveModelCommandSelection(
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
- ): { model: Model; selector: string; thinkingLevel?: ThinkingLevel } | undefined {
176
- const availableModels = runtime.session.getAvailableModels?.() ?? [];
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 selection = resolveModelCommandSelection(runtime, modelId);
286
- if (!selection) {
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") {