@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
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { type AgentMessage, ThinkingLevel } from "@gajae-code/agent-core";
4
- import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@gajae-code/tui";
4
+ import { type AutocompleteProvider, getKeybindings, matchesKey, type SlashCommand } from "@gajae-code/tui";
5
5
  import { $pickenv, logger, sanitizeText } from "@gajae-code/utils";
6
6
  import { type AppKeybinding, KEYBINDINGS } from "../../config/keybindings";
7
7
  import { isSettingsInitialized, settings } from "../../config/settings";
@@ -1855,19 +1855,36 @@ export class InputController {
1855
1855
  const now = Date.now();
1856
1856
  if (now - this.#lastBackgroundFoldKeyTime > BACKGROUND_FOLD_DOUBLE_PRESS_MS) {
1857
1857
  this.#lastBackgroundFoldKeyTime = now;
1858
- this.ctx.showStatus("Press Ctrl+B again to fold supported foreground bash into a background job");
1858
+ const foldKeys = getKeybindings().getKeys("app.tool.backgroundFold");
1859
+ this.ctx.showStatus(
1860
+ `Press ${foldKeys.join(" / ")} again to fold supported foreground bash into a background job`,
1861
+ );
1859
1862
  return true;
1860
1863
  }
1861
1864
  this.#lastBackgroundFoldKeyTime = 0;
1862
1865
 
1863
- if (!this.ctx.session.requestForegroundBashBackground?.()) {
1866
+ try {
1867
+ const request = this.ctx.session.requestForegroundBashBackground?.() ?? Promise.resolve(false);
1868
+ void request
1869
+ .then(backgrounded => {
1870
+ if (backgrounded) {
1871
+ this.ctx.showStatus("Folding foreground bash into a quiet background job…");
1872
+ return;
1873
+ }
1874
+ this.ctx.showWarning(
1875
+ "No supported foreground tool can be folded. Use managed async bash/auto-background; raw Ctrl+Z/bg is not supported inside the TUI.",
1876
+ );
1877
+ })
1878
+ .catch(() => {
1879
+ this.ctx.showWarning(
1880
+ "No supported foreground tool can be folded. Use managed async bash/auto-background; raw Ctrl+Z/bg is not supported inside the TUI.",
1881
+ );
1882
+ });
1883
+ } catch {
1864
1884
  this.ctx.showWarning(
1865
1885
  "No supported foreground tool can be folded. Use managed async bash/auto-background; raw Ctrl+Z/bg is not supported inside the TUI.",
1866
1886
  );
1867
- return true;
1868
1887
  }
1869
-
1870
- this.ctx.showStatus("Folding foreground bash into a quiet background job…");
1871
1888
  return true;
1872
1889
  }
1873
1890
 
@@ -426,6 +426,7 @@ export class InteractiveMode implements InteractiveModeContext {
426
426
  lastComposerClearEscapeTime = 0;
427
427
  shutdownRequested = false;
428
428
  #isShuttingDown = false;
429
+ #shutdownEscalated = false;
429
430
  hookSelector: HookSelectorComponent | undefined = undefined;
430
431
  hookInput: HookInputComponent | undefined = undefined;
431
432
  hookEditor: HookEditorComponent | undefined = undefined;
@@ -1682,7 +1683,19 @@ export class InteractiveMode implements InteractiveModeContext {
1682
1683
  }
1683
1684
 
1684
1685
  async shutdown(): Promise<void> {
1685
- if (this.#isShuttingDown) return;
1686
+ if (this.#isShuttingDown) {
1687
+ // A second explicit exit is an escalation, not another silent no-op. The
1688
+ // graceful path may be waiting on a lock-hostile filesystem; postmortem.quit
1689
+ // has its own bounded cleanup deadline and will force the process out even
1690
+ // when the first shutdown cannot finish.
1691
+ if (!this.#shutdownEscalated) {
1692
+ this.#shutdownEscalated = true;
1693
+ logger.warn("Escalating repeated interactive shutdown request");
1694
+ this.session.abort();
1695
+ void postmortem.quit(1);
1696
+ }
1697
+ return;
1698
+ }
1686
1699
  this.#isShuttingDown = true;
1687
1700
 
1688
1701
  // `/btw` owns the shared composer while its panel is open. Never persist a
@@ -16,12 +16,48 @@ import type { AsyncJob, AsyncJobManager } from "../async";
16
16
  import { deleteCronJobById, listCronSnapshots, onCronChange } from "../tools/cron";
17
17
 
18
18
  export type JobsWorstState = "none" | "running" | "failed";
19
+ export type JobDeliveryState = "pending" | "delivered" | "failed-visible";
20
+
21
+ /** The manager-owned, one-pass job projection consumed by this observer. */
22
+ export interface AsyncJobSnapshotEntry {
23
+ id: string;
24
+ kind: string;
25
+ label: string;
26
+ status: AsyncJob["status"];
27
+ generation: string;
28
+ backgrounded: boolean;
29
+ deliveryState: JobDeliveryState;
30
+ }
31
+
32
+ /** Scalar delivery failure evidence that may outlive the AsyncJob record. */
33
+ export interface DeadLetteredJobSnapshotEntry {
34
+ jobId: string;
35
+ generation: string;
36
+ ownerId?: string;
37
+ backgrounded?: boolean;
38
+ attempt: number;
39
+ lastError?: string;
40
+ recordedAt: number;
41
+ }
42
+
43
+ export interface AsyncJobsSnapshot {
44
+ jobs: AsyncJobSnapshotEntry[];
45
+ deadLettered: DeadLetteredJobSnapshotEntry[];
46
+ }
19
47
 
20
48
  export interface MonitorJobView {
21
49
  id: string;
22
50
  label: string;
23
51
  status: AsyncJob["status"];
24
52
  startTime: number;
53
+ generation?: string;
54
+ backgrounded?: boolean;
55
+ deliveryState?: JobDeliveryState;
56
+ }
57
+
58
+ export interface FoldedJobView extends AsyncJobSnapshotEntry {
59
+ /** Safe diagnostic retained for a dead-lettered delivery, when available. */
60
+ errorText?: string;
25
61
  }
26
62
 
27
63
  export interface CronJobView {
@@ -39,6 +75,10 @@ export interface CronJobView {
39
75
  export interface JobsSnapshot {
40
76
  monitors: MonitorJobView[];
41
77
  crons: CronJobView[];
78
+ /** Backgrounded work and undeliverable terminal work that must stay visible. */
79
+ foldedJobs?: FoldedJobView[];
80
+ /** Scalar dead-letter evidence retained for diagnostics and red-drill-in. */
81
+ deadLettered?: DeadLetteredJobSnapshotEntry[];
42
82
  activeMonitorCount: number;
43
83
  activeCronCount: number;
44
84
  worstState: JobsWorstState;
@@ -48,12 +88,22 @@ export interface JobsSnapshot {
48
88
  export const EMPTY_JOBS_SNAPSHOT: JobsSnapshot = {
49
89
  monitors: [],
50
90
  crons: [],
91
+ foldedJobs: [],
92
+ deadLettered: [],
51
93
  activeMonitorCount: 0,
52
94
  activeCronCount: 0,
53
95
  worstState: "none",
54
96
  failedUnacknowledged: false,
55
97
  };
56
98
 
99
+ function jobKey(id: string, generation: string): string {
100
+ return `${id}\u0000${generation}`;
101
+ }
102
+
103
+ interface AsyncJobSnapshotSource {
104
+ getJobsSnapshot?: (filter?: { ownerId?: string }) => AsyncJobsSnapshot;
105
+ }
106
+
57
107
  export class JobsObserver {
58
108
  readonly #manager: AsyncJobManager;
59
109
  readonly #ownerId: string | undefined;
@@ -103,7 +153,14 @@ export class JobsObserver {
103
153
  }
104
154
  }
105
155
 
156
+ #readAsyncJobsSnapshot(): AsyncJobsSnapshot {
157
+ const source = this.#manager as AsyncJobSnapshotSource;
158
+ if (typeof source.getJobsSnapshot !== "function") return { jobs: [], deadLettered: [] };
159
+ return source.getJobsSnapshot(this.#ownerId ? { ownerId: this.#ownerId } : undefined);
160
+ }
161
+
106
162
  #listMonitorJobs(): AsyncJob[] {
163
+ if (typeof this.#manager.getAllJobs !== "function") return [];
107
164
  const filter = this.#ownerId ? { ownerId: this.#ownerId } : undefined;
108
165
  return this.#manager.getAllJobs(filter).filter(job => job.type === "bash" && job.metadata?.monitor === true);
109
166
  }
@@ -114,25 +171,92 @@ export class JobsObserver {
114
171
  * snapshot (never scans the manager/cron stores).
115
172
  */
116
173
  #recompute(): void {
174
+ const asyncSnapshot = this.#readAsyncJobsSnapshot();
175
+ const snapshotEntries = new Map<string, AsyncJobSnapshotEntry>();
176
+ for (const entry of asyncSnapshot.jobs) snapshotEntries.set(jobKey(entry.id, entry.generation), entry);
177
+
117
178
  const monitorJobs = this.#listMonitorJobs();
118
- const presentIds = new Set(monitorJobs.map(job => job.id));
119
- // Prune acknowledged ids whose jobs have been evicted.
120
- for (const id of this.#acknowledgedFailedIds) {
121
- if (!presentIds.has(id)) this.#acknowledgedFailedIds.delete(id);
179
+ const monitorKeys = new Set(monitorJobs.map(job => jobKey(job.id, job.generation)));
180
+ const presentKeys = new Set<string>([
181
+ ...asyncSnapshot.jobs.map(entry => jobKey(entry.id, entry.generation)),
182
+ ...asyncSnapshot.deadLettered.map(entry => jobKey(entry.jobId, entry.generation)),
183
+ ]);
184
+ // Prune acknowledged keys whose jobs and scalar dead letters have been evicted.
185
+ for (const key of this.#acknowledgedFailedIds) {
186
+ if (!presentKeys.has(key)) this.#acknowledgedFailedIds.delete(key);
122
187
  }
123
- // Sticky failure latch: set when an unacknowledged failed monitor is seen
124
- // (including at construction); stays set even after the failed job evicts,
125
- // until acknowledgeFailures() clears it.
126
- const hasUnacknowledgedFailure = monitorJobs.some(
127
- job => job.status === "failed" && !this.#acknowledgedFailedIds.has(job.id),
128
- );
188
+
189
+ // Sticky failure latch: the manager's terminal status and delivery
190
+ // classification are authoritative; neither is reconstructed from getters.
191
+ const hasUnacknowledgedFailure =
192
+ asyncSnapshot.jobs.some(
193
+ entry =>
194
+ (entry.deliveryState === "failed-visible" ||
195
+ (entry.status === "failed" &&
196
+ (entry.backgrounded || monitorKeys.has(jobKey(entry.id, entry.generation))))) &&
197
+ !this.#acknowledgedFailedIds.has(jobKey(entry.id, entry.generation)),
198
+ ) ||
199
+ asyncSnapshot.deadLettered.some(
200
+ entry => !this.#acknowledgedFailedIds.has(jobKey(entry.jobId, entry.generation)),
201
+ );
129
202
  if (hasUnacknowledgedFailure) this.#failedUnacknowledged = true;
130
203
 
131
- const activeMonitors = monitorJobs.filter(job => job.status === "running");
132
- const cronSnapshots = listCronSnapshots(this.#ownerId);
133
204
  const monitors: MonitorJobView[] = monitorJobs
134
- .map(job => ({ id: job.id, label: job.label, status: job.status, startTime: job.startTime }))
205
+ .flatMap(job => {
206
+ const entry = snapshotEntries.get(jobKey(job.id, job.generation));
207
+ if (!entry) return [];
208
+ return [
209
+ {
210
+ id: entry.id,
211
+ label: entry.label,
212
+ status: entry.status,
213
+ startTime: job.startTime,
214
+ generation: entry.generation,
215
+ backgrounded: entry.backgrounded,
216
+ deliveryState: entry.deliveryState,
217
+ },
218
+ ];
219
+ })
135
220
  .sort((a, b) => b.startTime - a.startTime);
221
+
222
+ // Keep every backgrounded job visible. A non-delivered terminal entry is
223
+ // also retained here so pending/failed delivery can never disappear merely
224
+ // because it was not marked backgrounded by its producer.
225
+ const deadLettersByKey = new Map(
226
+ asyncSnapshot.deadLettered.map(entry => [jobKey(entry.jobId, entry.generation), entry] as const),
227
+ );
228
+ const foldedJobs: FoldedJobView[] = asyncSnapshot.jobs
229
+ .filter(
230
+ entry =>
231
+ entry.backgrounded ||
232
+ (entry.status !== "running" &&
233
+ (entry.deliveryState === "failed-visible" ||
234
+ (entry.status === "failed" && entry.backgrounded) ||
235
+ entry.deliveryState !== "delivered")),
236
+ )
237
+ .map(entry => {
238
+ const lastError = deadLettersByKey.get(jobKey(entry.id, entry.generation))?.lastError;
239
+ return lastError === undefined ? { ...entry } : { ...entry, errorText: lastError };
240
+ });
241
+ const foldedKeys = new Set(foldedJobs.map(entry => jobKey(entry.id, entry.generation)));
242
+ for (const entry of asyncSnapshot.deadLettered) {
243
+ const key = jobKey(entry.jobId, entry.generation);
244
+ if (foldedKeys.has(key)) continue;
245
+ foldedKeys.add(key);
246
+ foldedJobs.push({
247
+ id: entry.jobId,
248
+ kind: "dead-letter",
249
+ label: entry.jobId,
250
+ status: "failed",
251
+ generation: entry.generation,
252
+ backgrounded: entry.backgrounded === true,
253
+ deliveryState: "failed-visible",
254
+ errorText: entry.lastError,
255
+ });
256
+ }
257
+
258
+ const activeMonitors = monitors.filter(monitor => monitor.status === "running" && !monitor.backgrounded);
259
+ const cronSnapshots = listCronSnapshots(this.#ownerId);
136
260
  const crons: CronJobView[] = cronSnapshots
137
261
  .map(snapshot => ({
138
262
  id: snapshot.id,
@@ -145,14 +269,17 @@ export class JobsObserver {
145
269
  firing: snapshot.firing,
146
270
  }))
147
271
  .sort((a, b) => b.createdAt - a.createdAt);
272
+ const hasRunningFoldedJob = foldedJobs.some(job => job.status === "running");
148
273
  const worstState: JobsWorstState = this.#failedUnacknowledged
149
274
  ? "failed"
150
- : activeMonitors.length > 0 || crons.length > 0
275
+ : activeMonitors.length > 0 || crons.length > 0 || hasRunningFoldedJob
151
276
  ? "running"
152
277
  : "none";
153
278
  this.#snapshot = {
154
279
  monitors,
155
280
  crons,
281
+ foldedJobs,
282
+ deadLettered: asyncSnapshot.deadLettered.map(entry => ({ ...entry })),
156
283
  activeMonitorCount: activeMonitors.length,
157
284
  activeCronCount: crons.length,
158
285
  worstState,
@@ -167,8 +294,14 @@ export class JobsObserver {
167
294
 
168
295
  /** Clear the failure latch (called when the user opens the jobs overlay). */
169
296
  acknowledgeFailures(): void {
170
- for (const job of this.#listMonitorJobs()) {
171
- if (job.status === "failed") this.#acknowledgedFailedIds.add(job.id);
297
+ const asyncSnapshot = this.#readAsyncJobsSnapshot();
298
+ for (const entry of asyncSnapshot.jobs) {
299
+ if (entry.status === "failed" || entry.deliveryState === "failed-visible") {
300
+ this.#acknowledgedFailedIds.add(jobKey(entry.id, entry.generation));
301
+ }
302
+ }
303
+ for (const entry of asyncSnapshot.deadLettered) {
304
+ this.#acknowledgedFailedIds.add(jobKey(entry.jobId, entry.generation));
172
305
  }
173
306
  if (!this.#failedUnacknowledged) return;
174
307
  this.#failedUnacknowledged = false;
@@ -343,7 +343,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
343
343
  return { retried: true, immediate: true };
344
344
  }
345
345
  case "bash.background": {
346
- if (!session.requestForegroundBashBackground())
346
+ if (!(await session.requestForegroundBashBackground()))
347
347
  throw Object.assign(
348
348
  new Error("The active bash command cannot be moved to a managed background job."),
349
349
  { code: "not_foldable" },
@@ -17,7 +17,11 @@ import type { InteractiveModeContext } from "../types";
17
17
  * (unhandled rejection → resident crash).
18
18
  */
19
19
  export function extensionUserMessageContentError(content: string | (TextContent | ImageContent)[]): Error | undefined {
20
- if (typeof content === "string") return undefined;
20
+ if (typeof content === "string") {
21
+ if (content.trim().length === 0)
22
+ return Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
23
+ return undefined;
24
+ }
21
25
  if (!Array.isArray(content)) {
22
26
  return Object.assign(new Error("sendUserMessage requires string or content-array content."), {
23
27
  code: "invalid_input",
@@ -30,6 +34,16 @@ export function extensionUserMessageContentError(content: string | (TextContent
30
34
  });
31
35
  }
32
36
  }
37
+ const text = content
38
+ .filter((part): part is TextContent => part.type === "text")
39
+ .map(part => part.text)
40
+ .join("\n");
41
+ const hasUsableImage = content.some(
42
+ (part): part is ImageContent =>
43
+ part.type === "image" && typeof part.data === "string" && part.data.trim().length > 0,
44
+ );
45
+ if (text.trim().length === 0 && !hasUsableImage)
46
+ return Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
33
47
  return undefined;
34
48
  }
35
49
  export function normalizeInjectedUserContent(content: string | (TextContent | ImageContent)[]): {
@@ -16,8 +16,8 @@ Executes bash command in shell session for terminal operations like git, bun, ca
16
16
  {{#if asyncEnabled}}
17
17
  - Use `async: true` for long-running commands when you don't need immediate output; the call returns a background job ID and the result is delivered automatically as a follow-up.
18
18
  {{/if}}
19
- {{#if autoBackgroundEnabled}}
20
- - In the interactive TUI, the user can press `Ctrl+B` twice while a supported managed foreground bash command is still running to fold it into a quiet background job. Do not instruct users to use raw shell `Ctrl+Z`/`bg` inside the GJC TUI; ownership and output routing are not safe there.
19
+ {{#if foregroundFoldEnabled}}
20
+ - In the interactive TUI, the user can press the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via the `app.tool.backgroundFold` keybinding) while a foreground command is still running to fold it into a background job. All three command surfaces fold: managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded command keeps its original timeout and is never killed or restarted by folding; a folded PTY continues output-only, so it stops accepting input. The turn then ends cleanly and a later turn resumes automatically with the command's result, so the interrupted task is finished rather than abandoned. `task`/`subagent` waits are deliberately NOT foldable. Do not instruct users to use raw shell `Ctrl+Z`/`bg` inside the GJC TUI; ownership and output routing are not safe there.
21
21
  {{/if}}
22
22
  </instruction>
23
23
  {{#if restrictedAllowedPrefixes}}
@@ -0,0 +1,3 @@
1
+ This result came from a folded {{kind}} wait (job {{jobId}}).
2
+ Output reference: {{outputInstruction}}
3
+ {{cwdNotice}}{{intentNotice}}
@@ -2,7 +2,7 @@ Inspects, waits, or cancels async jobs.
2
2
 
3
3
  Background job results are delivered automatically when complete. Jobs that back task subagents should be controlled via the `subagent` tool when it is available; use `job` for non-subagent jobs (async bash, monitors) and as a compatibility fallback. Running job output stays quiet by default to avoid flooding the conversation; use `tail` when you explicitly want to show/reopen retained output. Reach for this tool only when you need to inspect or intervene.
4
4
 
5
- In the interactive TUI, supported managed foreground bash can be folded into a background job by pressing `Ctrl+B` twice while it is running. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.
5
+ In the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`). This covers managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded job keeps its original deadline, is never killed or restarted by folding, and its result wakes a later turn automatically; a folded PTY continues output-only and no longer accepts input. `task`/`subagent` waits are not foldable. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.
6
6
 
7
7
  # Operations
8
8
 
@@ -9,7 +9,11 @@ import {
9
9
  type SessionReconcileUncertainTarget,
10
10
  validateSessionReconcileUncertainTarget,
11
11
  } from "../lifecycle/service";
12
- import { validateAdapterControl, validateAdapterSecretFields } from "../protocol/adapter-validation";
12
+ import {
13
+ validateAdapterControl,
14
+ validateAdapterSecretFields,
15
+ validateRequiredPromptText,
16
+ } from "../protocol/adapter-validation";
13
17
  import { OPERATIONS } from "../protocol/operation-registry";
14
18
  import { type SessionAttachment, type SessionRouter, SessionRouterError } from "../router";
15
19
  import { ACP_SESSION_RECONNECT, SESSION_ABORT_TIMEOUT_MS } from "../session-reconnect";
@@ -343,7 +347,18 @@ export class AcpSdkAdapter {
343
347
  }
344
348
 
345
349
  async prompt(params: JsonObject | string): Promise<unknown> {
346
- const text = typeof params === "string" ? params : String(params.prompt ?? params.text ?? "");
350
+ const rawText =
351
+ typeof params === "string"
352
+ ? params
353
+ : params !== null && typeof params === "object"
354
+ ? (params.prompt ?? params.text)
355
+ : undefined;
356
+ const text = typeof rawText === "string" ? rawText : "";
357
+ const invalid = validateRequiredPromptText("turn.prompt", {
358
+ text,
359
+ ...(typeof params === "object" && Array.isArray(params.images) ? { images: params.images } : {}),
360
+ });
361
+ if (invalid) throw new AcpSdkAdapterError(invalid.code, invalid.message);
347
362
  return await this.#requestSession({
348
363
  type: "control_request",
349
364
  operation: "turn.prompt",
@@ -46,6 +46,8 @@ export interface BrokerSettings {
46
46
  heartbeatTtlMs?: number;
47
47
  /** Broker-owned migration policy. Client lifecycle frames cannot select it. */
48
48
  resolveDirectoryMigration?: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
49
+ /** Host model resolver override for lifecycle tests and embedders. */
50
+ resolveModelPin?: SdkHostModelResolver;
49
51
  }
50
52
 
51
53
  type ResolvedBrokerSettings = {
@@ -863,7 +865,8 @@ export class Broker {
863
865
  #completion!: Promise<void>;
864
866
  #resolveCompletion!: () => void;
865
867
  #rejectCompletion!: (error: unknown) => void;
866
- readonly #resolveModelPin: SdkHostModelResolver;
868
+ #resolveModelPin: SdkHostModelResolver;
869
+ #ownsResolveModelPin: boolean;
867
870
  constructor(settings: BrokerSettings) {
868
871
  this.settings = {
869
872
  agentDir: settings.agentDir,
@@ -874,7 +877,8 @@ export class Broker {
874
877
  };
875
878
  this.index = new SessionIndex(settings.agentDir);
876
879
  this.ledger = new LifecycleLedger(settings.agentDir);
877
- this.#resolveModelPin = createDefaultSdkHostModelResolver(this.settings.agentDir);
880
+ this.#ownsResolveModelPin = settings.resolveModelPin === undefined;
881
+ this.#resolveModelPin = settings.resolveModelPin ?? createDefaultSdkHostModelResolver(this.settings.agentDir);
878
882
  this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
879
883
  const completion = Promise.withResolvers<void>();
880
884
  this.#completion = completion.promise;
@@ -1023,6 +1027,8 @@ export class Broker {
1023
1027
  this.#resolveCompletion = completion.resolve;
1024
1028
  this.#rejectCompletion = completion.reject;
1025
1029
  this.#completionTask = null;
1030
+ if (this.#ownsResolveModelPin)
1031
+ this.#resolveModelPin = createDefaultSdkHostModelResolver(this.settings.agentDir);
1026
1032
  // A drained queue refuses every later startup by design, so a restarted broker
1027
1033
  // needs a new one or it would admit nothing for the rest of the process.
1028
1034
  this.#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
@@ -1285,25 +1291,35 @@ export class Broker {
1285
1291
  if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
1286
1292
  this.#heartbeatTimer = null;
1287
1293
  this.#completionTask = (async () => {
1288
- await this.#transport?.stop();
1289
- this.#transport = null;
1290
- if (mode === "lost-root")
1291
- await Promise.race([Promise.allSettled(this.#admitted), Bun.sleep(BROKER_SETTLEMENT_MS)]);
1292
- else await Promise.allSettled(this.#admitted);
1293
- this.#publication?.close();
1294
- this.#publication = null;
1295
- if (mode === "owned-root" && this.discovery?.ownerId === this.#owner) {
1296
- try {
1297
- const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
1298
- ownerId?: string;
1299
- };
1300
- if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
1301
- } catch (e) {
1302
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
1294
+ try {
1295
+ await this.#transport?.stop();
1296
+ this.#transport = null;
1297
+ if (mode === "lost-root")
1298
+ await Promise.race([Promise.allSettled(this.#admitted), Bun.sleep(BROKER_SETTLEMENT_MS)]);
1299
+ else await Promise.allSettled(this.#admitted);
1300
+ this.#publication?.close();
1301
+ this.#publication = null;
1302
+ if (mode === "owned-root" && this.discovery?.ownerId === this.#owner) {
1303
+ try {
1304
+ const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
1305
+ ownerId?: string;
1306
+ };
1307
+ if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
1308
+ } catch (e) {
1309
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
1310
+ }
1311
+ await this.#releaseOwnedLock();
1312
+ }
1313
+ } finally {
1314
+ const disposeModelPin = this.#ownsResolveModelPin ? this.#resolveModelPin.dispose?.() : undefined;
1315
+ if (mode === "lost-root" && disposeModelPin !== undefined) {
1316
+ void disposeModelPin.catch(() => undefined);
1317
+ await Promise.race([disposeModelPin, Bun.sleep(BROKER_SETTLEMENT_MS)]);
1318
+ } else {
1319
+ await disposeModelPin;
1303
1320
  }
1304
- await this.#releaseOwnedLock();
1321
+ this.discovery = null;
1305
1322
  }
1306
- this.discovery = null;
1307
1323
  })();
1308
1324
  void this.#completionTask.then(this.#resolveCompletion, this.#rejectCompletion);
1309
1325
  return this.#completionTask;
@@ -1417,14 +1433,25 @@ export class Broker {
1417
1433
 
1418
1434
  try {
1419
1435
  const endpointPath = path.join(record.locator.stateRoot, "sdk", `${record.sessionId}.json`);
1420
- const [source, metadata] = await Promise.all([fs.readFile(endpointPath, "utf8"), fs.stat(endpointPath)]);
1436
+ const before = await fs.lstat(endpointPath, { bigint: true });
1437
+ if (!before.isFile()) return error("endpoint_stale", "session endpoint is not a regular file");
1438
+ const source = await fs.readFile(endpointPath, "utf8");
1439
+ const metadata = await fs.lstat(endpointPath, { bigint: true });
1440
+ if (
1441
+ before.dev !== metadata.dev ||
1442
+ before.ino !== metadata.ino ||
1443
+ before.size !== metadata.size ||
1444
+ before.mtimeNs !== metadata.mtimeNs ||
1445
+ before.ctimeNs !== metadata.ctimeNs
1446
+ )
1447
+ return error("endpoint_stale", "session endpoint changed during read");
1421
1448
  const endpoint = JSON.parse(source) as Record<string, unknown>;
1422
1449
  if (
1423
1450
  endpoint.sessionId !== record.sessionId ||
1424
1451
  endpoint.pid !== record.pid ||
1425
1452
  endpoint.stale === true ||
1426
1453
  record.endpointMtimeMs === undefined ||
1427
- Math.abs(metadata.mtimeMs - record.endpointMtimeMs) > 0.001
1454
+ Math.abs(Number(metadata.mtimeNs) / 1_000_000 - record.endpointMtimeMs) > 0.001
1428
1455
  )
1429
1456
  return error("endpoint_stale", "session endpoint is stale");
1430
1457
  await this.index.refresh();
@@ -14,7 +14,10 @@ function nativeLifecycle(): typeof import("@gajae-code/natives") {
14
14
  }
15
15
 
16
16
  import { $credentialEnv, logger, resolveEquivalentPath } from "@gajae-code/utils";
17
-
17
+ import {
18
+ loadAcceptedModelPresetRegistry,
19
+ loadAcceptedModelPresetRegistryAsync,
20
+ } from "../../config/model-preset-registry";
18
21
  import {
19
22
  isModelProfileError,
20
23
  type ModelProfileErrorDetails,
@@ -609,13 +612,36 @@ function text(value: unknown): string | undefined {
609
612
  return typeof value === "string" && value ? value : undefined;
610
613
  }
611
614
 
612
- function validateBrokerModelPreset(agentDir: string, requestedProfile: string): string | BrokerResponse {
615
+ function validateBrokerModelPresetSync(agentDir: string, requestedProfile: string): string | BrokerResponse {
616
+ const modelsConfigFile = ModelsConfigFile.relocate(path.join(agentDir, "models.yml"));
617
+ modelsConfigFile.invalidate();
618
+ const loaded = modelsConfigFile.tryLoad();
619
+ const accepted = loadAcceptedModelPresetRegistry(agentDir);
620
+ const profiles = mergeModelProfiles(loaded.status === "ok" ? loaded.value.profiles : undefined, accepted.profiles);
621
+ try {
622
+ return validateModelProfileName(
623
+ requestedProfile,
624
+ profiles,
625
+ accepted.error ?? (loaded.status === "error" ? loaded.error : undefined),
626
+ );
627
+ } catch (error) {
628
+ if (isModelProfileError(error)) return fail(error.code, error.message, undefined, error.details);
629
+ throw error;
630
+ }
631
+ }
632
+
633
+ async function validateBrokerModelPreset(agentDir: string, requestedProfile: string): Promise<string | BrokerResponse> {
613
634
  const modelsConfigFile = ModelsConfigFile.relocate(path.join(agentDir, "models.yml"));
614
635
  modelsConfigFile.invalidate();
615
636
  const loaded = modelsConfigFile.tryLoad();
616
- const profiles = mergeModelProfiles(loaded.status === "ok" ? loaded.value.profiles : undefined);
637
+ const accepted = await loadAcceptedModelPresetRegistryAsync(agentDir);
638
+ const profiles = mergeModelProfiles(loaded.status === "ok" ? loaded.value.profiles : undefined, accepted.profiles);
617
639
  try {
618
- return validateModelProfileName(requestedProfile, profiles, loaded.status === "error" ? loaded.error : undefined);
640
+ return validateModelProfileName(
641
+ requestedProfile,
642
+ profiles,
643
+ accepted.error ?? (loaded.status === "error" ? loaded.error : undefined),
644
+ );
619
645
  } catch (error) {
620
646
  if (isModelProfileError(error)) return fail(error.code, error.message, undefined, error.details);
621
647
  throw error;
@@ -623,7 +649,7 @@ function validateBrokerModelPreset(agentDir: string, requestedProfile: string):
623
649
  }
624
650
 
625
651
  export function validateBrokerModelPresetForTest(agentDir: string, requestedProfile: string): string | BrokerResponse {
626
- return validateBrokerModelPreset(agentDir, requestedProfile);
652
+ return validateBrokerModelPresetSync(agentDir, requestedProfile);
627
653
  }
628
654
 
629
655
  function readinessTimeout(input: Input): number | BrokerResponse {
@@ -4030,7 +4056,7 @@ async function launchInput(
4030
4056
  if (input.modelPreset !== undefined && (typeof input.modelPreset !== "string" || input.modelPreset.length === 0))
4031
4057
  return fail("invalid_input", "modelPreset must be a non-empty exact profile ID.");
4032
4058
  if (modelPreset !== undefined) {
4033
- const validatedModelPreset = validateBrokerModelPreset(broker.settings.agentDir, modelPreset);
4059
+ const validatedModelPreset = await validateBrokerModelPreset(broker.settings.agentDir, modelPreset);
4034
4060
  if (typeof validatedModelPreset !== "string") return validatedModelPreset;
4035
4061
  modelPreset = validatedModelPreset;
4036
4062
  }
@@ -132,8 +132,8 @@ export type ChatDaemonAction = "stop" | "reload";
132
132
  * would retain the old hot polling loop, so replacement is required.
133
133
  */
134
134
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
135
- discord: 70,
136
- slack: 73,
135
+ discord: 71,
136
+ slack: 74,
137
137
  };
138
138
 
139
139
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -114,6 +114,10 @@ function readGeneration(value: unknown): number | undefined {
114
114
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
115
115
  }
116
116
 
117
+ function readSequence(value: unknown): number | undefined {
118
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
119
+ }
120
+
117
121
  /**
118
122
  * What one representation of a frame states about an identity it owns.
119
123
  *
@@ -138,7 +142,7 @@ const ABSENT_IDENTITY: IdentityClaim<never> = { state: "absent" };
138
142
  */
139
143
  function identityClaim<T>(
140
144
  frame: Record<string, unknown> | undefined,
141
- key: "sessionId" | "generation" | "name" | "kind",
145
+ key: "sessionId" | "generation" | "name" | "kind" | "seq",
142
146
  read: (value: unknown) => T | undefined,
143
147
  ): IdentityClaim<T> {
144
148
  if (!frame || !Object.hasOwn(frame, key)) return ABSENT_IDENTITY;
@@ -232,11 +236,17 @@ function correlateFrame(frame: Record<string, unknown>): CorrelatedFrame | undef
232
236
  identityClaim(payload, "generation", readGeneration),
233
237
  );
234
238
  if (!generation.ok) return undefined;
239
+ const seq = reconcileIdentity(
240
+ identityClaim(frame, "seq", readSequence),
241
+ identityClaim(payload, "seq", readSequence),
242
+ );
243
+ if (!seq.ok) return undefined;
235
244
  return {
236
245
  body,
237
246
  name: envelopeName.value ?? bodyName,
238
247
  sessionId: sessionId.value,
239
248
  generation: generation.value,
249
+ seq: seq.value,
240
250
  };
241
251
  }
242
252