@gajae-code/coding-agent 0.12.8 → 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 (40) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/sdk.d.ts +23 -0
  4. package/dist/types/discovery/agents-md.d.ts +11 -1
  5. package/dist/types/modes/types.d.ts +6 -0
  6. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  7. package/dist/types/sdk/bus/notification-service.d.ts +23 -0
  8. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
  9. package/dist/types/session/internal/managed-session-storage.d.ts +8 -0
  10. package/dist/types/session/internal/native-publish-outcome.d.ts +2 -2
  11. package/dist/types/system-prompt.d.ts +12 -1
  12. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  13. package/package.json +8 -8
  14. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  15. package/src/cli/notify-cli.ts +12 -6
  16. package/src/commands/sdk.ts +54 -0
  17. package/src/config/model-profiles.ts +8 -0
  18. package/src/config/model-registry.ts +4 -1
  19. package/src/discovery/agents-md.ts +76 -19
  20. package/src/gjc-runtime/psmux-detect.ts +12 -16
  21. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  22. package/src/internal-urls/docs-index.generated.ts +4 -3
  23. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  24. package/src/modes/controllers/selector-controller.ts +61 -43
  25. package/src/modes/print-mode.ts +3 -0
  26. package/src/modes/types.ts +6 -0
  27. package/src/prompts/system/system-prompt.md +1 -0
  28. package/src/sdk/bus/daemon-paths.ts +2 -0
  29. package/src/sdk/bus/notification-service.ts +140 -7
  30. package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
  31. package/src/sdk/session.ts +24 -7
  32. package/src/session/agent-session.ts +65 -13
  33. package/src/session/internal/managed-session-storage.ts +36 -2
  34. package/src/session/internal/native-publish-outcome.ts +12 -2
  35. package/src/slash-commands/builtin-registry.ts +35 -11
  36. package/src/system-prompt.ts +28 -15
  37. package/src/tools/bash.ts +3 -1
  38. package/src/tools/composer-bash-policy.ts +21 -5
  39. package/src/utils/command-args.ts +19 -30
  40. package/src/utils/lang-from-path.ts +2 -4
@@ -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,
@@ -14228,6 +14229,12 @@ export class AgentSession {
14228
14229
  );
14229
14230
  }
14230
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;
14231
14238
  if (transportFailure) return true;
