@gajae-code/coding-agent 0.15.5 → 0.15.6

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 (155) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +2 -0
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +96 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  22. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  23. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  25. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/main.d.ts +3 -1
  28. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  29. package/dist/types/modes/jobs-observer.d.ts +36 -0
  30. package/dist/types/sdk/broker/broker.d.ts +3 -0
  31. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  32. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  33. package/dist/types/sdk/client/discovery.d.ts +1 -0
  34. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  35. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  36. package/dist/types/sdk/host/session-runtime.d.ts +15 -1
  37. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  38. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  39. package/dist/types/sdk/router/session-router.d.ts +9 -0
  40. package/dist/types/session/account-inventory.d.ts +1 -0
  41. package/dist/types/session/agent-session.d.ts +19 -7
  42. package/dist/types/session/fold-coordinator.d.ts +201 -0
  43. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  44. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  45. package/dist/types/telemetry/control.d.ts +11 -0
  46. package/dist/types/telemetry/events.d.ts +20 -0
  47. package/dist/types/telemetry/index.d.ts +3 -0
  48. package/dist/types/telemetry/transport.d.ts +22 -0
  49. package/dist/types/tools/bash-interactive.d.ts +23 -0
  50. package/dist/types/tools/index.d.ts +6 -5
  51. package/dist/types/web/search/providers/base.d.ts +9 -4
  52. package/dist/types/web/search/providers/xai.d.ts +2 -1
  53. package/dist/types/web/search/types.d.ts +14 -0
  54. package/package.json +10 -7
  55. package/scripts/generate-hotkeys-docs.ts +4 -0
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  57. package/src/async/job-manager.ts +671 -39
  58. package/src/cli/auth-broker-cli.ts +40 -12
  59. package/src/cli/auth-gateway-cli.ts +273 -97
  60. package/src/cli/config-cli.ts +2 -2
  61. package/src/cli/fast-help.ts +3 -0
  62. package/src/cli/model-presets-cli.ts +100 -0
  63. package/src/cli/update-cli.ts +40 -5
  64. package/src/cli.ts +6 -2
  65. package/src/commands/auth-gateway.ts +12 -8
  66. package/src/commands/gc.ts +19 -0
  67. package/src/commands/model-presets.ts +24 -0
  68. package/src/config/autorouting-tier-map.ts +32 -0
  69. package/src/config/file-lock-gc.ts +9 -4
  70. package/src/config/file-lock.ts +775 -59
  71. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  72. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  73. package/src/config/keybindings.ts +6 -2
  74. package/src/config/model-preset-registry.ts +2799 -0
  75. package/src/config/model-profile-activation.ts +115 -28
  76. package/src/config/model-profile-contract.ts +4 -1
  77. package/src/config/model-profiles.ts +51 -9
  78. package/src/config/model-registry.ts +1021 -122
  79. package/src/config/settings-schema.ts +11 -0
  80. package/src/config/settings.ts +6 -1
  81. package/src/coordinator-mcp/model-preset.ts +32 -17
  82. package/src/coordinator-mcp/projection-scan.ts +571 -0
  83. package/src/coordinator-mcp/server.ts +19 -24
  84. package/src/coordinator-mcp/session-reaper.ts +9 -3
  85. package/src/extensibility/extensions/loader.ts +0 -1
  86. package/src/extensibility/extensions/types.ts +2 -3
  87. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  88. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  89. package/src/gjc-runtime/gc-render.ts +29 -0
  90. package/src/gjc-runtime/gc-runtime.ts +174 -11
  91. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  92. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  93. package/src/index.ts +1 -0
  94. package/src/internal-urls/docs-index.generated.ts +9 -8
  95. package/src/lsp/config.ts +4 -3
  96. package/src/lsp/path-trust.ts +12 -6
  97. package/src/main.ts +9 -2
  98. package/src/modes/acp/acp-agent.ts +11 -5
  99. package/src/modes/components/jobs-overlay-model.ts +99 -16
  100. package/src/modes/components/model-selector.ts +158 -18
  101. package/src/modes/components/status-line/segments.ts +6 -1
  102. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  103. package/src/modes/controllers/input-controller.ts +23 -6
  104. package/src/modes/interactive-mode.ts +14 -1
  105. package/src/modes/jobs-observer.ts +149 -16
  106. package/src/modes/runtime-init.ts +1 -1
  107. package/src/modes/utils/injected-user-submission.ts +15 -1
  108. package/src/prompts/tools/bash.md +2 -2
  109. package/src/prompts/tools/fold-receipt.md +3 -0
  110. package/src/prompts/tools/job.md +1 -1
  111. package/src/sdk/acp/adapter.ts +17 -2
  112. package/src/sdk/broker/broker.ts +48 -21
  113. package/src/sdk/broker/lifecycle.ts +32 -6
  114. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  115. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  117. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  118. package/src/sdk/bus/slack-conversation.ts +5 -1
  119. package/src/sdk/cli/session-cli.ts +12 -1
  120. package/src/sdk/client/discovery.ts +3 -1
  121. package/src/sdk/host/control/dispatch.ts +3 -0
  122. package/src/sdk/host/model-pin.ts +185 -25
  123. package/src/sdk/host/sdk-run-capability.ts +1 -0
  124. package/src/sdk/host/session-runtime.test.ts +371 -37
  125. package/src/sdk/host/session-runtime.ts +364 -52
  126. package/src/sdk/prompt-deadline-manager.ts +16 -1
  127. package/src/sdk/protocol/adapter-validation.ts +27 -0
  128. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  129. package/src/sdk/protocol/operation-registry.ts +4 -1
  130. package/src/sdk/router/session-router.ts +228 -96
  131. package/src/sdk/session.ts +83 -12
  132. package/src/session/account-inventory.ts +27 -11
  133. package/src/session/agent-session.ts +678 -103
  134. package/src/session/fold-coordinator.ts +433 -0
  135. package/src/session/sdk-run-capability-internal.ts +15 -0
  136. package/src/session/sdk-run-capability.ts +2 -0
  137. package/src/session/session-manager.ts +5 -4
  138. package/src/setup/provider-presets.json +1 -1
  139. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  140. package/src/task/executor.ts +19 -3
  141. package/src/telemetry/control.ts +26 -0
  142. package/src/telemetry/events.ts +142 -0
  143. package/src/telemetry/index.ts +3 -0
  144. package/src/telemetry/transport.ts +87 -0
  145. package/src/tools/bash-interactive.ts +182 -85
  146. package/src/tools/bash.ts +772 -67
  147. package/src/tools/index.ts +7 -5
  148. package/src/tools/tool-catalog.generated.ts +1 -1
  149. package/src/web/search/provider.ts +120 -11
  150. package/src/web/search/providers/anthropic.ts +89 -17
  151. package/src/web/search/providers/base.ts +9 -4
  152. package/src/web/search/providers/gemini.ts +87 -15
  153. package/src/web/search/providers/openai-compatible.ts +23 -8
  154. package/src/web/search/providers/xai.ts +98 -12
  155. package/src/web/search/types.ts +16 -0
