@gajae-code/coding-agent 0.15.4 → 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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  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 +27 -5
  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 +99 -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 +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. 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;
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  type AutocompleteItem,
3
3
  type AutocompleteProvider,
4
+ autocompleteFuzzyMatch,
5
+ autocompleteFuzzyScore,
4
6
  CombinedAutocompleteProvider,
5
7
  extractSlashCommandTokenPrefix,
6
8
  getSlashCommandMatchRank,
9
+ normalizeFuzzyText,
7
10
  type SlashCommand,
8
11
  } from "@gajae-code/tui";
9
12
  import type { KeybindingsManager } from "../config/keybindings";
@@ -50,43 +53,6 @@ interface PromptActionAutocompleteOptions {
50
53
  getPromptSuggestion?: () => string | null;
51
54
  }
52
55
 
53
- function fuzzyMatch(query: string, target: string): boolean {
54
- if (query.length === 0) return true;
55
- if (query.length > target.length) return false;
56
-
57
- let queryIndex = 0;
58
- for (let targetIndex = 0; targetIndex < target.length && queryIndex < query.length; targetIndex += 1) {
59
- if (query[queryIndex] === target[targetIndex]) {
60
- queryIndex += 1;
61
- }
62
- }
63
-
64
- return queryIndex === query.length;
65
- }
66
-
67
- function fuzzyScore(query: string, target: string): number {
68
- if (query.length === 0) return 1;
69
- if (target === query) return 100;
70
- if (target.startsWith(query)) return 80;
71
- if (target.includes(query)) return 60;
72
-
73
- let queryIndex = 0;
74
- let gaps = 0;
75
- let lastMatchIndex = -1;
76
- for (let targetIndex = 0; targetIndex < target.length && queryIndex < query.length; targetIndex += 1) {
77
- if (query[queryIndex] === target[targetIndex]) {
78
- if (lastMatchIndex >= 0 && targetIndex - lastMatchIndex > 1) {
79
- gaps += 1;
80
- }
81
- lastMatchIndex = targetIndex;
82
- queryIndex += 1;
83
- }
84
- }
85
-
86
- if (queryIndex !== query.length) return 0;
87
- return Math.max(1, 40 - gaps * 5);
88
- }
89
-
90
56
  function isPromptActionItem(item: AutocompleteItem): item is PromptActionAutocompleteItem {
91
57
  return "actionId" in item && "execute" in item && typeof item.execute === "function";
92
58
  }
@@ -127,6 +93,7 @@ function sortSlashCommandSuggestions(
127
93
  ): { items: AutocompleteItem[]; prefix: string } | null {
128
94
  if (!suggestions) return null;
129
95
  const query = suggestions.prefix.slice(1).toLowerCase();
96
+ const normalizedQuery = normalizeFuzzyText(query);
130
97
  const commandIndexes = new Map(commands.map((command, index) => [command.name, index]));
131
98
  const commandByName = new Map(commands.map(command => [command.name, command]));
132
99
  const items = suggestions.items
@@ -135,13 +102,20 @@ function sortSlashCommandSuggestions(
135
102
  const commandIndex = commandIndexes.get(item.value) ?? index;
136
103
  const lowerName = item.value.toLowerCase();
137
104
  const lowerDesc = command?.description?.toLowerCase() ?? item.description?.toLowerCase() ?? "";
138
- const nameScore = fuzzyMatch(query, lowerName) ? fuzzyScore(query, lowerName) : 0;
139
- const descScore = fuzzyMatch(query, lowerDesc) ? fuzzyScore(query, lowerDesc) * 0.5 : 0;
105
+ const normalizedName = normalizeFuzzyText(item.value);
106
+ const normalizedDesc = normalizeFuzzyText(lowerDesc);
107
+ const nameScore = autocompleteFuzzyMatch(normalizedQuery, normalizedName)
108
+ ? autocompleteFuzzyScore(normalizedQuery, normalizedName)
109
+ : 0;
110
+ const descScore = autocompleteFuzzyMatch(normalizedQuery, normalizedDesc)
111
+ ? autocompleteFuzzyScore(normalizedQuery, normalizedDesc) * 0.5
112
+ : 0;
113
+ const isAscii = /^[\x00-\x7F]*$/.test(`${query}${item.value}`);
140
114
  return {
141
115
  item,
142
116
  index,
143
117
  commandIndex,
144
- matchRank: getSlashCommandMatchRank(query, lowerName),
118
+ matchRank: isAscii ? getSlashCommandMatchRank(query, lowerName) : 4,
145
119
  priority: getSlashCommandPriority(command, item),
146
120
  score: Math.max(nameScore, descScore),
147
121
  };
@@ -219,14 +193,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
219
193
  const searchable = [action.id, action.label, action.description, ...action.keywords]
220
194
  .join(" ")
221
195
  .toLowerCase();
222
- if (!fuzzyMatch(query, searchable)) return null;
196
+ if (!autocompleteFuzzyMatch(query, searchable)) return null;
223
197
  return {
224
198
  value: action.label,
225
199
  label: action.label,
226
200
  description: action.description,
227
201
  actionId: action.id,
228
202
  execute: action.execute,
229
- score: fuzzyScore(query, searchable),
203
+ score: autocompleteFuzzyScore(query, searchable),
230
204
  } satisfies PromptActionAutocompleteItem & { score: number };
231
205
  })
232
206
  .filter(item => item !== null)
@@ -350,7 +324,10 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
350
324
  if (!prefix) return null;
351
325
  const query = prefix.slice(1).toLowerCase();
352
326
  if (query.length === 0 && !options.includeEmpty) return null;
353
- const normalizedQuery = query.startsWith("skill-") ? `skill:${query.slice("skill-".length)}` : query;
327
+ const normalizedQuery = normalizeFuzzyText(
328
+ query.startsWith("skill-") ? `skill:${query.slice("skill-".length)}` : query,
329
+ );
330
+ if (query.length > 0 && normalizedQuery.length === 0) return null;
354
331
  const exactNonSkillCommand = this.#commands.some(
355
332
  command => command.name === query && !command.name.startsWith("skill:"),
356
333
  );
@@ -364,16 +341,18 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
364
341
  `skill-${skillName}`,
365
342
  ...(exactNonSkillCommand ? [] : [skillName]),
366
343
  command.description ?? "",
367
- ].map(target => target.toLowerCase());
344
+ ].map(target => normalizeFuzzyText(target));
368
345
  if (
369
- !searchTargets.some(target => fuzzyMatch(normalizedQuery, target) || target.includes(normalizedQuery))
346
+ !searchTargets.some(
347
+ target => autocompleteFuzzyMatch(normalizedQuery, target) || target.includes(normalizedQuery),
348
+ )
370
349
  ) {
371
350
  return null;
372
351
  }
373
352
  const bestScore = Math.max(
374
353
  ...searchTargets.map(target =>
375
- fuzzyMatch(normalizedQuery, target)
376
- ? fuzzyScore(normalizedQuery, target)
354
+ autocompleteFuzzyMatch(normalizedQuery, target)
355
+ ? autocompleteFuzzyScore(normalizedQuery, target)
377
356
  : target.includes(normalizedQuery)
378
357
  ? 60
379
358
  : 0,
@@ -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",