14232
14239
  if (
14233
14240
  trigger.class === "rate_limit" ||
@@ -14616,7 +14623,10 @@ export class AgentSession {
14616
14623
  message: AssistantMessage,
14617
14624
  allowLegacyUsageLimit: boolean,
14618
14625
  transportFailure?: TransportFailureFacts,
14619
- ): { 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 {
14620
14630
  if (classifyContextOverflow(message, transportFailure, this.model?.contextWindow ?? 0)) return undefined;
14621
14631
  const transport = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
14622
14632
  if (transport.class !== "other") return transport;
@@ -14723,25 +14733,60 @@ export class AgentSession {
14723
14733
  return `Model fallback chain exhausted; models tried: ${tried}; models skipped: ${skipped}`;
14724
14734
  }
14725
14735
 
14726
- 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> {
14727
14761
  if (!this.model || (trigger.class !== "auth" && trigger.class !== "quota" && trigger.class !== "rate_limit")) {
14728
14762
  return false;
14729
14763
  }
14764
+ // (2) Terminal forbidden: no credential state may change.
14765
+ if (trigger.class === "auth" && trigger.authDisposition === "forbidden") return false;
14766
+
14730
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
+
14731
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;
14732
14776
  if (trigger.class === "auth") {
14733
- const apiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14734
- if (!isAuthenticated(apiKey)) return false;
14735
- return authStorage.invalidateCredentialMatching(this.model.provider, apiKey, {
14777
+ if (!isAuthenticated(activeApiKey)) return false;
14778
+ mutated = await authStorage.invalidateCredentialMatching(provider, activeApiKey, {
14736
14779
  sessionId: providerAffinitySessionId,
14737
14780
  });
14781
+ } else {
14782
+ mutated = await authStorage.markUsageLimitReached(provider, providerAffinitySessionId, {
14783
+ retryAfterMs: trigger.retryAfterMs,
14784
+ });
14738
14785
  }
14739
- if (authStorage.hasRuntimeApiKey(this.model.provider)) return false;
14740
- const activeApiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14741
- const rotated = await authStorage.markUsageLimitReached(this.model.provider, providerAffinitySessionId, {
14742
- retryAfterMs: trigger.retryAfterMs,
14743
- });
14744
- 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;
14745
14790
  }
14746
14791
 
14747
14792
  /** Handle retryable errors with exponential backoff. */
@@ -14836,8 +14881,15 @@ export class AgentSession {
14836
14881
  credentialRotated = await this.#markFailedCredential(trigger);
14837
14882
  }
14838
14883
  if (credentialRotated) {
14839
- if (managedFallback) controller.restorePreviousEntryForRetry();
14840
- 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
+ }
14841
14893
  }
14842
14894
  if (outcome === "exhausted") {
14843
14895
  if (managedFallback) {
@@ -9,6 +9,7 @@ import {
9
9
  exactReplacePath,
10
10
  exactRestore,
11
11
  exactUnlink,
12
+ linkNoReplacePath,
12
13
  type NativeDirectoryTreeSnapshot,
13
14
  type NativeOwnerOnlySecurityResult,
14
15
  openRecoveryFsRoot,
@@ -114,6 +115,21 @@ export function mayCleanManagedTreeStaging(error: unknown): boolean {
114
115
  return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
115
116
  }
116
117
 
118
+ /**
119
+ * A filesystem that implements no `renameat2` rename flag at all rejects the no-replace publish
120
+ * before mutating anything: NFS answers `EINVAL` (`invalid_request`) and kernels older than 3.15
121
+ * answer `ENOSYS` (`atomic_unavailable`). Only those two pre-mutation outcomes authorize the
122
+ * `linkat` stand-in; every other failure describes a publish that was actually attempted, and
123
+ * retrying it under a different primitive could publish twice.
124
+ */
125
+ export function renameFlagsUnsupported(outcome: NativePublishOutcome): boolean {
126
+ return (
127
+ !outcome.ok &&
128
+ mayCleanCurrentStaging(outcome) &&
129
+ (outcome.reason === "atomic_unavailable" || outcome.reason === "invalid_request")
130
+ );
131
+ }
132
+
117
133
  export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
118
134
 
119
135
  export type ManagedStorageFailure =
@@ -1580,6 +1596,7 @@ export async function publishManagedFileNoReplace(
1580
1596
  let failure: unknown;
1581
1597
  let outcome: NativePublishOutcome | undefined;
1582
1598
  let renameAttempted = false;
1599
+ let linkPublished = false;
1583
1600
 
1584
1601
  try {
1585
1602
  assertOwned?.();
@@ -1599,6 +1616,16 @@ export async function publishManagedFileNoReplace(
1599
1616
 
1600
1617
  renameAttempted = true;
1601
1618
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1619
+ if (renameFlagsUnsupported(outcome)) {
1620
+ // linkat publishes the destination without consuming the staging name, so the
1621
+ // secured staging descriptor stays authoritative across publication exactly as
1622
+ // it does across a rename. The staging link is removed in the finally block
1623
+ // below, after that descriptor is closed: unlinking a still-open name on NFS
1624
+ // silly-renames it instead of removing it, which would leave a second link on
1625
+ // the published inode.
1626
+ outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
1627
+ linkPublished = outcome.ok;
1628
+ }
1602
1629
 
1603
1630
  if (!outcome.ok) throw publishFailure(outcome);
1604
1631
 
@@ -1621,7 +1648,7 @@ export async function publishManagedFileNoReplace(
1621
1648
  } finally {
1622
1649
  if (fd !== undefined) fs.closeSync(fd);
1623
1650
 
1624
- if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1651
+ if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1625
1652
  await fsp
1626
1653
  .lstat(staging, { bigint: true })
1627
1654
  .then(stat => {
@@ -1649,6 +1676,7 @@ export function publishManagedFileNoReplaceSync(
1649
1676
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1650
1677
  let failure: unknown;
1651
1678
  let outcome: NativePublishOutcome | undefined;
1679
+ let linkPublished = false;
1652
1680
 
1653
1681
  try {
1654
1682
  fd = fs.openSync(
@@ -1665,6 +1693,12 @@ export function publishManagedFileNoReplaceSync(
1665
1693
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1666
1694
 
1667
1695
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1696
+ if (renameFlagsUnsupported(outcome)) {
1697
+ // See publishManagedFileNoReplace: the staging link outlives this publication
1698
+ // and is removed only after the secured descriptor is closed.
1699
+ outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
1700
+ linkPublished = outcome.ok;
1701
+ }
1668
1702
  if (!outcome.ok) throw publishFailure(outcome);
1669
1703
 
1670
1704
  const named = fs.lstatSync(destination, { bigint: true });
@@ -1680,7 +1714,7 @@ export function publishManagedFileNoReplaceSync(
1680
1714
  } finally {
1681
1715
  if (fd !== undefined) fs.closeSync(fd);
1682
1716
  }
1683
- if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
1717
+ if (stagingIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome)))) {
1684
1718
  try {
1685
1719
  const named = fs.lstatSync(staging, { bigint: true });
1686
1720
  if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
@@ -16,6 +16,8 @@ export type NativePublishOperation = "direct_rename" | "retained_file" | "retain
16
16
 
17
17
  export type NativePublishPrimitive =
18
18
  | "renameat2_noreplace"
19
+ | "linkat_noreplace"
20
+ | "mkdirat_renameat_noreplace"
19
21
  | "renameatx_np_excl"
20
22
  | "windows_rename_noreplace"
21
23
  | "unsupported"
@@ -24,6 +26,7 @@ export type NativePublishPhase =
24
26
  | "preflight"
25
27
  | "file_sync"
26
28
  | "rename"
29
+ | "source_unlink"
27
30
  | "source_parent_sync"
28
31
  | "destination_parent_sync"
29
32
  | "terminal_identity"
@@ -83,6 +86,8 @@ const reasons = new Set<NativePublishReason>([
83
86
  ]);
84
87
  const primitives = new Set<NativePublishPrimitive>([
85
88
  "renameat2_noreplace",
89
+ "linkat_noreplace",
90
+ "mkdirat_renameat_noreplace",
86
91
  "renameatx_np_excl",
87
92
  "windows_rename_noreplace",
88
93
  "unsupported",
@@ -92,6 +97,7 @@ const phases = new Set<NativePublishPhase>([
92
97
  "preflight",
93
98
  "file_sync",
94
99
  "rename",
100
+ "source_unlink",
95
101
  "source_parent_sync",
96
102
  "destination_parent_sync",
97
103
  "terminal_identity",
@@ -204,7 +210,7 @@ function legalOutcome(outcome: NativePublishOutcome): boolean {
204
210
  ((outcome.reason === "durability_not_provable" &&
205
211
  ["file_sync", "source_parent_sync", "destination_parent_sync"].includes(outcome.phase)) ||
206
212
  (outcome.reason === "identity_violation" && outcome.phase === "terminal_identity") ||
207
- (outcome.reason === "io_failure" && outcome.phase === "terminal_identity"))
213
+ (outcome.reason === "io_failure" && ["source_unlink", "terminal_identity"].includes(outcome.phase)))
208
214
  );
209
215
  }
210
216
 
@@ -247,8 +253,12 @@ export function classifyNativePublishOutcome(
247
253
  const outcome = value as unknown as NativePublishOutcome;
248
254
  if (!legalOutcome(outcome)) return malformed;
249
255
  if (operation === "direct_rename") return outcome;
256
+ const retainedPrimitiveValid =
257
+ operation === "retained_file"
258
+ ? outcome.primitive === "renameat2_noreplace" || outcome.primitive === "linkat_noreplace"
259
+ : outcome.primitive === "renameat2_noreplace" || outcome.primitive === "mkdirat_renameat_noreplace";
250
260
  if (
251
- outcome.primitive !== "renameat2_noreplace" ||
261
+ !retainedPrimitiveValid ||
252
262
  (outcome.ok && (!outcome.identity || outcome.durabilityState !== "proven" || outcome.phase !== "complete"))
253
263
  )
254
264
  return malformed;
@@ -417,6 +417,9 @@ function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: st
417
417
  .join("\n\n");
418
418
  }
419
419
 
420
+ /** Opt into the paste-a-code OAuth login for browsers that cannot reach this machine. */
421
+ const MANUAL_LOGIN_FLAG = "--manual";
422
+
420
423
  const EFFORT_COMMAND_INPUT_HINT = "[inherit|off|minimal|low|medium|high|xhigh|max]";
421
424
  const EFFORT_COMMAND_ACCEPTED_VALUES = ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
422
425
 
@@ -1364,20 +1367,45 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1364
1367
  {
1365
1368
  name: "login",
1366
1369
  description: "Login with OAuth provider",
1367
- inlineHint: "[provider|redirect URL]",
1370
+ inlineHint: "[provider|redirect URL] [--manual]",
1368
1371
  allowArgs: true,
1369
1372
  handleTui: (command, runtime) => {
1370
1373
  const manualInput = runtime.ctx.oauthManualInput;
1371
1374
  const args = command.args.trim();
1375
+ const pendingLoginMessage = (): string => {
1376
+ const pendingProvider = manualInput.pendingProviderId;
1377
+ return pendingProvider
1378
+ ? `OAuth login already in progress for ${pendingProvider}. Paste the redirect URL with /login <url>.`
1379
+ : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
1380
+ };
1372
1381
  if (args.length > 0) {
1382
+ const tokens = args.split(/\s+/);
1383
+ // `--manual` is resolved before the paste fallback below, otherwise
1384
+ // `/login anthropic --manual` would be submitted as an authorization code.
1385
+ if (tokens.includes(MANUAL_LOGIN_FLAG)) {
1386
+ const rest = tokens.filter(token => token !== MANUAL_LOGIN_FLAG);
1387
+ const requestedProvider = rest.length === 1 ? rest[0] : undefined;
1388
+ const manualProvider = requestedProvider
1389
+ ? getOAuthProviders().find(provider => provider.id === requestedProvider)
1390
+ : undefined;
1391
+ if (!manualProvider) {
1392
+ runtime.ctx.showWarning(`Usage: /login <provider> ${MANUAL_LOGIN_FLAG}`);
1393
+ runtime.ctx.editor.setText("");
1394
+ return;
1395
+ }
1396
+ if (manualInput.hasPending()) {
1397
+ runtime.ctx.showWarning(pendingLoginMessage());
1398
+ runtime.ctx.editor.setText("");
1399
+ return;
1400
+ }
1401
+ void runtime.ctx.showOAuthSelector("login", manualProvider.id, { manualCode: true });
1402
+ runtime.ctx.editor.setText("");
1403
+ return;
1404
+ }
1373
1405
  const matchedProvider = getOAuthProviders().find(provider => provider.id === args);
1374
1406
  if (matchedProvider) {
1375
1407
  if (manualInput.hasPending()) {
1376
- const pendingProvider = manualInput.pendingProviderId;
1377
- const message = pendingProvider
1378
- ? `OAuth login already in progress for ${pendingProvider}. Paste the redirect URL with /login <url>.`
1379
- : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
1380
- runtime.ctx.showWarning(message);
1408
+ runtime.ctx.showWarning(pendingLoginMessage());
1381
1409
  runtime.ctx.editor.setText("");
1382
1410
  return;
1383
1411
  }
@@ -1396,11 +1424,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1396
1424
  }
1397
1425
 
1398
1426
  if (manualInput.hasPending()) {
1399
- const provider = manualInput.pendingProviderId;
1400
- const message = provider
1401
- ? `OAuth login already in progress for ${provider}. Paste the redirect URL with /login <url>.`
1402
- : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
1403
- runtime.ctx.showWarning(message);
1427
+ runtime.ctx.showWarning(pendingLoginMessage());
1404
1428
  runtime.ctx.editor.setText("");
1405
1429
  return;
1406
1430
  }
@@ -245,10 +245,14 @@ function dedupeExactContextFiles(
245
245
  // Keep the closest matching context entry when content is byte-for-byte identical.
246
246
  lastIndexByContent.set(file.content, index);
247
247
  }
248
-
249
248
  return contextFiles.filter((file, index) => lastIndexByContent.get(file.content) === index);
250
249
  }
251
250
 
251
+ export interface ProjectContextFilesResult {
252
+ contextFiles: Array<{ path: string; content: string; depth?: number }>;
253
+ warnings: string[];
254
+ }
255
+
252
256
  /**
253
257
  * Load all context files using the capability API.
254
258
  * Returns {path, content, depth} entries for all discovered context files.
@@ -257,11 +261,10 @@ function dedupeExactContextFiles(
257
261
  * prominent. User-home files from foreign providers (`~/.claude/CLAUDE.md`,
258
262
  * `~/.codex/AGENTS.md`, …) stay excluded — only gjc's own user config applies.
259
263
  */
260
- export async function loadProjectContextFiles(
264
+ export async function loadProjectContextFilesResult(
261
265
  options: LoadContextFilesOptions = {},
262
- ): Promise<Array<{ path: string; content: string; depth?: number }>> {
266
+ ): Promise<ProjectContextFilesResult> {
263
267
  const resolvedCwd = options.cwd ?? getProjectDir();
264
-
265
268
  const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd });
266
269
  const items = result.items as ContextFile[];
267
270
 
@@ -288,7 +291,17 @@ export async function loadProjectContextFiles(
288
291
  return depthB - depthA;
289
292
  });
290
293
 
291
- return dedupeExactContextFiles([...userFiles, ...projectFiles]);
294
+ return {
295
+ contextFiles: dedupeExactContextFiles([...userFiles, ...projectFiles]),
296
+ warnings: result.warnings,
297
+ };
298
+ }
299
+
300
+ /** Load project context files without exposing discovery diagnostics. */
301
+ export async function loadProjectContextFiles(
302
+ options: LoadContextFilesOptions = {},
303
+ ): Promise<Array<{ path: string; content: string; depth?: number }>> {
304
+ return (await loadProjectContextFilesResult(options)).contextFiles;
292
305
  }
293
306
 
294
307
  /**
@@ -386,10 +399,11 @@ export interface BuildSystemPromptOptions {
386
399
  subagent?: boolean;
387
400
  }
388
401
 
389
- /** Result of building provider-facing system prompt messages. */
390
402
  export interface BuildSystemPromptResult {
391
403
  /** Ordered system prompt blocks. Providers should preserve entries as distinct messages/blocks. */
392
404
  systemPrompt: string[];
405
+ /** Context-file discovery warnings visible to SDK and session callers. */
406
+ warnings: string[];
393
407
  }
394
408
  export interface BuildVolatileProjectContextOptions {
395
409
  cwd?: string;
@@ -421,7 +435,7 @@ export function buildVolatileProjectContext(options: BuildVolatileProjectContext
421
435
  /** Build the system prompt with tools, guidelines, and context */
422
436
  export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<BuildSystemPromptResult> {
423
437
  if ($env.NULL_PROMPT === "true") {
424
- return { systemPrompt: [] };
438
+ return { systemPrompt: [], warnings: [] };
425
439
  }
426
440
 
427
441
  const {
@@ -448,7 +462,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
448
462
  resolvedCustomPrompt: undefined as string | undefined,
449
463
  resolvedAppendPrompt: undefined as string | undefined,
450
464
  systemPromptCustomization: null as string | null,
451
- contextFiles: dedupeExactContextFiles(providedContextFiles ?? []),
465
+ contextFiles: { contextFiles: [] as Array<{ path: string; content: string; depth?: number }>, warnings: [] },
452
466
  workspaceTree: {
453
467
  rootPath: resolvedCwd,
454
468
  rendered: "",
@@ -490,8 +504,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
490
504
  cwd: resolvedCwd,
491
505
  });
492
506
  const contextFilesPromise = providedContextFiles
493
- ? Promise.resolve(providedContextFiles)
494
- : logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd });
507
+ ? Promise.resolve({ contextFiles: providedContextFiles, warnings: [] })
508
+ : logger.time("loadProjectContextFiles", loadProjectContextFilesResult, { cwd: resolvedCwd });
495
509
  const workspaceTreePromise =
496
510
  providedWorkspaceTree !== undefined
497
511
  ? Promise.resolve(providedWorkspaceTree)
@@ -499,7 +513,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
499
513
  buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }),
500
514
  );
501
515
 
502
- const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, workspaceTree] =
516
+ const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFileResult, workspaceTree] =
503
517
  await Promise.all([
504
518
  withDeadline(
505
519
  "customPrompt",
@@ -516,11 +530,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
516
530
  systemPromptCustomizationPromise,
517
531
  prepDefaults.systemPromptCustomization,
518
532
  ),
519
- withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
520
- dedupeExactContextFiles,
521
- ),
533
+ withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles),
522
534
  withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
523
535
  ]);
536
+ const contextFiles = dedupeExactContextFiles(contextFileResult.contextFiles);
524
537
  const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
525
538
 
526
539
  if (timedOut.length > 0) {
@@ -628,5 +641,5 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
628
641
  systemPrompt.push(pluginAppendices.trim());
629
642
  }
630
643
 
631
- return { systemPrompt };
644
+ return { systemPrompt, warnings: contextFileResult.warnings };
632
645
  }
package/src/tools/bash.ts CHANGED
@@ -982,8 +982,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
982
982
  }
983
983
  }
984
984
 
985
+ const activeModel = this.session.model;
985
986
  const composerPolicy = checkComposerBashPolicy({
986
- modelId: this.session.getActiveModelString?.() ?? this.session.getModelString?.() ?? this.session.model?.id,
987
+ modelId: this.session.getActiveModelString?.() ?? this.session.getModelString?.() ?? activeModel?.id,
988
+ provider: activeModel?.provider,
987
989
  commands: rawCommand === command ? [command] : [rawCommand, command],
988
990
  });
989
991
  if (!composerPolicy.allowed) {
@@ -1,7 +1,11 @@
1
- import { isComposerHarnessModel } from "@gajae-code/ai/providers/composer-discipline";
1
+ import {
2
+ type ComposerBashPolicyToolSurface,
3
+ formatComposerBashPolicyError,
4
+ isComposerHarnessModel,
5
+ } from "@gajae-code/ai/providers/composer-discipline";
2
6
 
3
- export const COMPOSER_BASH_POLICY_ERROR =
4
- "Composer bash policy blocked repository file I/O. Use find, search, read, and edit tools for file discovery, file inspection, and file mutation.";
7
+ export const COMPOSER_BASH_POLICY_ERROR = formatComposerBashPolicyError("generic");
8
+ export const CURSOR_COMPOSER_BASH_POLICY_ERROR = formatComposerBashPolicyError("cursor");
5
9
 
6
10
  type ComposerBashPolicyResult =
7
11
  | { allowed: true }
@@ -9,6 +13,7 @@ type ComposerBashPolicyResult =
9
13
  allowed: false;
10
14
  reason: string;
11
15
  message: string;
16
+ surface: ComposerBashPolicyToolSurface;
12
17
  };
13
18
 
14
19
  const BLOCK_PATTERNS: Array<{ id: string; pattern: RegExp }> = [
@@ -77,19 +82,30 @@ export function isComposerBashPolicyModel(modelId: string | undefined): boolean
77
82
  return Boolean(modelId && isComposerHarnessModel(modelId));
78
83
  }
79
84
 
85
+ export function resolveComposerBashPolicyToolSurface(input: {
86
+ modelId?: string;
87
+ provider?: string;
88
+ }): ComposerBashPolicyToolSurface {
89
+ if (input.provider?.toLowerCase() === "cursor") return "cursor";
90
+ return /(?:^|[/:._-])cursor(?:[/:._-]|$)/i.test(input.modelId ?? "") ? "cursor" : "generic";
91
+ }
92
+
80
93
  export function checkComposerBashPolicy(input: {
81
94
  modelId?: string;
95
+ provider?: string;
82
96
  commands: readonly string[];
83
97
  }): ComposerBashPolicyResult {
84
98
  if (!isComposerBashPolicyModel(input.modelId)) return { allowed: true };
99
+ const surface = resolveComposerBashPolicyToolSurface(input);
100
+ const message = formatComposerBashPolicyError(surface);
85
101
  for (const command of input.commands) {
86
102
  for (const block of BLOCK_PATTERNS) {
87
103
  if (block.pattern.test(command)) {
88
- return { allowed: false, reason: block.id, message: COMPOSER_BASH_POLICY_ERROR };
104
+ return { allowed: false, reason: block.id, message, surface };
89
105
  }
90
106
  }
91
107
  if (!isAllowedComposerTerminalCommand(command)) {
92
- return { allowed: false, reason: "not-allowlisted", message: COMPOSER_BASH_POLICY_ERROR };
108
+ return { allowed: false, reason: "not-allowlisted", message, surface };
93
109
  }
94
110
  }
95
111
  return { allowed: true };
@@ -39,38 +39,27 @@ export function parseCommandArgs(argsString: string): string[] {
39
39
  * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted.
40
40
  */
41
41
  export function substituteArgs(content: string, args: string[]): string {
42
- let result = content;
43
-
44
- // Replace $1, $2, etc. with positional args FIRST (before wildcards)
45
- // This prevents wildcard replacement values containing $<digit> patterns from being re-substituted
46
- result = result.replace(/\$(\d+)/g, (_, num) => {
47
- const index = parseInt(num, 10) - 1;
48
- return args[index] ?? "";
49
- });
50
-
51
- result = result.replace(/\$@\[(\d+)(?::(\d*)?)?\]/g, (_, startRaw: string, lengthRaw?: string) => {
52
- const start = Number.parseInt(startRaw, 10);
53
- if (!Number.isFinite(start) || start < 1) return "";
54
- const startIndex = start - 1;
55
- if (startIndex >= args.length) return "";
56
-
57
- if (lengthRaw === undefined || lengthRaw === "") {
58
- return args.slice(startIndex).join(" ");
59
- }
60
-
61
- const length = Number.parseInt(lengthRaw, 10);
62
- if (!Number.isFinite(length) || length <= 0) return "";
63
- return args.slice(startIndex, startIndex + length).join(" ");
64
- });
65
-
66
- // Pre-compute all args joined (optimization)
67
42
  const allArgs = args.join(" ");
43
+ return content.replace(
44
+ /\$@\[(\d+)(?::(\d*)?)?\]|\$ARGUMENTS|\$@|\$(\d+)/g,
45
+ (_, startRaw: string | undefined, lengthRaw: string | undefined, positionRaw: string | undefined) => {
46
+ if (positionRaw !== undefined) {
47
+ return args[Number.parseInt(positionRaw, 10) - 1] ?? "";
48
+ }
49
+ if (startRaw === undefined) return allArgs;
68
50
 
69
- // Replace $ARGUMENTS with all args joined (new syntax, aligns with Anthropic model, OpenAI code backend, OpenCode)
70
- result = result.replaceAll("$ARGUMENTS", allArgs);
51
+ const start = Number.parseInt(startRaw, 10);
52
+ if (!Number.isFinite(start) || start < 1) return "";
53
+ const startIndex = start - 1;
54
+ if (startIndex >= args.length) return "";
71
55
 
72
- // Replace $@ with all args joined (existing syntax)
73
- result = result.replaceAll("$@", allArgs);
56
+ if (lengthRaw === undefined || lengthRaw === "") {
57
+ return args.slice(startIndex).join(" ");
58
+ }
74
59
 
75
- return result;
60
+ const length = Number.parseInt(lengthRaw, 10);
61
+ if (!Number.isFinite(length) || length <= 0) return "";
62
+ return args.slice(startIndex, startIndex + length).join(" ");
63
+ },
64
+ );
76
65
  }
@@ -201,18 +201,16 @@ function lspExtensionKey(filePath: string): string {
201
201
  * Language id for syntax highlighting and UI (icons, read tool), or undefined if unknown.
202
202
  */
203
203
  export function getLanguageFromPath(filePath: string): string | undefined {
204
- const pair = EXTENSION_LANG[themeExtensionKey(filePath)];
205
- if (pair) return pair[0];
206
-
207
204
  const baseName = path.basename(filePath).toLowerCase();
208
205
  if (baseName.startsWith(".env.")) return "env";
209
206
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
210
207
  return "dockerfile";
211
208
  }
209
+ if (baseName === "makefile" || baseName === "gnumakefile") return "make";
212
210
  if (baseName === "justfile") return "just";
213
211
  if (baseName === "cmakelists.txt") return "cmake";
214
212
 
215
- return undefined;
213
+ return EXTENSION_LANG[themeExtensionKey(filePath)]?.[0];
216
214
  }
217
215
 
218
216
  /**