package/src/lsp/config.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { $which, isRecord, logger, pathIsWithin } from "@gajae-code/utils";
4
+ import * as piUtils from "@gajae-code/utils";
5
+ import { isRecord, logger, pathIsWithin } from "@gajae-code/utils";
5
6
  import { YAML } from "bun";
6
7
  import { getConfigDirPaths } from "../config";
7
8
  import { type ClaudePluginRoot, getPreloadedPluginRoots } from "../discovery/helpers";
@@ -332,13 +333,13 @@ export function resolveCommand(command: string, cwd: string): string | null {
332
333
  }
333
334
 
334
335
  // Fall back to $PATH
335
- return $which(command);
336
+ return piUtils.$which(command);
336
337
  }
337
338
 
338
339
  /** Resolve an LSP executable without consulting project-controlled bin directories. */
339
340
  function resolveTrustedLspCommand(command: string, cwd: string): string | null {
340
341
  if (!path.isAbsolute(command) && (command.includes("/") || command.includes("\\"))) return null;
341
- const discovered = path.isAbsolute(command) ? command : $which(command);
342
+ const discovered = path.isAbsolute(command) ? command : piUtils.$which(command);
342
343
  if (!discovered) return null;
343
344
  if (isProjectControlledPath(discovered, cwd)) return null;
344
345
  const canonical = canonicalExistingPath(discovered);
@@ -63,19 +63,25 @@ export function isProjectControlledPath(candidate: string, cwd: string): boolean
63
63
  const home = os.homedir();
64
64
  const stopPaths = new Set([path.resolve(home), canonicalPath(home)].map(normalizePathForComparison));
65
65
  const lexicalTrustRoot = findProjectTrustRoot(cwd, stopPaths);
66
- if (lexicalTrustRoot !== undefined && pathIsLexicallyWithin(lexicalTrustRoot, candidate)) return true;
66
+ const candidateIsHomeScoped = pathIsLexicallyWithin(home, candidate) || pathIsWithin(home, candidate);
67
+ if (
68
+ lexicalTrustRoot !== undefined &&
69
+ pathIsLexicallyWithin(lexicalTrustRoot, candidate) &&
70
+ (!candidateIsHomeScoped || pathIsLexicallyWithin(home, lexicalTrustRoot))
71
+ )
72
+ return true;
67
73
 
68
74
  const canonicalTrustRoots = new Set<string>();
69
75
  if (lexicalTrustRoot !== undefined) canonicalTrustRoots.add(canonicalPath(lexicalTrustRoot));
70
76
  const canonicalTrustRoot = findProjectTrustRoot(canonicalPath(cwd), stopPaths);
71
- if (canonicalTrustRoot !== undefined) canonicalTrustRoots.add(canonicalTrustRoot);
77
+ if (canonicalTrustRoot !== undefined) canonicalTrustRoots.add(canonicalPath(canonicalTrustRoot));
72
78
  for (const trustRoot of canonicalTrustRoots) {
73
79
  if (
74
- pathIsLexicallyWithin(trustRoot, canonicalParentPath(candidate)) ||
75
- pathIsWithin(trustRoot, canonicalPath(candidate))
76
- ) {
80
+ (pathIsLexicallyWithin(trustRoot, canonicalParentPath(candidate)) ||
81
+ pathIsWithin(trustRoot, canonicalPath(candidate))) &&
82
+ (!candidateIsHomeScoped || pathIsLexicallyWithin(home, trustRoot) || pathIsWithin(home, trustRoot))
83
+ )
77
84
  return true;
78
- }
79
85
  }
80
86
  return false;
81
87
  }
