@gajae-code/coding-agent 0.12.7 → 0.12.10

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 (117) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/commands/sdk.d.ts +23 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  7. package/dist/types/config/model-registry.d.ts +2 -0
  8. package/dist/types/config/resolve-config-value.d.ts +2 -1
  9. package/dist/types/config/settings-schema.d.ts +17 -1
  10. package/dist/types/discovery/agents-md.d.ts +11 -1
  11. package/dist/types/main.d.ts +1 -0
  12. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  13. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  14. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  16. package/dist/types/modes/types.d.ts +6 -0
  17. package/dist/types/sdk/broker/broker.d.ts +5 -1
  18. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/config.d.ts +75 -113
  21. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +54 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  33. package/dist/types/sdk/index.d.ts +1 -0
  34. package/dist/types/sdk/providers.d.ts +22 -0
  35. package/dist/types/sdk/session-directory.d.ts +1 -1
  36. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  37. package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
  38. package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
  39. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  40. package/dist/types/session/session-manager.d.ts +3 -1
  41. package/dist/types/session/session-storage.d.ts +2 -0
  42. package/dist/types/system-prompt.d.ts +12 -1
  43. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  44. package/package.json +10 -8
  45. package/scripts/build-sdk-package-smoke.ts +51 -0
  46. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  47. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  48. package/src/cli/notify-cli.ts +215 -99
  49. package/src/commands/notify.ts +19 -8
  50. package/src/commands/sdk.ts +54 -0
  51. package/src/config/file-lock.ts +6 -0
  52. package/src/config/model-discovery-manager.ts +50 -8
  53. package/src/config/model-profiles.ts +8 -0
  54. package/src/config/model-registry.ts +866 -76
  55. package/src/config/resolve-config-value.ts +10 -8
  56. package/src/config/settings-schema.ts +25 -15
  57. package/src/config/settings.ts +15 -15
  58. package/src/discovery/agents-md.ts +76 -19
  59. package/src/gjc-runtime/psmux-detect.ts +12 -16
  60. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  61. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  62. package/src/internal-urls/docs-index.generated.ts +10 -9
  63. package/src/main.ts +43 -7
  64. package/src/modes/DESIGN.md +11 -11
  65. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  66. package/src/modes/components/hook-selector.ts +155 -44
  67. package/src/modes/components/model-selector.ts +242 -49
  68. package/src/modes/components/notifications-settings-editor.ts +631 -85
  69. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  70. package/src/modes/controllers/selector-controller.ts +525 -93
  71. package/src/modes/print-mode.ts +3 -0
  72. package/src/modes/types.ts +6 -0
  73. package/src/prompts/system/system-prompt.md +1 -0
  74. package/src/sdk/broker/broker.ts +54 -10
  75. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  76. package/src/sdk/broker/lifecycle.ts +47 -50
  77. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  78. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  79. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  80. package/src/sdk/bus/config.ts +534 -286
  81. package/src/sdk/bus/daemon-paths.ts +2 -0
  82. package/src/sdk/bus/discord-live-provider.ts +57 -2
  83. package/src/sdk/bus/discord-provider.ts +21 -0
  84. package/src/sdk/bus/index.ts +97 -35
  85. package/src/sdk/bus/notification-orchestration.ts +371 -68
  86. package/src/sdk/bus/notification-service.ts +571 -137
  87. package/src/sdk/bus/session-control.ts +119 -44
  88. package/src/sdk/bus/slack-live-provider.ts +78 -2
  89. package/src/sdk/bus/slack-provider.ts +24 -0
  90. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  91. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  92. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  93. package/src/sdk/bus/telegram-daemon.ts +23 -15
  94. package/src/sdk/client/discovery.ts +10 -4
  95. package/src/sdk/host/query/handlers.ts +20 -1
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  98. package/src/sdk/protocol/operation-registry.ts +5 -2
  99. package/src/sdk/providers.ts +51 -0
  100. package/src/sdk/session-directory.ts +1 -0
  101. package/src/sdk/session.ts +34 -14
  102. package/src/session/agent-session.ts +109 -37
  103. package/src/session/internal/managed-session-scope.ts +981 -450
  104. package/src/session/internal/managed-session-storage.ts +418 -47
  105. package/src/session/internal/native-publish-outcome.ts +15 -3
  106. package/src/session/internal/session-open-errors.ts +11 -0
  107. package/src/session/session-manager.ts +27 -3
  108. package/src/session/session-storage.ts +72 -5
  109. package/src/slash-commands/builtin-registry.ts +117 -15
  110. package/src/system-prompt.ts +28 -15
  111. package/src/task/index.ts +9 -3
  112. package/src/tools/ask.ts +1 -1
  113. package/src/tools/bash.ts +4 -2
  114. package/src/tools/composer-bash-policy.ts +21 -5
  115. package/src/tools/telegram-send.ts +4 -2
  116. package/src/utils/command-args.ts +19 -30
  117. package/src/utils/lang-from-path.ts +2 -4
