@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.
- package/CHANGELOG.md +29 -0
- package/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/commands/sdk.d.ts +23 -0
- package/dist/types/discovery/agents-md.d.ts +11 -1
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/notification-service.d.ts +23 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -0
- package/dist/types/session/internal/native-publish-outcome.d.ts +2 -2
- package/dist/types/system-prompt.d.ts +12 -1
- package/dist/types/tools/composer-bash-policy.d.ts +9 -1
- package/package.json +8 -8
- package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
- package/src/cli/notify-cli.ts +12 -6
- package/src/commands/sdk.ts +54 -0
- package/src/config/model-profiles.ts +8 -0
- package/src/config/model-registry.ts +4 -1
- package/src/discovery/agents-md.ts +76 -19
- package/src/gjc-runtime/psmux-detect.ts +12 -16
- package/src/gjc-runtime/tmux-sessions.ts +8 -1
- package/src/internal-urls/docs-index.generated.ts +4 -3
- package/src/modes/components/extensions/inspector-panel.ts +9 -5
- package/src/modes/controllers/selector-controller.ts +61 -43
- package/src/modes/print-mode.ts +3 -0
- package/src/modes/types.ts +6 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/notification-service.ts +140 -7
- package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
- package/src/sdk/session.ts +24 -7
- package/src/session/agent-session.ts +65 -13
- package/src/session/internal/managed-session-storage.ts +36 -2
- package/src/session/internal/native-publish-outcome.ts +12 -2
- package/src/slash-commands/builtin-registry.ts +35 -11
- package/src/system-prompt.ts +28 -15
- package/src/tools/bash.ts +3 -1
- package/src/tools/composer-bash-policy.ts +21 -5
- package/src/utils/command-args.ts +19 -30
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14734
|
-
|
|
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 (
|
|
14740
|
-
|
|
14741
|
-
|
|
14742
|
-
|
|
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
|
|
14840
|
-
|
|
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" &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -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
|
|
264
|
+
export async function loadProjectContextFilesResult(
|
|
261
265
|
options: LoadContextFilesOptions = {},
|
|
262
|
-
): Promise<
|
|
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
|
|
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:
|
|
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",
|
|
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,
|
|
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)
|
|
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?.() ??
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
56
|
+
if (lengthRaw === undefined || lengthRaw === "") {
|
|
57
|
+
return args.slice(startIndex).join(" ");
|
|
58
|
+
}
|
|
74
59
|
|
|
75
|
-
|
|
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
|
|
213
|
+
return EXTENSION_LANG[themeExtensionKey(filePath)]?.[0];
|
|
216
214
|
}
|
|
217
215
|
|
|
218
216
|
/**
|