package/src/main.ts CHANGED
@@ -349,7 +349,9 @@ export async function submitInteractiveInput(
349
349
  | "checkShutdownRequested"
350
350
  | "waitForAgentEnd"
351
351
  >,
352
- session: Pick<AgentSession, "prompt" | "promptCustomMessage">,
352
+ session: Pick<AgentSession, "prompt" | "promptCustomMessage"> & {
353
+ continuePersistedHistory?: AgentSession["continuePersistedHistory"];
354
+ },
353
355
  input: SubmittedUserInput,
354
356
  ): Promise<void> {
355
357
  if (input.cancelled) {
@@ -357,8 +359,13 @@ export async function submitInteractiveInput(
357
359
  }
358
360
 
359
361
  try {
362
+ if (input.started) {
363
+ if (!session.continuePersistedHistory) throw new Error("Session continuation is unavailable.");
364
+ await session.continuePersistedHistory();
365
+ return;
366
+ }
360
367
  // Continue shortcuts submit an already-started empty prompt with no optimistic user message.
361
- if (!input.started && !mode.markPendingSubmissionStarted(input)) {
368
+ if (!mode.markPendingSubmissionStarted(input)) {
362
369
  return;
363
370
  }
364
371
  const agentEnd = mode.waitForAgentEnd();
@@ -54,6 +54,7 @@ import type { AbortScope } from "../../sdk/host/control/operations";
54
54
  import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
55
55
  import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
56
56
  import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
57
+ import { validateRequiredPromptText } from "../../sdk/protocol/adapter-validation";
57
58
  import { type SessionAttachment, SessionRouter, type SessionRouterFrame } from "../../sdk/router";
58
59
  import { SessionListTraversalError, sessionListPageFromResponse, traverseSessionList } from "../../sdk/session-list";
59
60
  import { resolveAcpAbortScope } from "./abort-scope";
@@ -994,7 +995,6 @@ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
994
995
  text.push(block.text);
995
996
  break;
996
997
  case "image":
997
- if (block.uri) text.push(`[Image URI: ${block.uri}]`);
998
998
  images.push({ data: block.data, mimeType: block.mimeType });
999
999
  break;
1000
1000
  case "resource_link":
@@ -1027,7 +1027,6 @@ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
1027
1027
  "unsupported_content",
1028
1028
  `Unsupported embedded resource MIME type: ${mimeType}`,
1029
1029
  );
1030
- text.push(`[Resource: ${resource.uri}]\nMIME: ${mimeType}`);
1031
1030
  images.push({ data: resource.blob, mimeType });
1032
1031
  break;
1033
1032
  }
@@ -1038,7 +1037,7 @@ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
1038
1037
  }
1039
1038
  }
1040
1039
  if (text.length === 0 && images.length === 0)
1041
- throw new AcpSdkAdapterError("invalid_input", "ACP prompt must contain at least one supported content block.");
1040
+ throw new AcpSdkAdapterError("invalid_input", "Prompt must not be empty.");
1042
1041
  return { text: text.join("\n"), images };
1043
1042
  }
1044
1043
 