@@ -1,4 +1,6 @@
1
1
  import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../../prompt-status.js";
2
+ import type { ActiveProviderDescriptor } from "../../providers.js";
3
+ import { ActiveProviderResolutionError } from "../../providers.js";
2
4
  import {
3
5
  assertCursorSelector,
4
6
  type CursorEnvelope,
@@ -22,6 +24,7 @@ export interface SessionSurface {
22
24
  getUsage(): unknown | Promise<unknown>;
23
25
  getModels(): unknown | Promise<unknown>;
24
26
  getSkillState(): unknown | Promise<unknown>;
27
+ getActiveProviders?(): ActiveProviderDescriptor[] | Promise<ActiveProviderDescriptor[]>;
25
28
  /** Q12 rows preserve workflow gate fields and include stable durable gate metadata. */
26
29
  getGates(): unknown | Promise<unknown>;
27
30
  getConfigItems(): unknown | Promise<unknown>;
@@ -102,6 +105,7 @@ const sources: Record<string, { resource: string; method: keyof SessionSurface;
102
105
  Q21: { resource: "queue", method: "getQueueMessages", mvcc: true },
103
106
  Q22: { resource: "extensions", method: "getExtensions", mvcc: true },
104
107
  Q25: { resource: "jobs", method: "getJobs", mvcc: false },
108
+ Q29: { resource: "activeProviders", method: "getActiveProviders", mvcc: false },
105
109
  Q27: { resource: "modelProfiles", method: "getModelProfiles", mvcc: true },
106
110
  };
107
111
  const names = [
@@ -133,6 +137,7 @@ const names = [
133
137
  "turn.prompt_status",
134
138
  "models.profiles.list",
135
139
  "skill.invoke_status",
140
+ "providers.list/active",
136
141
  ];
137
142
 
138
143
  export class QueryHandlers {
@@ -168,6 +173,15 @@ export class QueryHandlers {
168
173
  return this.#error(request, "invalid_request", false, "models.profiles.list does not accept input fields.");
169
174
  if (query === "Q27" && typeof this.surface.getModelProfiles !== "function")
170
175
  return this.#error(request, "unavailable", false, "models.profiles.list is unavailable for this session.");
176
+ if (query === "Q29" && request.input && Object.keys(request.input).length > 0)
177
+ return this.#error(
178
+ request,
179
+ "invalid_request",
180
+ false,
181
+ "providers.list/active does not accept input fields.",
182
+ );
183
+ if (query === "Q29" && typeof this.surface.getActiveProviders !== "function")
184
+ return this.#error(request, "unavailable", false, "providers.list/active is unavailable for this session.");
171
185
  const source = sources[query];
172
186
  if (!source) return this.#error(request, "invalid_request");
173
187
  return await this.#pageSource(request, query, source);
@@ -257,7 +271,12 @@ export class QueryHandlers {
257
271
  source.resource === "transcript" ? { highWatermark: cursor.highWatermark } : {},
258
272
  );
259
273
  } else {
260
- snapshot = await (this.surface[source.method] as () => unknown)();
274
+ try {
275
+ snapshot = await (this.surface[source.method] as () => unknown)();
276
+ } catch (error) {
277
+ if (queryId === "Q29") throw new ActiveProviderResolutionError();
278
+ throw error;
279
+ }
261
280
  revision = await this.revisions.createRevision(source.resource, resourceId, snapshot);
262
281
  }
263
282
  if (snapshot === undefined) return this.#error(request, "resource_gone");
package/src/sdk/index.ts CHANGED
@@ -22,5 +22,6 @@ export type {
22
22
  Q10ThinkingMode,
23
23
  } from "./models";
24
24
  export * from "./prompt-status";
25
+ export type { ActiveProviderConnectionKind, ActiveProviderDescriptor } from "./providers";
25
26
  export * from "./session";
26
27
  export * from "./session-directory";
@@ -1583,6 +1583,24 @@
1583
1583
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
1584
1584
  ]
1585
1585
  },
1586
+ {
1587
+ "sourceId": "registry:Q29",
1588
+ "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts",
1589
+ "sourceKind": "registry",
1590
+ "decision": "include",
1591
+ "sdkId": "providers.list/active",
1592
+ "adapterMappings": {
1593
+ "telegram": "prohibited",
1594
+ "discord": "prohibited",
1595
+ "slack": "prohibited",
1596
+ "mcp": "generic_safe",
1597
+ "acp": "generic_safe",
1598
+ "daemonCli": "generic_safe"
1599
+ },
1600
+ "testIds": [
1601
+ "packages/coding-agent/test/sdk-operation-inventory.test.ts"
1602
+ ]
1603
+ },
1586
1604
  {
1587
1605
  "sourceId": "registry:R01",
1588
1606
  "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts",
@@ -164,6 +164,7 @@ const queries = [
164
164
  "skill.invoke_status",
165
165
  "Read the authoritative reconciliation status of a skill.invoke by command/turn IDs or clientRef.",
166
166
  ],
167
+ ["providers.list/active", "List active providers."],
167
168
  ] as const;
168
169
 
169
170
  const reverse = [
@@ -243,7 +244,7 @@ function queryContinuityClass(id: string): QueryContinuityClass {
243
244
  }
244
245
 
245
246
  function queryDisposition(id: string): Record<Adapter, AdapterDisposition> {
246
- if (["Q23", "Q24", "Q25", "Q26", "Q27", "Q28"].includes(id))
247
+ if (["Q23", "Q24", "Q25", "Q26", "Q27", "Q28", "Q29"].includes(id))
247
248
  return dispositions({ telegram: "prohibited", discord: "prohibited", slack: "prohibited" });
248
249
  return dispositions();
249
250
  }
@@ -306,7 +307,9 @@ export const OPERATIONS: readonly Operation[] = [
306
307
  errorCodes:
307
308
  id === "Q27"
308
309
  ? ["invalid_request", "resource_gone", "model_profile_registry_error"]
309
- : ["invalid_request", "resource_gone"],
310
+ : id === "Q29"
311
+ ? ["invalid_request", "resource_gone", "internal"]
312
+ : ["invalid_request", "resource_gone"],
310
313
  continuityClass: queryContinuityClass(id),
311
314
  adapterDispositions: queryDisposition(id),
312
315
  testIds: ["packages/coding-agent/test/sdk-operation-inventory.test.ts"],
@@ -0,0 +1,51 @@
1
+ export type ActiveProviderConnectionKind = "credential" | "credentialless";
2
+
3
+ export interface ActiveProviderDescriptor {
4
+ provider: string;
5
+ connectionKind: ActiveProviderConnectionKind;
6
+ }
7
+ function compareProviderIdsByUtf8(left: string, right: string): number {
8
+ const leftBytes = new TextEncoder().encode(left);
9
+ const rightBytes = new TextEncoder().encode(right);
10
+ const length = Math.min(leftBytes.length, rightBytes.length);
11
+ for (let index = 0; index < length; index += 1) {
12
+ const difference = leftBytes[index]! - rightBytes[index]!;
13
+ if (difference !== 0) return difference;
14
+ }
15
+ return leftBytes.length - rightBytes.length;
16
+ }
17
+
18
+ /**
19
+ * Project active-provider inputs to the exact public DTO shape.
20
+ *
21
+ * Connection credentials take precedence when the same provider is reported
22
+ * more than once. Provider IDs are sorted by their UTF-8 bytes without
23
+ * normalization.
24
+ */
25
+ export function projectActiveProviderDescriptors(
26
+ descriptors: readonly { provider: string; connectionKind: unknown }[],
27
+ ): ActiveProviderDescriptor[] {
28
+ const active = new Map<string, ActiveProviderConnectionKind>();
29
+ for (const descriptor of descriptors) {
30
+ if (descriptor.connectionKind !== "credential" && descriptor.connectionKind !== "credentialless") {
31
+ throw new Error("Invalid active provider connection kind.");
32
+ }
33
+ if (active.get(descriptor.provider) === "credential") continue;
34
+ active.set(descriptor.provider, descriptor.connectionKind);
35
+ }
36
+ return [...active.entries()]
37
+ .sort(([left], [right]) => compareProviderIdsByUtf8(left, right))
38
+ .map(([provider, connectionKind]) => ({ provider, connectionKind }));
39
+ }
40
+
41
+ export const ACTIVE_PROVIDER_RESOLUTION_ERROR_CODE = "internal" as const;
42
+ export const ACTIVE_PROVIDER_RESOLUTION_ERROR_MESSAGE = "Unable to resolve active providers.";
43
+
44
+ export class ActiveProviderResolutionError extends Error {
45
+ readonly code = ACTIVE_PROVIDER_RESOLUTION_ERROR_CODE;
46
+
47
+ constructor() {
48
+ super(ACTIVE_PROVIDER_RESOLUTION_ERROR_MESSAGE);
49
+ this.name = "ActiveProviderResolutionError";
50
+ }
51
+ }
@@ -39,6 +39,7 @@ export type ResolveManagedSessionScopeResult =
39
39
  | "sessions_root_unavailable"
40
40
  | "binding_conflict"
41
41
  | "binding_invalid"
42
+ | "migration_busy"
42
43
  | "atomic_unavailable"
43
44
  | "durability_not_provable"
44
45
  | "durability_failed"
@@ -108,10 +108,10 @@ import { MCPManager } from "../runtime-mcp";
108
108
  import { createNotificationsExtension } from "../sdk/bus";
109
109
  import {
110
110
  getNotificationConfig,
111
- isNotificationHostEligible,
111
+ isGenericNotificationHostEligible,
112
112
  type NotificationConfig,
113
113
  SPAWN_PROVENANCE_ENV,
114
- shouldRegisterNotificationsExtension,
114
+ shouldRegisterGenericNotificationsExtension,
115
115
  } from "../sdk/bus/config";
116
116
  import { NotificationSessionController } from "../sdk/bus/session-control";
117
117
  import { shouldHostSdk } from "../sdk/host";
@@ -136,6 +136,7 @@ import {
136
136
  buildSystemPrompt as buildSystemPromptInternal,
137
137
  buildSystemPromptToolMetadata,
138
138
  loadProjectContextFiles as loadContextFilesInternal,
139
+ loadProjectContextFilesResult as loadContextFilesResultInternal,
139
140
  } from "../system-prompt";
140
141
  import { AgentOutputManager } from "../task/output-manager";
141
142
  import { parseThinkingLevel, resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
@@ -184,6 +185,7 @@ import {
184
185
 
185
186
  type AsyncResultEntry = {
186
187
  jobId: string;
188
+ generation: string;
187
189
  result: string;
188
190
  job: AsyncJob | undefined;
189
191
  durationMs: number | undefined;
@@ -1165,10 +1167,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1165
1167
  // Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited
1166
1168
  // at their respective consumer sites. Their work can overlap with model resolution, secret loading,
1167
1169
  // session-context build, tool creation, MCP discovery, and extension discovery.
1168
- const contextFilesPromise = options.contextFiles
1169
- ? Promise.resolve(options.contextFiles)
1170
- : logger.time("discoverContextFiles", discoverContextFiles, cwd, agentDir);
1171
- contextFilesPromise.catch(() => {});
1170
+ const contextFilesResultPromise = options.contextFiles
1171
+ ? Promise.resolve({ contextFiles: options.contextFiles, warnings: [] })
1172
+ : logger.time("discoverContextFiles", loadContextFilesResultInternal, { cwd });
1173
+ contextFilesResultPromise.catch(() => {});
1172
1174
  const promptTemplatesPromise = options.promptTemplates
1173
1175
  ? Promise.resolve(options.promptTemplates)
1174
1176
  : logger.time("discoverPromptTemplates", discoverPromptTemplates, cwd, agentDir);
@@ -1464,10 +1466,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1464
1466
  }
1465
1467
  return result;
1466
1468
  };
1467
- const [contextFiles, resolvedWorkspaceTree] = await Promise.all([
1468
- contextFilesPromise,
1469
+ const [contextFilesResult, resolvedWorkspaceTree] = await Promise.all([
1470
+ contextFilesResultPromise,
1469
1471
  raceWithDeadline("buildWorkspaceTree", workspaceTreePromise),
1470
1472
  ]);
1473
+ const contextFiles = contextFilesResult.contextFiles;
1474
+ const discoveredContextFileWarnings = contextFilesResult.warnings;
1471
1475
 
1472
1476
  const backgroundJobsEnabled = isBackgroundJobSupportEnabled(settings);
1473
1477
  const asyncMaxJobs = Math.min(100, Math.max(1, settings.get("async.maxJobs") ?? 100));
@@ -1502,13 +1506,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1502
1506
  ? new AsyncJobManager({
1503
1507
  maxRunningJobs: asyncMaxJobs,
1504
1508
  onJobComplete: async (jobId, result, job) => {
1505
- if (!session || asyncJobManager!.isDeliverySuppressed(jobId)) return;
1509
+ if (!session) return;
1506
1510
  const formattedResult = await formatAsyncResultForFollowUp(result);
1507
- if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
1511
+ if (asyncJobManager!.isDeliverySuppressed(jobId, job?.generation)) return;
1508
1512
 
1509
1513
  const durationMs = job ? jobElapsedMs(job) : undefined;
1510
1514
  session.yieldQueue.enqueue<AsyncResultEntry>("async-result", {
1511
1515
  jobId,
1516
+ generation: job?.generation ?? "",
1512
1517
  result: formattedResult,
1513
1518
  job,
1514
1519
  durationMs,
@@ -1907,7 +1912,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1907
1912
  const spawnProvenance = process.env[SPAWN_PROVENANCE_ENV];
1908
1913
  const spawnedByGjc = typeof spawnProvenance === "string" && spawnProvenance.trim().length > 0;
1909
1914
  delete process.env[SPAWN_PROVENANCE_ENV];
1910
- const notificationHostEligible = isNotificationHostEligible({
1915
+ const notificationHostEligible = isGenericNotificationHostEligible({
1911
1916
  env: process.env,
1912
1917
  hostModeSupported: options.notificationHostModeSupported ?? true,
1913
1918
  taskDepth,
@@ -1919,10 +1924,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1919
1924
  const notificationSessionController = new NotificationSessionController({
1920
1925
  eligible: notificationHostEligible,
1921
1926
  getConfig: () => getNotificationConfig(settings),
1927
+ spawnedByGjc,
1922
1928
  });
1923
1929
  if (
1924
1930
  lifecycleStartupCapability ||
1925
- shouldRegisterNotificationsExtension({
1931
+ shouldRegisterGenericNotificationsExtension({
1926
1932
  env: process.env,
1927
1933
  cfg: notificationCfg,
1928
1934
  taskDepth,
@@ -2230,6 +2236,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2230
2236
  settings.get("tools.intentTracing"),
2231
2237
  options.hasUI ?? false,
2232
2238
  );
2239
+ const emittedContextFileWarnings = new Set(discoveredContextFileWarnings);
2240
+ const contextFileWarnings = [...discoveredContextFileWarnings];
2241
+ for (const warning of discoveredContextFileWarnings) {
2242
+ logger.warn("Context file discovery warning", { warning });
2243
+ }
2233
2244
  const intentField = intentTracingEnabled ? INTENT_FIELD : undefined;
2234
2245
  const rebuildSystemPrompt = async (
2235
2246
  toolNames: string[],
@@ -2308,14 +2319,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2308
2319
  subagent: options.parentTaskPrefix !== undefined,
2309
2320
  });
2310
2321
 
2322
+ for (const warning of defaultPrompt.warnings) {
2323
+ if (emittedContextFileWarnings.has(warning)) continue;
2324
+ emittedContextFileWarnings.add(warning);
2325
+ contextFileWarnings.push(warning);
2326
+ if (hasSession) session.configWarnings.push(warning);
2327
+ logger.warn("Context file discovery warning", { warning });
2328
+ }
2311
2329
  if (options.systemPrompt === undefined) {
2312
2330
  return defaultPrompt;
2313
2331
  }
2314
2332
  if (Array.isArray(options.systemPrompt)) {
2315
- return { systemPrompt: options.systemPrompt };
2333
+ return { systemPrompt: options.systemPrompt, warnings: defaultPrompt.warnings };
2316
2334
  }
2317
2335
  return {
2318
2336
  systemPrompt: options.systemPrompt(defaultPrompt.systemPrompt),
2337
+ warnings: defaultPrompt.warnings,
2319
2338
  };
2320
2339
  };
2321
2340
 
@@ -2794,10 +2813,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2794
2813
  forkContextSeed: options.forkContextSeed,
2795
2814
  providerSessionState: options.providerSessionState,
2796
2815
  });
2816
+ session.configWarnings.push(...contextFileWarnings);
2797
2817
  hasSession = true;
2798
2818
  if (asyncJobManager) {
2799
2819
  session.yieldQueue.register<AsyncResultEntry>("async-result", {
2800
- isStale: entry => asyncJobManager.isDeliverySuppressed(entry.jobId),
2820
+ isStale: entry => asyncJobManager.isDeliverySuppressed(entry.jobId, entry.generation),
2801
2821
  build: buildAsyncResultBatchMessage,
2802
2822
  });
2803
2823
  }
@@ -109,6 +109,7 @@ import {
109
109
  streamSimple,
110
110
  } from "@gajae-code/ai";
111
111
  import {
112
+ type AuthDisposition,
112
113
  beginAttempt,
113
114
  classifyFallbackTrigger,
114
115
  type FallbackAttemptToken,
@@ -1869,13 +1870,6 @@ export class AgentSession {
1869
1870
  this.#retryReplayEpoch++;
1870
1871
  this.#retryReplayUnsafeEpoch = undefined;
1871
1872
  this.#firstEventTimeoutRetryStartedAt = Date.now();
1872
- if (
1873
- this.#extensionRunner?.hasHandlers("context") ||
1874
- this.#extensionRunner?.hasHandlers("before_provider_request") ||
1875
- this.#extensionRunner?.hasHandlers("after_provider_response")
1876
- ) {
1877
- this.#markRetryReplayUnsafe();
1878
- }
1879
1873
  }
1880
1874
 
1881
1875
  #markRetryReplayUnsafe(): void {
@@ -1885,6 +1879,14 @@ export class AgentSession {
1885
1879
  get #hasCleanRetryReplaySafety(): boolean {
1886
1880
  return this.#retryReplayEpoch > 0 && this.#retryReplayUnsafeEpoch !== this.#retryReplayEpoch;
1887
1881
  }
1882
+ #bindAttemptScope(scope: AttemptScope | undefined): void {
1883
+ if (!scope) return;
1884
+ this.#attemptRecordStore.register(scope);
1885
+ this.#attemptRecordStore.establishClean(scope);
1886
+ }
1887
+ #isRetryScopeClean(scope: AttemptScope | undefined): boolean {
1888
+ return scope !== undefined && this.#attemptRecordStore.isClean(scope);
1889
+ }
1888
1890
  #prePromptContextCheckPromise: Promise<void> | undefined = undefined;
1889
1891
  /** Display-only context snapshot; pre-prompt compaction estimates deliberately remain uncached. */
1890
1892
  #contextUsageCache: { key: string; value: ContextUsage } | undefined;
@@ -1898,6 +1900,8 @@ export class AgentSession {
1898
1900
  // Handoff state
1899
1901
  #handoffAbortController: AbortController | undefined = undefined;
1900
1902
  #skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined;
1903
+ /** Scope cleanliness captured after message_end extension delivery and before terminal agent_end retirement. */
1904
+ #assistantAttemptScopes = new WeakMap<AssistantMessage, { scope?: AttemptScope; wasClean: boolean }>();
1901
1905
 
1902
1906
  // Retry state
1903
1907
  #retryAbortController: AbortController | undefined = undefined;
@@ -2606,6 +2610,7 @@ export class AgentSession {
2606
2610
  if (this.#extensionRunner && typeof this.#extensionRunner.setAttemptRecordStore === "function") {
2607
2611
  this.#extensionRunner.setAttemptRecordStore(this.#attemptRecordStore);
2608
2612
  }
2613
+ this.agent.setMainAttemptScopeObserver(scope => this.#bindAttemptScope(scope));
2609
2614
  this.#skills = config.skills ?? [];
2610
2615
  this.#skillWarnings = config.skillWarnings ?? [];
2611
2616
  this.#customCommands = config.customCommands ?? [];
@@ -3739,11 +3744,7 @@ export class AgentSession {
3739
3744
  /** Internal handler for agent events - shared by subscribe and reconnect */
3740
3745
  #handleAgentEvent = async (event: AgentEvent, activePromptHandle?: string): Promise<void> => {
3741
3746
  const attemptScope = (event as AgentEvent & { scope?: AttemptScope }).scope;
3742
- if ((event.type === "agent_start" || event.type === "turn_start") && attemptScope) {
3743
- this.#attemptRecordStore.register(attemptScope);
3744
- this.#attemptRecordStore.establishClean(attemptScope);
3745
- }
3746
- if (this.#extensionRunner?.hasHandlers(event.type)) this.#markRetryReplayUnsafe();
3747
+
3747
3748
  if (
3748
3749
  event.type === "tool_execution_start" ||
3749
3750
  event.type === "tool_execution_update" ||
@@ -3947,6 +3948,12 @@ export class AgentSession {
3947
3948
  }
3948
3949
 
3949
3950
  await this.#emitSessionEvent(displayEvent);
3951
+ if (event.type === "message_end" && event.message.role === "assistant") {
3952
+ this.#assistantAttemptScopes.set(event.message, {
3953
+ scope: attemptScope,
3954
+ wasClean: this.#isRetryScopeClean(attemptScope),
3955
+ });
3956
+ }
3950
3957
  if (
3951
3958
  displayEvent !== event &&
3952
3959
  displayEvent.type === "message_end" &&
@@ -4321,7 +4328,14 @@ export class AgentSession {
4321
4328
  if (this.#isRetryableError(msg)) {
4322
4329
  const transportFailure = (msg as AssistantMessage & { transportFailure?: TransportFailureFacts })
4323
4330
  .transportFailure;
4324
- const didRetry = await this.#handleRetryableError(msg, false, transportFailure);
4331
+ const messageScope = this.#assistantAttemptScopes.get(msg);
4332
+ const didRetry = await this.#handleRetryableError(
4333
+ msg,
4334
+ false,
4335
+ transportFailure,
4336
+ messageScope?.scope ?? event.scope,
4337
+ messageScope?.wasClean ?? false,
4338
+ );
4325
4339
  if (didRetry) return; // Retry was initiated, don't proceed to compaction
4326
4340
  }
4327
4341
  if (this.#retryAttempt > 0) {
@@ -5838,6 +5852,7 @@ export class AgentSession {
5838
5852
  this.#abortActiveMidRunBarriers();
5839
5853
  this.abortCompaction();
5840
5854
  this.agent.abort();
5855
+ this.agent.setMainAttemptScopeObserver(undefined);
5841
5856
  // Disconnect the Agent event bridge NOW — before the maintenance join and the
5842
5857
  // bounded idle / forceAbort below — so no agent_end emitted during teardown
5843
5858
  // (including the one forceAbort emits) can re-enter #handleAgentEvent and start
@@ -14214,6 +14229,12 @@ export class AgentSession {
14214
14229
  );
14215
14230
  }
14216
14231
  const trigger = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
14232
+ // A plain `forbidden` is terminal. Without this, the blanket
14233
+ // `transportFailure` admission below re-admits a committed forbidden
14234
+ // failure into retry policy: credential mutation is vetoed downstream, but
14235
+ // the chain would still retry and advance models on a request the caller is
14236
+ // simply not authorized to make.
14237
+ if (trigger.class === "auth" && trigger.authDisposition === "forbidden") return false;
14217
14238
  if (transportFailure) return true;
14218
14239
  if (
14219
14240
  trigger.class === "rate_limit" ||
@@ -14573,6 +14594,8 @@ export class AgentSession {
14573
14594
  outcome.failure.message,
14574
14595
  true,
14575
14596
  outcome.failure.transportFailure,
14597
+ outcome.scope,
14598
+ this.#isRetryScopeClean(outcome.scope),
14576
14599
  ) as Promise<ManagedAttemptDecision>;
14577
14600
  }
14578
14601
 
@@ -14600,7 +14623,10 @@ export class AgentSession {
14600
14623
  message: AssistantMessage,
14601
14624
  allowLegacyUsageLimit: boolean,
14602
14625
  transportFailure?: TransportFailureFacts,
14603
- ): { class: FallbackTriggerClass; retryAfterMs?: number } | undefined {
14626
+ // `authDisposition` is carried through from `classifyFallbackTrigger`, whose
14627
+ // result is returned verbatim below. Narrowing it away here would silently
14628
+ // disable the terminal-403 guard in `#markFailedCredential`.
14629
+ ): { class: FallbackTriggerClass; retryAfterMs?: number; authDisposition?: AuthDisposition } | undefined {
14604
14630
  if (classifyContextOverflow(message, transportFailure, this.model?.contextWindow ?? 0)) return undefined;
14605
14631
  const transport = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
14606
14632
  if (transport.class !== "other") return transport;
@@ -14707,25 +14733,60 @@ export class AgentSession {
14707
14733
  return `Model fallback chain exhausted; models tried: ${tried}; models skipped: ${skipped}`;
14708
14734
  }
14709
14735
 
14710
- async #markFailedCredential(trigger: { class: FallbackTriggerClass; retryAfterMs?: number }): Promise<boolean> {
14736
+ /**
14737
+ * Marks the credential that just failed and reports whether the session
14738
+ * actually moved to a DIFFERENT stored credential.
14739
+ *
14740
+ * Three invariants this enforces, in order:
14741
+ *
14742
+ * 1. **Pin guard, first and for every trigger class.** A pinned credential
14743
+ * (`--api-key` or `--credential`) must never be mutated or rotated away
14744
+ * from. Both overrides are consulted: they live in separate maps, so
14745
+ * checking only `hasRuntimeApiKey` would silently rotate a `--credential`
14746
+ * pin. Previously the guard existed only on the quota path, so the `auth`
14747
+ * path invalidated pinned credentials outright.
14748
+ * 2. **A terminal `forbidden` never mutates credential state.** Rotation
14749
+ * would hide an authorization defect and cycle through healthy rows.
14750
+ * 3. **Distinct-row proof in BOTH branches.** `invalidateCredentialMatching`
14751
+ * reports "I matched and blocked a row", which is not the same as "the
14752
+ * session now uses a different credential" — with a single-row pool it is
14753
+ * true while nothing rotated. Both branches therefore re-resolve and
14754
+ * require the active key to have actually changed.
14755
+ */
14756
+ async #markFailedCredential(trigger: {
14757
+ class: FallbackTriggerClass;
14758
+ retryAfterMs?: number;
14759
+ authDisposition?: AuthDisposition;
14760
+ }): Promise<boolean> {
14711
14761
  if (!this.model || (trigger.class !== "auth" && trigger.class !== "quota" && trigger.class !== "rate_limit")) {
14712
14762
  return false;
14713
14763
  }
14764
+ // (2) Terminal forbidden: no credential state may change.
14765
+ if (trigger.class === "auth" && trigger.authDisposition === "forbidden") return false;
14766
+
14714
14767
  const authStorage = this.#modelRegistry.authStorage;
14768
+ const provider = this.model.provider;
14769
+ // (1) Pin guard, before any mutation and for every branch.
14770
+ if (authStorage.hasRuntimeApiKey(provider) || authStorage.hasRuntimeCredentialSelector(provider)) return false;
14771
+
14715
14772
  const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
14773
+ const activeApiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14774
+
14775
+ let mutated: boolean;
14716
14776
  if (trigger.class === "auth") {
14717
- const apiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14718
- if (!isAuthenticated(apiKey)) return false;
14719
- return authStorage.invalidateCredentialMatching(this.model.provider, apiKey, {
14777
+ if (!isAuthenticated(activeApiKey)) return false;
14778
+ mutated = await authStorage.invalidateCredentialMatching(provider, activeApiKey, {
14720
14779
  sessionId: providerAffinitySessionId,
14721
14780
  });
14781
+ } else {
14782
+ mutated = await authStorage.markUsageLimitReached(provider, providerAffinitySessionId, {
14783
+ retryAfterMs: trigger.retryAfterMs,
14784
+ });
14722
14785
  }
14723
- if (authStorage.hasRuntimeApiKey(this.model.provider)) return false;
14724
- const activeApiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14725
- const rotated = await authStorage.markUsageLimitReached(this.model.provider, providerAffinitySessionId, {
14726
- retryAfterMs: trigger.retryAfterMs,
14727
- });
14728
- return rotated && (await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId)) !== activeApiKey;
14786
+ if (!mutated) return false;
14787
+
14788
+ // (3) Distinct-row proof.
14789
+ return (await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId)) !== activeApiKey;
14729
14790
  }
14730
14791
 
14731
14792
  /** Handle retryable errors with exponential backoff. */
@@ -14733,6 +14794,8 @@ export class AgentSession {
14733
14794
  message: AssistantMessage,
14734
14795
  managedOutcome = false,
14735
14796
  transportFailure?: TransportFailureFacts,
14797
+ scope?: AttemptScope,
14798
+ scopeWasClean = this.#isRetryScopeClean(scope),
14736
14799
  ): Promise<boolean | ManagedAttemptDecision> {
14737
14800
  const controller = this.#defaultFallbackChain();
14738
14801
  const managedFallback = controller.chain.entries.length > 1;
@@ -14747,12 +14810,16 @@ export class AgentSession {
14747
14810
  if (!managedFallback && !retrySettings.enabled) return false;
14748
14811
  const classification = this.#classifyErrorForRetry(message);
14749
14812
  const firstEventTimeout = classification === "first_event_timeout";
14750
- if (!managedFallback && firstEventTimeout) {
14751
- this.#firstEventTimeoutRetryStartedAt ??= Date.now();
14813
+ if (!managedFallback && firstEventTimeout && this.#retryAttempt === 0) {
14814
+ this.#firstEventTimeoutRetryStartedAt = Date.now();
14752
14815
  }
14753
- // First-event timeouts are replay-safe only before any model progress,
14754
- // abort, or extension/provider lifecycle participation.
14755
- if (firstEventTimeout && (!this.#hasCleanRetryReplaySafety || assistantMessageHasVisibleOrToolContent(message))) {
14816
+ const requiresScopedFirstEventTimeout = managedFallback || !legacyRetryConfigured;
14817
+ if (
14818
+ firstEventTimeout &&
14819
+ (assistantMessageHasVisibleOrToolContent(message) ||
14820
+ (this.#retryAttempt > 0 && !this.#hasCleanRetryReplaySafety) ||
14821
+ (requiresScopedFirstEventTimeout && (!this.#isTypedFirstEventTimeout(message) || !scope || !scopeWasClean)))
14822
+ ) {
14756
14823
  return managedOutcome
14757
14824
  ? {
14758
14825
  type: "terminal",
@@ -14780,14 +14847,12 @@ export class AgentSession {
14780
14847
  // before the failure. This bypasses #hasCleanRetryReplaySafety because the
14781
14848
  // content-free check is the replay-safety guarantee for credential rotation.
14782
14849
  const canReplayRotatedCredential = credentialRotated;
14783
- // Bare defaults admit only clean, side-effect-free canonical stream watchdog failures,
14784
- // content-free quota/rate-limit credential rotations, and the explicit Codex
14785
- // capacity-overload event.
14850
+ // Bare defaults retain their narrow watchdog and Codex admissions. A
14851
+ // first-event timeout adds the typed, content-free, current-clean-scope
14852
+ // requirement above; other transient watchdogs preserve legacy behavior.
14786
14853
  if (!managedFallback && !legacyRetryConfigured && !canReplayRotatedCredential) {
14787
14854
  const bareDefaultCodexOverload = isBareDefaultCodexOverload(message);
14788
14855
  const bareDefaultWrappedFirstEventTimeout = isBareDefaultWrappedFirstEventTimeout(message);
14789
- // Content-free Codex overload is replay-safe by the same reasoning as
14790
- // credential rotation: no partial output means no observable state.
14791
14856
  const canReplayCodexOverload = bareDefaultCodexOverload;
14792
14857
  if (
14793
14858
  (!canReplayCodexOverload &&
@@ -14796,7 +14861,7 @@ export class AgentSession {
14796
14861
  (hasBareDefaultRetryDisqualifyingFacts(message) ||
14797
14862
  (classification !== "transient" && classification !== "first_event_timeout") ||
14798
14863
  !BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))) ||
14799
- (!canReplayCodexOverload && !this.#hasCleanRetryReplaySafety)
14864
+ (!canReplayCodexOverload && !firstEventTimeout && !this.#hasCleanRetryReplaySafety)
14800
14865
  ) {
14801
14866
  return false;
14802
14867
  }
@@ -14816,8 +14881,15 @@ export class AgentSession {
14816
14881
  credentialRotated = await this.#markFailedCredential(trigger);
14817
14882
  }
14818
14883
  if (credentialRotated) {
14819
- if (managedFallback) controller.restorePreviousEntryForRetry();
14820
- outcome = "retry";
14884
+ // A rotation only becomes a same-model retry if the controller can
14885
+ // actually be rewound. `restorePreviousEntryForRetry()` refuses once an
14886
+ // entry's restore budget is consumed or attempts are exhausted; ignoring
14887
+ // that would force `outcome = "retry"` while `activeIndex` stays on the
14888
+ // next entry and `this.model` stays on the previous one — splitting
14889
+ // attempt attribution, exhaustion, and sticky selection across two models.
14890
+ if (!managedFallback || controller.restorePreviousEntryForRetry()) {
14891
+ outcome = "retry";
14892
+ }
14821
14893
  }
14822
14894
  if (outcome === "exhausted") {
14823
14895
  if (managedFallback) {