@@ -1576,10 +1575,17 @@ export class AcpAgent implements Agent {
1576
1575
  if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
1577
1576
  if (record.activePrompt) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
1578
1577
  if (record.authFailure) throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
1579
- // A new turn starts uncancelled; a stale flag must never settle it as `cancelled`.
1580
- record.cancelRequested = false;
1581
1578
  const payload = acpPromptPayload(params.prompt);
1582
1579
  const skillInvocation = acpSkillInvocation(params.prompt);
1580
+ if (!skillInvocation) {
1581
+ const promptError = validateRequiredPromptText("turn.prompt", {
1582
+ text: payload.text,
1583
+ images: payload.images,
1584
+ });
1585
+ if (promptError) throw new AcpSdkAdapterError(promptError.code, promptError.message);
1586
+ }
1587
+ // A new turn starts uncancelled; a stale flag must never settle it as `cancelled`.
1588
+ record.cancelRequested = false;
1583
1589
  if (isAcpUnavailableSlashCommand(payload.text)) {
1584
1590
  await this.#publishSessionUpdate(
1585
1591
  params.sessionId,
@@ -6,20 +6,41 @@
6
6
  * SelectItem lists into nested SelectLists (list -> detail -> confirm).
7
7
  */
8
8
  import type { SelectItem } from "@gajae-code/tui";
9
+ import { replaceTabs, shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
9
10
  import type { JobsSnapshot } from "../jobs-observer";
11
+ import { sanitizeStatusText } from "../shared";
10
12
 
11
- export type JobRefKind = "monitor" | "cron";
13
+ export type JobRefKind = "monitor" | "cron" | "folded";
12
14
 
13
15
  export interface JobRef {
14
16
  kind: JobRefKind;
15
17
  id: string;
18
+ generation?: string;
16
19
  }
17
20
 
18
- const PROMPT_PREVIEW_MAX = 60;
21
+ function encodeJobRefPart(value: string): string {
22
+ return encodeURIComponent(value);
23
+ }
24
+
25
+ function decodeJobRefPart(value: string): string | undefined {
26
+ try {
27
+ return decodeURIComponent(value);
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ function displayText(text: string, max: number = TRUNCATE_LENGTHS.CONTENT): string {
34
+ const sanitized = shortenPath(replaceTabs(sanitizeStatusText(text)));
35
+ return truncateToWidth(sanitized, max);
36
+ }
37
+
38
+ function preview(text: string, max: number = TRUNCATE_LENGTHS.TITLE): string {
39
+ return displayText(text, max);
40
+ }
19
41
 
20
- function preview(text: string, max = PROMPT_PREVIEW_MAX): string {
21
- const oneLine = text.replace(/\s+/g, " ").trim();
22
- return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
42
+ function snapshotJobKey(id: string, generation: string | undefined): string {
43
+ return `${id}\u0000${generation ?? ""}`;
23
44
  }
24
45
 
25
46
  /** Compact relative time, e.g. "in 5m", "2m ago", "now". */
@@ -42,30 +63,71 @@ export function parseJobRef(value: string): JobRef | null {
42
63
  if ((kind === "monitor" || kind === "cron") && id.length > 0) {
43
64
  return { kind, id };
44
65
  }
66
+ if (kind === "folded") {
67
+ const generationSep = id.indexOf(":");
68
+ if (generationSep > 0 && generationSep < id.length - 1) {
69
+ const decodedId = decodeJobRefPart(id.slice(0, generationSep));
70
+ const generation = decodeJobRefPart(id.slice(generationSep + 1));
71
+ if (decodedId && generation) return { kind, id: decodedId, generation };
72
+ }
73
+ }
45
74
  return null;
46
75
  }
47
76
 
48
77
  /**
49
78
  * Build the grouped jobs list: monitors first (newest-first), then crons
50
- * (newest-first). The snapshot arrays are already sorted newest-first.
79
+ * (newest-first), then read-only folded jobs. The snapshot arrays are already
80
+ * sorted newest-first where a source has a timestamp.
51
81
  */
52
82
  export function buildJobsListItems(snapshot: JobsSnapshot): SelectItem[] {
53
83
  const items: SelectItem[] = [];
84
+ const foldedJobs = snapshot.foldedJobs ?? [];
85
+ const foldedKeys = new Set(foldedJobs.map(job => snapshotJobKey(job.id, job.generation)));
54
86
  for (const monitor of snapshot.monitors) {
87
+ // A monitor can also be backgrounded; render it only in the folded section
88
+ // so a public job state has exactly one row.
89
+ if (monitor.backgrounded || foldedKeys.has(snapshotJobKey(monitor.id, monitor.generation))) continue;
55
90
  items.push({
56
91
  value: `monitor:${monitor.id}`,
57
- label: `monitor · ${preview(monitor.label, 40)}`,
58
- description: monitor.status,
59
- hint: monitor.status === "failed" ? "failed" : undefined,
92
+ label: `monitor · ${preview(monitor.label, TRUNCATE_LENGTHS.SHORT)}`,
93
+ description: displayText(monitor.status, TRUNCATE_LENGTHS.SHORT),
94
+ hint: monitor.status === "failed" || monitor.deliveryState === "failed-visible" ? "failed" : undefined,
60
95
  });
61
96
  }
62
97
  for (const cron of snapshot.crons) {
63
98
  items.push({
64
99
  value: `cron:${cron.id}`,
65
- label: `cron · ${cron.humanSchedule}`,
100
+ label: `cron · ${preview(cron.humanSchedule, TRUNCATE_LENGTHS.SHORT)}`,
66
101
  description: preview(cron.prompt),
67
102
  });
68
103
  }
104
+ for (const job of foldedJobs) {
105
+ const state = displayText(job.deliveryState, TRUNCATE_LENGTHS.SHORT);
106
+ const kind = preview(job.kind, TRUNCATE_LENGTHS.SHORT);
107
+ const deadLetter = (snapshot.deadLettered ?? []).find(
108
+ entry => snapshotJobKey(entry.jobId, entry.generation) === snapshotJobKey(job.id, job.generation),
109
+ );
110
+ const errorText = job.errorText ?? deadLetter?.lastError;
111
+ const description =
112
+ deadLetter || errorText
113
+ ? displayText(
114
+ [
115
+ state,
116
+ deadLetter ? `attempt ${deadLetter.attempt}` : undefined,
117
+ errorText ? `error: ${errorText}` : undefined,
118
+ ]
119
+ .filter((part): part is string => part !== undefined)
120
+ .join(" · "),
121
+ TRUNCATE_LENGTHS.CONTENT,
122
+ )
123
+ : state;
124
+ items.push({
125
+ value: `folded:${encodeJobRefPart(job.id)}:${encodeJobRefPart(job.generation)}`,
126
+ label: `${kind} · ${preview(job.label, TRUNCATE_LENGTHS.SHORT)}`,
127
+ description,
128
+ hint: job.status === "failed" || job.deliveryState === "failed-visible" ? "failed" : "read-only",
129
+ });
130
+ }
69
131
  return items;
70
132
  }
71
133
 
@@ -80,21 +142,41 @@ export function buildJobDetailItems(snapshot: JobsSnapshot, ref: JobRef, output
80
142
  if (!monitor) return [{ value: "back", label: "Back (job no longer present)" }];
81
143
  const lastOutput = output.trim().split("\n").filter(Boolean).slice(-1)[0] ?? "(no output captured)";
82
144
  return [
83
- { value: "noop", label: "Status", description: monitor.status },
145
+ { value: "noop", label: "Status", description: displayText(monitor.status, TRUNCATE_LENGTHS.SHORT) },
84
146
  { value: "noop", label: "Label", description: preview(monitor.label) },
85
147
  { value: "noop", label: "Started", description: formatRelative(monitor.startTime) },
86
- { value: "noop", label: "Output", description: preview(lastOutput, 80) },
148
+ { value: "noop", label: "Output", description: preview(lastOutput, TRUNCATE_LENGTHS.CONTENT) },
87
149
  { value: "action:cancel", label: "Cancel this monitor", hint: "stops the running job" },
88
150
  { value: "back", label: "Back" },
89
151
  ];
90
152
  }
153
+ if (ref.kind === "folded") {
154
+ const job = snapshot.foldedJobs?.find(
155
+ entry => entry.id === ref.id && (ref.generation === undefined || entry.generation === ref.generation),
156
+ );
157
+ if (!job) return [{ value: "back", label: "Back (job no longer present)" }];
158
+ return [
159
+ { value: "noop", label: "Status", description: displayText(job.status, TRUNCATE_LENGTHS.SHORT) },
160
+ { value: "noop", label: "Kind", description: preview(job.kind, TRUNCATE_LENGTHS.SHORT) },
161
+ { value: "noop", label: "Label", description: preview(job.label) },
162
+ { value: "noop", label: "Generation", description: preview(job.generation, TRUNCATE_LENGTHS.CONTENT) },
163
+ ...(job.errorText
164
+ ? [{ value: "noop", label: "Error", description: preview(job.errorText, TRUNCATE_LENGTHS.CONTENT) }]
165
+ : []),
166
+ { value: "back", label: "Back" },
167
+ ];
168
+ }
91
169
  const cron = snapshot.crons.find(c => c.id === ref.id);
92
170
  if (!cron) return [{ value: "back", label: "Back (job no longer present)" }];
93
171
  return [
94
- { value: "noop", label: "Schedule", description: `${cron.humanSchedule} (${cron.cronExpression})` },
172
+ {
173
+ value: "noop",
174
+ label: "Schedule",
175
+ description: `${preview(cron.humanSchedule, TRUNCATE_LENGTHS.SHORT)} (${preview(cron.cronExpression, TRUNCATE_LENGTHS.SHORT)})`,
176
+ },
95
177
  { value: "noop", label: "Recurring", description: cron.recurring ? "yes" : "no" },
96
178
  { value: "noop", label: "Next fire", description: formatRelative(cron.nextFireAt) },
97
- { value: "noop", label: "Prompt", description: preview(cron.prompt, 80) },
179
+ { value: "noop", label: "Prompt", description: preview(cron.prompt, TRUNCATE_LENGTHS.CONTENT) },
98
180
  { value: "action:delete", label: "Delete this cron", hint: "removes the schedule" },
99
181
  { value: "back", label: "Back" },
100
182
  ];
@@ -102,8 +184,9 @@ export function buildJobDetailItems(snapshot: JobsSnapshot, ref: JobRef, output
102
184
 
103
185
  /** Yes/No confirm items for a destructive action. */
104
186
  export function buildConfirmItems(actionLabel: string): SelectItem[] {
187
+ const safeActionLabel = displayText(actionLabel, TRUNCATE_LENGTHS.TITLE);
105
188
  return [
106
- { value: "no", label: `No, keep it` },
107
- { value: "yes", label: `Yes, ${actionLabel}` },
189
+ { value: "no", label: "No, keep it" },
190
+ { value: "yes", label: `Yes, ${safeActionLabel}` },
108
191
  ];
109
192
  }
@@ -22,9 +22,18 @@ import {
22
22
  normalizeTierMap,
23
23
  validateAutoroutingSetup,
24
24
  } from "../../config/autorouting-contract";
25
+ import {
26
+ getProxyRoutableProviders,
27
+ inspectProxyProviderId,
28
+ requiresQualifiedModelProfileRoleResolution,
29
+ resolveProxyMode,
30
+ rewriteSelectorForProxy,
31
+ tryResolveProxyProviderId,
32
+ } from "../../config/model-profile-activation";
25
33
 
26
34
  import { isModelProfileProviderAvailable } from "../../config/model-profile-contract";
27
35
  import {
36
+ deriveModelProfileMappedProviders,
28
37
  getModelProfilePresentation,
29
38
  groupModelProfilesForPresetLanding,
30
39
  type ModelProfileDefinition,
@@ -399,6 +408,7 @@ export class ModelSelectorComponent extends Container {
399
408
  #closeAfterAssignment = false;
400
409
  #unsubscribeCatalogChanged: () => void = () => {};
401
410
  #unsubscribeProviderOrderChanged: () => void = () => {};
411
+ #unsubscribeAuthGeneration: () => void = () => {};
402
412
  #disposed = false;
403
413
  /** Standalone smart-routing entry: cancel closes the selector instead of falling back to the preset landing. */
404
414
  #smartRoutingOnly = false;
@@ -413,6 +423,7 @@ export class ModelSelectorComponent extends Container {
413
423
  #providerAuthById = new Map<string, boolean>();
414
424
  #bareProfileAuthByName = new Map<string, boolean>();
415
425
  #providerAuthPending: boolean = false;
426
+ #providerAuthRefreshGeneration = 0;
416
427
  #presetLoginHint?: string;
417
428
  #authSessionId?: string;
418
429
  #imageRoleFilter: boolean = false;
@@ -524,6 +535,14 @@ export class ModelSelectorComponent extends Container {
524
535
  if (typeof this.#modelRegistry.onCatalogChanged === "function") {
525
536
  this.#unsubscribeCatalogChanged = this.#modelRegistry.onCatalogChanged(() => {
526
537
  if (this.#disposed) return;
538
+ if (this.#viewMode === "presets") {
539
+ this.#refreshCatalogView();
540
+ this.#clampPresetCursor();
541
+ void this.#refreshProviderAuth();
542
+ this.#renderPresetLanding();
543
+ this.#tui.requestRender();
544
+ return;
545
+ }
527
546
  if (this.#refreshCatalogView()) this.#tui.requestRender();
528
547
  });
529
548
  }
@@ -538,6 +557,11 @@ export class ModelSelectorComponent extends Container {
538
557
  panel.updateProviderOrderHint(this.#providerOrderHintFor(panel.getProviderOrder()));
539
558
  this.#tui.requestRender();
540
559
  });
560
+ this.#unsubscribeAuthGeneration =
561
+ this.#modelRegistry.authStorage?.onGenerationChanged?.(() => {
562
+ if (this.#disposed || this.#viewMode !== "presets") return;
563
+ void this.#refreshProviderAuth();
564
+ }) ?? (() => {});
541
565
 
542
566
  // Load models and do initial render
543
567
  this.#loadModels().then(() => {
@@ -572,8 +596,10 @@ export class ModelSelectorComponent extends Container {
572
596
  override dispose(): void {
573
597
  if (this.#disposed) return;
574
598
  this.#disposed = true;
599
+ this.#providerAuthRefreshGeneration++;
575
600
  this.#unsubscribeCatalogChanged();
576
601
  this.#unsubscribeProviderOrderChanged();
602
+ this.#unsubscribeAuthGeneration();
577
603
  super.dispose();
578
604
  }
579
605
 
@@ -1301,13 +1327,61 @@ export class ModelSelectorComponent extends Container {
1301
1327
  return this.#providerAuthById.get(providerId);
1302
1328
  }
1303
1329
 
1330
+ #getProfileAvailableModels(): Model[] {
1331
+ const getAvailableForProfileActivation = this.#modelRegistry.getAvailableForProfileActivation;
1332
+ return typeof getAvailableForProfileActivation === "function"
1333
+ ? getAvailableForProfileActivation.call(this.#modelRegistry)
1334
+ : this.#modelRegistry.getAvailable();
1335
+ }
1336
+
1337
+ #createProfileResolutionRegistry(availableModels: readonly Model[]) {
1338
+ return {
1339
+ getAvailable: () => availableModels as Model[],
1340
+ getApiKey: this.#modelRegistry.getApiKey?.bind(this.#modelRegistry) ?? (async () => undefined),
1341
+ resolveCanonicalModel: this.#modelRegistry.resolveCanonicalModel?.bind(this.#modelRegistry),
1342
+ getCanonicalVariants: this.#modelRegistry.getCanonicalVariants?.bind(this.#modelRegistry),
1343
+ getCanonicalId: this.#modelRegistry.getCanonicalId?.bind(this.#modelRegistry),
1344
+ resolveModelByLookupAlias: this.#modelRegistry.resolveModelByLookupAlias?.bind(this.#modelRegistry),
1345
+ lookupAliasExists: this.#modelRegistry.lookupAliasExists?.bind(this.#modelRegistry),
1346
+ clearCanonicalVariant: this.#modelRegistry.clearCanonicalVariant?.bind(this.#modelRegistry),
1347
+ };
1348
+ }
1349
+
1350
+ #getProfileAuthenticatedProviders(profile: ModelProfileDefinition): Set<string> {
1351
+ if (profile.source !== "user" && inspectProxyProviderId(this.#settings).status === "invalid") return new Set();
1352
+ const authenticated = new Set(
1353
+ [...this.#providerAuthById]
1354
+ .filter(([, providerAuthenticated]) => providerAuthenticated)
1355
+ .map(([provider]) => provider),
1356
+ );
1357
+ const proxyProvider = profile.source === "user" ? undefined : tryResolveProxyProviderId(this.#settings);
1358
+ if (proxyProvider !== undefined && this.#isProviderAuthenticated(proxyProvider) === true) {
1359
+ for (const provider of getProxyRoutableProviders(profile)) authenticated.add(provider);
1360
+ }
1361
+ return authenticated;
1362
+ }
1363
+
1364
+ #rewriteProfileSelectorForProxy(profile: ModelProfileDefinition, selector: string): string {
1365
+ if (profile.source === "user") return selector;
1366
+ const proxyProvider = tryResolveProxyProviderId(this.#settings);
1367
+ if (proxyProvider === undefined || this.#isProviderAuthenticated(proxyProvider) !== true) return selector;
1368
+ return rewriteSelectorForProxy(
1369
+ selector,
1370
+ proxyProvider,
1371
+ resolveProxyMode(this.#settings),
1372
+ this.#getProfileAvailableModels(),
1373
+ new Set(
1374
+ [...this.#providerAuthById].filter(([, authenticated]) => authenticated).map(([provider]) => provider),
1375
+ ),
1376
+ getProxyRoutableProviders(profile),
1377
+ );
1378
+ }
1379
+
1304
1380
  #getMissingProviders(profileOrProfiles: ModelProfileDefinition | ModelProfileDefinition[]): string[] {
1305
1381
  const profiles = Array.isArray(profileOrProfiles) ? profileOrProfiles : [profileOrProfiles];
1306
1382
  const missing = new Set<string>();
1307
1383
  for (const profile of profiles) {
1308
- const authenticated = new Set(
1309
- profileRequiredProviders(profile).filter(provider => this.#isProviderAuthenticated(provider) === true),
1310
- );
1384
+ const authenticated = this.#getProfileAuthenticatedProviders(profile);
1311
1385
  if (isModelProfileProviderAvailable(profile, authenticated)) continue;
1312
1386
  const alternativeGroups = profile.alternativeProviderGroups ?? [];
1313
1387
  const alternativeProviders = new Set(alternativeGroups.flat());
@@ -1325,18 +1399,52 @@ export class ModelSelectorComponent extends Container {
1325
1399
  #isPresetAuthenticated(profileOrProfiles: ModelProfileDefinition | ModelProfileDefinition[]): boolean {
1326
1400
  const profiles = Array.isArray(profileOrProfiles) ? profileOrProfiles : [profileOrProfiles];
1327
1401
  return profiles.every(profile => {
1402
+ if (profile.source !== "user") {
1403
+ if (inspectProxyProviderId(this.#settings).status === "invalid") return false;
1404
+ const proxyProvider = tryResolveProxyProviderId(this.#settings);
1405
+ if (
1406
+ proxyProvider !== undefined &&
1407
+ !(this.#modelRegistry.getConfiguredProviderIds?.() ?? []).includes(proxyProvider)
1408
+ )
1409
+ return false;
1410
+ try {
1411
+ if (resolveProxyMode(this.#settings) === "always") {
1412
+ const configuredProviders = this.#modelRegistry.getConfiguredProviderIds?.() ?? [];
1413
+ if (
1414
+ proxyProvider === undefined ||
1415
+ this.#isProviderAuthenticated(proxyProvider) !== true ||
1416
+ !configuredProviders.includes(proxyProvider)
1417
+ )
1418
+ return false;
1419
+ }
1420
+ } catch {
1421
+ return false;
1422
+ }
1423
+ }
1328
1424
  if (this.#getMissingProviders(profile).length > 0) return false;
1329
1425
  const bindings = resolveProfileBindings(profile);
1330
- const values = [
1331
- ...(bindings.defaultSelector ? [bindings.defaultSelector] : []),
1332
- ...Object.values(bindings.modelRoles),
1333
- ...Object.values(bindings.agentModelOverrides),
1426
+ const assignments = [
1427
+ ...(bindings.defaultSelector !== undefined ? [{ value: bindings.defaultSelector, isDefault: true }] : []),
1428
+ ...Object.values(bindings.modelRoles).map(value => ({ value, isDefault: false })),
1429
+ ...Object.values(bindings.agentModelOverrides).map(value => ({ value, isDefault: false })),
1334
1430
  ];
1335
1431
  const bareAssignmentsAvailable = this.#bareProfileAuthByName.get(profile.name) ?? true;
1336
- return values.every(value =>
1337
- normalizeModelSelectorValue(value).some(selector => {
1432
+ const availableModels = this.#getProfileAvailableModels();
1433
+ return assignments.every(assignment => {
1434
+ let selectors: string[];
1435
+ try {
1436
+ selectors = normalizeModelSelectorValue(assignment.value).map(selector =>
1437
+ this.#rewriteProfileSelectorForProxy(profile, selector),
1438
+ );
1439
+ } catch {
1440
+ return false;
1441
+ }
1442
+ const hasBareSelector = selectors.some(selector => !selector.includes("/"));
1443
+ if (!assignment.isDefault && !requiresQualifiedModelProfileRoleResolution(profile) && !hasBareSelector)
1444
+ return true;
1445
+ return selectors.some(selector => {
1338
1446
  if (!selector.includes("/")) return bareAssignmentsAvailable;
1339
- const resolved = resolveModelRoleValue(selector, this.#modelRegistry.getAvailable(), {
1447
+ const resolved = resolveModelRoleValue(selector, availableModels, {
1340
1448
  settings: this.#settings,
1341
1449
  modelRegistry: this.#modelRegistry,
1342
1450
  aliasIntent: "preset-equivalent",
@@ -1357,7 +1465,7 @@ export class ModelSelectorComponent extends Container {
1357
1465
  return (
1358
1466
  resolveModelRoleValue(
1359
1467
  replacementSelector,
1360
- this.#modelRegistry.getAvailable().filter(model => model.provider === provider),
1468
+ availableModels.filter(model => model.provider === provider),
1361
1469
  {
1362
1470
  settings: this.#settings,
1363
1471
  modelRegistry: this.#modelRegistry,
@@ -1368,8 +1476,8 @@ export class ModelSelectorComponent extends Container {
1368
1476
  );
1369
1477
  }) ?? false
1370
1478
  );
1371
- }),
1372
- );
1479
+ });
1480
+ });
1373
1481
  });
1374
1482
  }
1375
1483
 
@@ -1383,10 +1491,23 @@ export class ModelSelectorComponent extends Container {
1383
1491
  }
1384
1492
 
1385
1493
  async #refreshProviderAuth(): Promise<void> {
1494
+ const refreshGeneration = ++this.#providerAuthRefreshGeneration;
1495
+ this.#providerAuthById = new Map();
1496
+ this.#bareProfileAuthByName = new Map();
1497
+ const availableModels = this.#getProfileAvailableModels();
1498
+ const resolutionRegistry = this.#createProfileResolutionRegistry(availableModels);
1386
1499
  const providers = new Set<string>();
1387
1500
  for (const profiles of this.#getPresetGroups().values()) {
1388
1501
  for (const profile of profiles) {
1389
1502
  for (const provider of profileRequiredProviders(profile)) providers.add(provider);
1503
+ for (const group of profile.alternativeProviderGroups ?? []) {
1504
+ for (const provider of group) providers.add(provider);
1505
+ }
1506
+ for (const provider of deriveModelProfileMappedProviders(profile)) providers.add(provider);
1507
+ if (profile.source !== "user") {
1508
+ const proxyProvider = tryResolveProxyProviderId(this.#settings);
1509
+ if (proxyProvider !== undefined) providers.add(proxyProvider);
1510
+ }
1390
1511
  const bindings = resolveProfileBindings(profile);
1391
1512
  const values = [
1392
1513
  ...(bindings.defaultSelector ? [bindings.defaultSelector] : []),
@@ -1395,7 +1516,7 @@ export class ModelSelectorComponent extends Container {
1395
1516
  ];
1396
1517
  for (const value of values) {
1397
1518
  for (const selector of normalizeModelSelectorValue(value)) {
1398
- const resolved = resolveModelRoleValue(selector, this.#modelRegistry.getAvailable(), {
1519
+ const resolved = resolveModelRoleValue(selector, availableModels, {
1399
1520
  settings: this.#settings,
1400
1521
  modelRegistry: this.#modelRegistry,
1401
1522
  aliasIntent: "preset-equivalent",
@@ -1419,6 +1540,12 @@ export class ModelSelectorComponent extends Container {
1419
1540
  }
1420
1541
  }),
1421
1542
  );
1543
+ if (
1544
+ this.#disposed ||
1545
+ this.#viewMode !== "presets" ||
1546
+ refreshGeneration !== this.#providerAuthRefreshGeneration
1547
+ )
1548
+ return;
1422
1549
  this.#providerAuthById = new Map(entries);
1423
1550
  const profileAuthEntries = await Promise.all(
1424
1551
  [...this.#getPresetGroups().values()].flat().map(async profile => {
@@ -1436,7 +1563,7 @@ export class ModelSelectorComponent extends Container {
1436
1563
  try {
1437
1564
  const resolution = await resolveModelChainWithAuth(
1438
1565
  normalizeModelSelectorValue(value),
1439
- this.#modelRegistry,
1566
+ resolutionRegistry,
1440
1567
  this.#settings,
1441
1568
  this.#authSessionId,
1442
1569
  {
@@ -1455,11 +1582,23 @@ export class ModelSelectorComponent extends Container {
1455
1582
  return [profile.name, available.every(Boolean)] as const;
1456
1583
  }),
1457
1584
  );
1585
+ if (
1586
+ this.#disposed ||
1587
+ this.#viewMode !== "presets" ||
1588
+ refreshGeneration !== this.#providerAuthRefreshGeneration
1589
+ )
1590
+ return;
1458
1591
  this.#bareProfileAuthByName = new Map(profileAuthEntries);
1459
1592
  } finally {
1460
- this.#providerAuthPending = false;
1461
- this.#renderPresetLanding();
1462
- this.#tui.requestRender();
1593
+ if (
1594
+ !this.#disposed &&
1595
+ this.#viewMode === "presets" &&
1596
+ refreshGeneration === this.#providerAuthRefreshGeneration
1597
+ ) {
1598
+ this.#providerAuthPending = false;
1599
+ this.#renderPresetLanding();
1600
+ this.#tui.requestRender();
1601
+ }
1463
1602
  }
1464
1603
  }
1465
1604
 
@@ -1676,6 +1815,7 @@ export class ModelSelectorComponent extends Container {
1676
1815
  this.#smartRoutingPanel = undefined;
1677
1816
  this.#viewMode = "presets";
1678
1817
  this.#presetCursor = Math.min(this.#presetCursor, Math.max(0, this.#getPresetRows().length - 1));
1818
+ void this.#refreshProviderAuth();
1679
1819
  this.#renderPresetLanding();
1680
1820
  this.#tui.requestRender();
1681
1821
  }
@@ -307,7 +307,9 @@ const jobsSegment: StatusLineSegment = {
307
307
  id: "jobs",
308
308
  render(ctx) {
309
309
  const { jobs } = ctx;
310
- const visible = jobs.activeMonitorCount > 0 || jobs.activeCronCount > 0 || jobs.worstState === "failed";
310
+ const foldedJobCount = jobs.foldedJobs?.filter(job => job.backgrounded).length ?? 0;
311
+ const visible =
312
+ jobs.activeMonitorCount > 0 || jobs.activeCronCount > 0 || foldedJobCount > 0 || jobs.worstState === "failed";
311
313
  if (!visible) {
312
314
  return { content: "", visible: false };
313
315
  }
@@ -318,6 +320,9 @@ const jobsSegment: StatusLineSegment = {
318
320
  if (jobs.activeCronCount > 0) {
319
321
  parts.push(withIcon(theme.icon.time, `${jobs.activeCronCount}`));
320
322
  }
323
+ if (foldedJobCount > 0) {
324
+ parts.push(withIcon(theme.icon.pause || theme.icon.agents, `${foldedJobCount} folded`));
325
+ }
321
326
  if (parts.length === 0) {
322
327
  // Nothing active but a failure is unacknowledged — keep a drill-in marker.
323
328
  parts.push(withIcon(theme.icon.warning, "jobs"));
@@ -312,7 +312,7 @@ export class ExtensionUiController {
312
312
  session.retryNow();
313
313
  return { retried: true, immediate: true };
314
314
  case "bash.background":
315
- if (!session.requestForegroundBashBackground())
315
+ if (!(await session.requestForegroundBashBackground()))
316
316
  throw Object.assign(new Error("The active bash command cannot be moved to a managed background job."), {
317
317
  code: "not_foldable",
318
318
  });