@gajae-code/coding-agent 0.17.6 → 0.17.7
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 +34 -0
- package/dist/types/cli/public-command-errors.d.ts +2 -0
- package/dist/types/commands/sdk.d.ts +7 -1
- package/dist/types/config/model-profile-ownership.d.ts +74 -0
- package/dist/types/config/model-registry.d.ts +2 -1
- package/dist/types/config/settings-schema.d.ts +32 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +4 -1
- package/dist/types/sdk/lifecycle/service.d.ts +2 -0
- package/dist/types/sdk/lifecycle-session.d.ts +5 -1
- package/dist/types/sdk/session-directory.d.ts +1 -0
- package/dist/types/sdk/session-list.d.ts +11 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +6 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/setup/model-onboarding-guidance.d.ts +18 -0
- package/dist/types/task/delegation-hint.d.ts +53 -0
- package/examples/README.md +3 -3
- package/examples/custom-tools/README.md +1 -1
- package/examples/hooks/README.md +1 -1
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +6 -0
- package/src/cli/config-cli.ts +2 -0
- package/src/cli/public-command-errors.ts +6 -0
- package/src/commands/sdk.ts +361 -117
- package/src/config/model-profile-ownership.ts +303 -0
- package/src/config/model-registry.ts +53 -14
- package/src/config/settings-schema.ts +81 -0
- package/src/internal-urls/docs-index.generated.ts +18 -17
- package/src/modes/controllers/extension-ui-controller.ts +6 -0
- package/src/modes/controllers/selector-controller.ts +3 -29
- package/src/sdk/broker/broker.ts +120 -23
- package/src/sdk/broker/lifecycle.ts +89 -13
- package/src/sdk/cli/session-cli.ts +45 -9
- package/src/sdk/host/control/dispatch.ts +6 -0
- package/src/sdk/lifecycle/service.ts +186 -3
- package/src/sdk/lifecycle-session.ts +35 -8
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/protocol/operation-registry.ts +5 -1
- package/src/sdk/session-directory.ts +1 -1
- package/src/sdk/session-list.ts +47 -0
- package/src/sdk/session.ts +290 -114
- package/src/sdk/startup-capability.ts +2 -1
- package/src/session/agent-session.ts +102 -4
- package/src/session/internal/managed-session-scope.ts +81 -17
- package/src/session/session-manager.ts +70 -11
- package/src/setup/model-onboarding-guidance.ts +26 -0
- package/src/task/delegation-hint.ts +578 -0
|
@@ -626,6 +626,10 @@ export class ExtensionUiController {
|
|
|
626
626
|
invokeSkill: (name, args, options) => this.ctx.session.invokeSkill(name, args, options),
|
|
627
627
|
setPlanMode: on => this.ctx.session.setSdkPlanMode(on),
|
|
628
628
|
operateGoal: (op, objective) => this.ctx.session.operateGoal(op, objective),
|
|
629
|
+
// Read the CURRENT session on every call: interactive contexts outlive a
|
|
630
|
+
// session rebind, and the snapshot must stay session-owned (owner-filtered)
|
|
631
|
+
// instead of reaching for the process-global manager (issue: jobs Q25).
|
|
632
|
+
getJobs: () => this.ctx.session.getAsyncJobSnapshot(),
|
|
629
633
|
getSkillState: () =>
|
|
630
634
|
this.ctx.session.skills.map(skill => ({ name: skill.name, description: skill.description })),
|
|
631
635
|
getConfigItems: () => this.ctx.session.getSdkConfigItems(),
|
|
@@ -854,6 +858,8 @@ export class ExtensionUiController {
|
|
|
854
858
|
}
|
|
855
859
|
return false;
|
|
856
860
|
},
|
|
861
|
+
// Same session-owned binding as the hooks/custom-tools context above.
|
|
862
|
+
getJobs: () => this.ctx.session.getAsyncJobSnapshot(),
|
|
857
863
|
getSkillState: () =>
|
|
858
864
|
this.ctx.session.skills.map(skill => ({ name: skill.name, description: skill.description })),
|
|
859
865
|
getConfigItems: () => this.ctx.session.getSdkConfigItems(),
|
|
@@ -29,12 +29,10 @@ import { CURATED_TIER_MAP } from "../../config/autorouting-tier-map";
|
|
|
29
29
|
import type { AppKeybinding } from "../../config/keybindings";
|
|
30
30
|
import {
|
|
31
31
|
activateModelProfile,
|
|
32
|
-
type MaterializeModelProfileForDeletionResult,
|
|
33
32
|
materializeActiveModelProfileAssignment,
|
|
34
33
|
materializeActiveModelProfileAssignments,
|
|
35
|
-
materializeModelProfileForDeletion,
|
|
36
|
-
restoreMaterializedModelProfileForDeletion,
|
|
37
34
|
} from "../../config/model-profile-activation";
|
|
35
|
+
import { ModelProfileReplacementRequiredError } from "../../config/model-profile-ownership";
|
|
38
36
|
import { formatModelProfileDisplayLabel, recommendModelProfileForProvider } from "../../config/model-profiles";
|
|
39
37
|
import { GJC_MODEL_ASSIGNMENT_TARGETS, type GjcModelAssignmentTargetId } from "../../config/model-registry";
|
|
40
38
|
import { formatModelSelectorValue } from "../../config/model-resolver";
|
|
@@ -1750,7 +1748,7 @@ export class SelectorController {
|
|
|
1750
1748
|
const profileLabel = profile ? formatModelProfileDisplayLabel(profile) : profileName;
|
|
1751
1749
|
const confirmed = await this.ctx.showHookConfirm(
|
|
1752
1750
|
`Delete custom model preset: ${profileLabel}`,
|
|
1753
|
-
"
|
|
1751
|
+
"Saved sessions keep references to deleted presets and will fail closed until an explicit replacement is selected.",
|
|
1754
1752
|
);
|
|
1755
1753
|
if (!confirmed) {
|
|
1756
1754
|
this.ctx.showStatus("Preset delete cancelled.");
|
|
@@ -1759,8 +1757,6 @@ export class SelectorController {
|
|
|
1759
1757
|
}
|
|
1760
1758
|
|
|
1761
1759
|
const activeProfile = this.ctx.session.getActiveModelProfile?.();
|
|
1762
|
-
const defaultProfile = this.ctx.settings.get("modelProfile.default");
|
|
1763
|
-
let snapshot: MaterializeModelProfileForDeletionResult | undefined;
|
|
1764
1760
|
let deletedProfile: ModelProfileConfig | undefined;
|
|
1765
1761
|
const refreshSelectorState = (refreshedProfileName?: string): void => {
|
|
1766
1762
|
modelSelector.refreshRoleAssignments({
|
|
@@ -1772,14 +1768,7 @@ export class SelectorController {
|
|
|
1772
1768
|
modelSelector.refreshPresetProfiles(refreshedProfileName);
|
|
1773
1769
|
};
|
|
1774
1770
|
try {
|
|
1775
|
-
if (activeProfile === profileName
|
|
1776
|
-
snapshot = await materializeModelProfileForDeletion({
|
|
1777
|
-
session: this.ctx.session,
|
|
1778
|
-
modelRegistry: this.ctx.session.modelRegistry,
|
|
1779
|
-
settings: this.ctx.settings,
|
|
1780
|
-
profileName,
|
|
1781
|
-
});
|
|
1782
|
-
}
|
|
1771
|
+
if (activeProfile === profileName) throw new ModelProfileReplacementRequiredError(profileName);
|
|
1783
1772
|
deletedProfile = await this.ctx.session.modelRegistry.deleteCustomModelProfile(profileName);
|
|
1784
1773
|
await this.ctx.session.modelRegistry.refresh("offline", this.ctx.session.credentialSessionId);
|
|
1785
1774
|
await this.ctx.notifyConfigChanged?.();
|
|
@@ -1796,21 +1785,6 @@ export class SelectorController {
|
|
|
1796
1785
|
presetRestoreError = restoreErr;
|
|
1797
1786
|
}
|
|
1798
1787
|
}
|
|
1799
|
-
if (snapshot) {
|
|
1800
|
-
try {
|
|
1801
|
-
await restoreMaterializedModelProfileForDeletion({
|
|
1802
|
-
settings: this.ctx.settings,
|
|
1803
|
-
session: this.ctx.session,
|
|
1804
|
-
snapshot,
|
|
1805
|
-
});
|
|
1806
|
-
} catch (restoreErr) {
|
|
1807
|
-
refreshSelectorState(deletedProfile ? profileName : undefined);
|
|
1808
|
-
this.ctx.showError(
|
|
1809
|
-
`Preset delete failed and settings rollback failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`,
|
|
1810
|
-
);
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
1788
|
if (deletedProfile) refreshSelectorState(profileName);
|
|
1815
1789
|
if (presetRestoreError) {
|
|
1816
1790
|
this.ctx.showError(
|
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
listManagedSessionCandidates,
|
|
22
22
|
resolveManagedSessionScope,
|
|
23
23
|
} from "../session-directory";
|
|
24
|
+
import {
|
|
25
|
+
SESSION_LIST_SAVED_SESSION_OMISSION_DETAIL_CODES,
|
|
26
|
+
type SessionListSavedSessionOmission,
|
|
27
|
+
type SessionListSavedSessionOmissionDetailCode,
|
|
28
|
+
} from "../session-list";
|
|
24
29
|
import {
|
|
25
30
|
BROKER_HEARTBEAT_TTL_MS,
|
|
26
31
|
type BrokerDiscovery,
|
|
@@ -392,6 +397,7 @@ type SessionListCursor = {
|
|
|
392
397
|
limit: number;
|
|
393
398
|
offset: number;
|
|
394
399
|
expiresAt: number;
|
|
400
|
+
cwd?: string;
|
|
395
401
|
scope?: ResolvedScopeV1;
|
|
396
402
|
observedAt?: string;
|
|
397
403
|
};
|
|
@@ -550,6 +556,10 @@ function sessionListLimit(input: Record<string, unknown>): number | BrokerRespon
|
|
|
550
556
|
return limit;
|
|
551
557
|
}
|
|
552
558
|
|
|
559
|
+
function savedSessionOmissionDetailCode(code: string): SessionListSavedSessionOmissionDetailCode | undefined {
|
|
560
|
+
return SESSION_LIST_SAVED_SESSION_OMISSION_DETAIL_CODES.find(candidate => candidate === code);
|
|
561
|
+
}
|
|
562
|
+
|
|
553
563
|
function isBrokerResponse(value: unknown): value is BrokerResponse {
|
|
554
564
|
return typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean";
|
|
555
565
|
}
|
|
@@ -750,8 +760,15 @@ export function normalizeBrokerInput(operation: string, input: Record<string, un
|
|
|
750
760
|
const resolved = input.resolveSessionId;
|
|
751
761
|
if (resolved !== undefined && (typeof resolved !== "string" || !isCanonicalSessionId(resolved)))
|
|
752
762
|
return error("invalid_input", "resolveSessionId must be a canonical safe identifier");
|
|
763
|
+
const cwd = input.cwd;
|
|
764
|
+
if (cwd !== undefined && (typeof cwd !== "string" || cwd.length === 0))
|
|
765
|
+
return error("invalid_input", "cwd must be a non-empty string");
|
|
753
766
|
if (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length === 0))
|
|
754
767
|
return error("invalid_input", "cursor must be a non-empty opaque string");
|
|
768
|
+
if (input.cursor !== undefined && resolved !== undefined)
|
|
769
|
+
return error("invalid_input", "cursor cannot be combined with resolveSessionId");
|
|
770
|
+
if (input.scope !== undefined && (cwd !== undefined || resolved !== undefined))
|
|
771
|
+
return error("invalid_input", "scope cannot be combined with cwd or resolveSessionId");
|
|
755
772
|
const limit = sessionListLimit(input);
|
|
756
773
|
if (isBrokerResponse(limit)) return limit;
|
|
757
774
|
if (input.scope !== undefined && !scopeRequestV1(input.scope) && input.cursor === undefined)
|
|
@@ -4469,6 +4486,8 @@ export class Broker {
|
|
|
4469
4486
|
if (typeof cursor === "string") this.#sessionListCursors.delete(cursor);
|
|
4470
4487
|
return error("invalid_input", "cursor is expired or invalid");
|
|
4471
4488
|
}
|
|
4489
|
+
if (stored && input.cwd !== undefined && input.cwd !== stored.cwd)
|
|
4490
|
+
return error("cwd_cursor_mismatch", "cwd must match the cursor snapshot");
|
|
4472
4491
|
if (stored && requestedLimit !== undefined && stored.limit !== requestedLimit)
|
|
4473
4492
|
return error("invalid_input", "limit must match the cursor page shape");
|
|
4474
4493
|
let scope: ResolvedScopeV1 | undefined;
|
|
@@ -4511,6 +4530,7 @@ export class Broker {
|
|
|
4511
4530
|
limit,
|
|
4512
4531
|
offset: 0,
|
|
4513
4532
|
expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS,
|
|
4533
|
+
...(typeof input.cwd === "string" ? { cwd: input.cwd } : {}),
|
|
4514
4534
|
...(scope === undefined ? {} : { scope, observedAt: observedAt! }),
|
|
4515
4535
|
};
|
|
4516
4536
|
const sessions = snapshot.sessions.slice(snapshot.offset, snapshot.offset + snapshot.limit).map(session => {
|
|
@@ -4543,17 +4563,31 @@ export class Broker {
|
|
|
4543
4563
|
requestedOperation: string,
|
|
4544
4564
|
idempotencyKey: string | undefined,
|
|
4545
4565
|
requestedFingerprint: string,
|
|
4566
|
+
legacyFingerprint?: string,
|
|
4546
4567
|
): Promise<BrokerResponse> {
|
|
4547
4568
|
if (!idempotencyKey) return error("invalid_input", "operation, idempotencyKey, and fingerprint are required");
|
|
4548
4569
|
if (requestedOperation === "session.spawn")
|
|
4549
4570
|
return error("invalid_input", "session.spawn does not support lifecycle lookup");
|
|
4550
4571
|
if (!LIFECYCLE_OPERATIONS.has(requestedOperation)) return error("not_found", "lifecycle operation was not found");
|
|
4551
4572
|
const identity = await deriveIdempotencyIdentity(this.settings.agentDir, requestedOperation, idempotencyKey);
|
|
4552
|
-
|
|
4573
|
+
let entry =
|
|
4553
4574
|
this.ledger.get(identity) ??
|
|
4554
4575
|
this.ledger.findByOperationKey(`${requestedOperation}\0${idempotencyKey}`, requestedFingerprint);
|
|
4576
|
+
let acceptedFingerprint = requestedFingerprint;
|
|
4577
|
+
if (!entry && legacyFingerprint !== undefined) {
|
|
4578
|
+
// Rows written before target-bound identities (v3) fingerprinted the raw,
|
|
4579
|
+
// un-normalized request and are never expired, so they are matched only
|
|
4580
|
+
// under their exact v3 identity and only against that raw fingerprint.
|
|
4581
|
+
const legacy = this.ledger.get(
|
|
4582
|
+
await deriveLegacyIdentity(this.settings.agentDir, requestedOperation, idempotencyKey),
|
|
4583
|
+
);
|
|
4584
|
+
if (legacy) {
|
|
4585
|
+
entry = legacy;
|
|
4586
|
+
if (legacy.fingerprint === legacyFingerprint) acceptedFingerprint = legacyFingerprint;
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4555
4589
|
if (!entry) return error("not_found", "lifecycle operation was not found");
|
|
4556
|
-
if (entry.fingerprint !==
|
|
4590
|
+
if (entry.fingerprint !== acceptedFingerprint)
|
|
4557
4591
|
return error("idempotency_conflict", "lifecycle request fingerprint differs");
|
|
4558
4592
|
if (entry.state === "terminal_uncertain")
|
|
4559
4593
|
return error("terminal_uncertain", "lifecycle outcome is still uncertain");
|
|
@@ -4606,9 +4640,14 @@ export class Broker {
|
|
|
4606
4640
|
if (operation === "session.lookup") {
|
|
4607
4641
|
const lookup = publicLifecycleLookupInput(input);
|
|
4608
4642
|
if (isBrokerResponse(lookup)) return lookup;
|
|
4643
|
+
// The ledger fingerprint covers the normalized mutation input, so the
|
|
4644
|
+
// lookup target must pass through the same normalization to match it.
|
|
4645
|
+
const lookupTarget = normalizeBrokerInput(lookup.operation, lookup.target);
|
|
4646
|
+
if (isBrokerResponse(lookupTarget)) return lookupTarget;
|
|
4609
4647
|
return await this.#lookupLifecycle(
|
|
4610
4648
|
lookup.operation,
|
|
4611
4649
|
idempotencyKey,
|
|
4650
|
+
lifecycleFingerprint(lookup.operation, lookupTarget.input),
|
|
4612
4651
|
lifecycleFingerprint(lookup.operation, lookup.target),
|
|
4613
4652
|
);
|
|
4614
4653
|
}
|
|
@@ -4625,38 +4664,96 @@ export class Broker {
|
|
|
4625
4664
|
const cwd = typeof input.cwd === "string" ? input.cwd : undefined;
|
|
4626
4665
|
if (resolveSessionId && cwd) {
|
|
4627
4666
|
const scope = await resolveManagedSessionScope({ cwd, agentDir: this.settings.agentDir });
|
|
4628
|
-
|
|
4667
|
+
let listed =
|
|
4629
4668
|
scope.kind === "resolved" ? await listManagedSessionCandidates({ scope: scope.scope }) : undefined;
|
|
4669
|
+
if (
|
|
4670
|
+
scope.kind === "resolved" &&
|
|
4671
|
+
listed?.kind === "complete" &&
|
|
4672
|
+
listed.invalid.some(
|
|
4673
|
+
candidate => candidate.sessionId === resolveSessionId && candidate.code === "source_changed",
|
|
4674
|
+
)
|
|
4675
|
+
)
|
|
4676
|
+
// A writer can finish after the preflight read but before the independent
|
|
4677
|
+
// identity-bound inspection. Retry one complete scan, rerunning every
|
|
4678
|
+
// scope, no-follow, uniqueness, and identity check against fresh state.
|
|
4679
|
+
listed = await listManagedSessionCandidates({ scope: scope.scope });
|
|
4680
|
+
const invalidCandidate =
|
|
4681
|
+
listed?.kind === "complete"
|
|
4682
|
+
? listed.invalid.find(candidate => candidate.sessionId === resolveSessionId)
|
|
4683
|
+
: undefined;
|
|
4630
4684
|
const matches =
|
|
4631
4685
|
listed?.kind === "complete"
|
|
4632
4686
|
? listed.owned.filter(candidate => candidate.sessionId === resolveSessionId)
|
|
4633
4687
|
: [];
|
|
4634
4688
|
const match = matches.length === 1 ? matches[0] : undefined;
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
:
|
|
4689
|
+
let savedSession: Record<string, unknown> | undefined;
|
|
4690
|
+
let savedSessionOmission: SessionListSavedSessionOmission | undefined;
|
|
4691
|
+
if (scope.kind !== "resolved") {
|
|
4692
|
+
savedSessionOmission = {
|
|
4693
|
+
sessionId: resolveSessionId,
|
|
4694
|
+
reason: "scope_unavailable",
|
|
4695
|
+
detailCode: scope.code,
|
|
4696
|
+
};
|
|
4697
|
+
} else if (listed?.kind !== "complete") {
|
|
4698
|
+
savedSessionOmission = {
|
|
4699
|
+
sessionId: resolveSessionId,
|
|
4700
|
+
reason: "candidate_scan_failed",
|
|
4701
|
+
...(listed === undefined ? {} : { detailCode: listed.code }),
|
|
4702
|
+
};
|
|
4703
|
+
} else if (invalidCandidate !== undefined) {
|
|
4704
|
+
const detailCode = savedSessionOmissionDetailCode(invalidCandidate.code);
|
|
4705
|
+
savedSessionOmission = {
|
|
4706
|
+
sessionId: resolveSessionId,
|
|
4707
|
+
reason: "candidate_invalid",
|
|
4708
|
+
...(detailCode === undefined ? {} : { detailCode }),
|
|
4709
|
+
};
|
|
4710
|
+
} else if (matches.length === 0) {
|
|
4711
|
+
savedSessionOmission = {
|
|
4712
|
+
sessionId: resolveSessionId,
|
|
4713
|
+
reason: "candidate_not_found",
|
|
4714
|
+
};
|
|
4715
|
+
} else if (matches.length > 1) {
|
|
4716
|
+
savedSessionOmission = {
|
|
4717
|
+
sessionId: resolveSessionId,
|
|
4718
|
+
reason: "candidate_ambiguous",
|
|
4719
|
+
candidateCount: matches.length,
|
|
4720
|
+
};
|
|
4721
|
+
} else if (match) {
|
|
4722
|
+
const missingIdentityFields: ("nlink" | "ctimeNs")[] = [];
|
|
4723
|
+
const { nlink, ctimeNs } = match.identity;
|
|
4724
|
+
if (nlink === undefined) missingIdentityFields.push("nlink");
|
|
4725
|
+
if (ctimeNs === undefined) missingIdentityFields.push("ctimeNs");
|
|
4726
|
+
if (missingIdentityFields.length > 0) {
|
|
4727
|
+
savedSessionOmission = {
|
|
4728
|
+
sessionId: resolveSessionId,
|
|
4729
|
+
reason: "identity_incomplete",
|
|
4730
|
+
missingIdentityFields,
|
|
4731
|
+
};
|
|
4732
|
+
} else if (match.sessionId === resolveSessionId && nlink !== undefined && ctimeNs !== undefined) {
|
|
4733
|
+
savedSession = {
|
|
4734
|
+
id: match.sessionId,
|
|
4735
|
+
path: match.path,
|
|
4736
|
+
identity: {
|
|
4737
|
+
dev: match.identity.dev.toString(),
|
|
4738
|
+
ino: match.identity.ino.toString(),
|
|
4739
|
+
nlink: nlink.toString(),
|
|
4740
|
+
size: match.identity.size,
|
|
4741
|
+
mtimeMs: match.identity.mtimeMs,
|
|
4742
|
+
mtimeNs: match.identity.mtimeNs.toString(),
|
|
4743
|
+
ctimeNs: ctimeNs.toString(),
|
|
4744
|
+
sha256: match.identity.sha256,
|
|
4745
|
+
},
|
|
4746
|
+
};
|
|
4747
|
+
}
|
|
4748
|
+
} else {
|
|
4749
|
+
savedSessionOmission = { sessionId: resolveSessionId, reason: "candidate_not_found" };
|
|
4750
|
+
}
|
|
4655
4751
|
return {
|
|
4656
4752
|
...page,
|
|
4657
4753
|
result: {
|
|
4658
4754
|
...pageResult,
|
|
4659
4755
|
...(savedSession === undefined ? {} : { savedSession }),
|
|
4756
|
+
...(savedSessionOmission === undefined ? {} : { savedSessionOmission }),
|
|
4660
4757
|
},
|
|
4661
4758
|
};
|
|
4662
4759
|
}
|
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
3
3
|
import * as fsSync from "node:fs";
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import type { NativeExactUnlinkResult } from "@gajae-code/natives";
|
|
6
|
+
import type { NativeExactFileIdentity, NativeExactUnlinkResult } from "@gajae-code/natives";
|
|
7
7
|
|
|
8
8
|
let nativeLifecycleBindings: typeof import("@gajae-code/natives") | undefined;
|
|
9
9
|
|
|
@@ -1758,29 +1758,105 @@ export async function writeEffectMarker(root: string, id: string, marker: Effect
|
|
|
1758
1758
|
}
|
|
1759
1759
|
}
|
|
1760
1760
|
|
|
1761
|
+
export class LifecycleReadinessCleanupError extends Error {
|
|
1762
|
+
constructor(cause: unknown) {
|
|
1763
|
+
super("Lifecycle readiness marker cleanup could not be proven.", { cause });
|
|
1764
|
+
this.name = "LifecycleReadinessCleanupError";
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const LIFECYCLE_READY_REVOCATION_GRACE_MS = 100;
|
|
1769
|
+
|
|
1761
1770
|
/** The child writes this only after its endpoint and semantic ready event are both live. */
|
|
1762
|
-
export async function writeSessionLifecycleReady(
|
|
1771
|
+
export async function writeSessionLifecycleReady(
|
|
1772
|
+
root: string,
|
|
1773
|
+
id: string,
|
|
1774
|
+
effectMarker: string,
|
|
1775
|
+
canPublish: () => boolean = () => true,
|
|
1776
|
+
onPublishing?: (revoke: () => Promise<boolean>) => void,
|
|
1777
|
+
onPublished?: () => void,
|
|
1778
|
+
): Promise<void> {
|
|
1763
1779
|
const incarnation = processIncarnation(process.pid);
|
|
1764
1780
|
if (!incarnation) throw new Error("Lifecycle child has no readable OS incarnation.");
|
|
1781
|
+
const native = nativeLifecycle();
|
|
1765
1782
|
const directory = path.join(root, "sdk");
|
|
1766
1783
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
1767
1784
|
const temporary = path.join(directory, `.${id}.lifecycle.ready.${randomUUID()}.tmp`);
|
|
1768
|
-
const
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1785
|
+
const readyPath = lifecycleReadyPath(root, id);
|
|
1786
|
+
let handle: fs.FileHandle | undefined;
|
|
1787
|
+
let published = false;
|
|
1788
|
+
let publicationIdentity: NativeExactFileIdentity | undefined;
|
|
1789
|
+
let revocation: Promise<boolean> | undefined;
|
|
1790
|
+
const readyMarkerIsAbsent = async (): Promise<boolean> => {
|
|
1791
|
+
try {
|
|
1792
|
+
await fs.lstat(readyPath);
|
|
1793
|
+
return false;
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
const revoke = (): Promise<boolean> => {
|
|
1799
|
+
if (revocation) return revocation;
|
|
1800
|
+
if (!publicationIdentity) return Promise.resolve(false);
|
|
1801
|
+
let queued: boolean;
|
|
1802
|
+
try {
|
|
1803
|
+
queued = native.exactUnlinkDirectDetached(readyPath, publicationIdentity);
|
|
1804
|
+
} catch {
|
|
1805
|
+
queued = false;
|
|
1806
|
+
}
|
|
1807
|
+
if (!queued) {
|
|
1808
|
+
revocation = readyMarkerIsAbsent();
|
|
1809
|
+
return revocation;
|
|
1810
|
+
}
|
|
1811
|
+
revocation = (async () => {
|
|
1812
|
+
const deadline = Date.now() + LIFECYCLE_READY_REVOCATION_GRACE_MS;
|
|
1813
|
+
while (Date.now() < deadline) {
|
|
1814
|
+
if (await readyMarkerIsAbsent()) return true;
|
|
1815
|
+
await Bun.sleep(5);
|
|
1816
|
+
}
|
|
1817
|
+
return false;
|
|
1818
|
+
})();
|
|
1819
|
+
return revocation;
|
|
1820
|
+
};
|
|
1773
1821
|
try {
|
|
1774
|
-
await
|
|
1822
|
+
handle = await fs.open(
|
|
1823
|
+
temporary,
|
|
1824
|
+
fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY,
|
|
1825
|
+
0o600,
|
|
1826
|
+
);
|
|
1827
|
+
const contents = Buffer.from(canonicalJson({ pid: process.pid, effectMarker, incarnation }), "utf8");
|
|
1828
|
+
await handle.writeFile(contents);
|
|
1775
1829
|
await handle.sync();
|
|
1776
|
-
|
|
1830
|
+
const stat = await handle.stat({ bigint: true });
|
|
1831
|
+
const parent = await fs.lstat(directory, { bigint: true });
|
|
1832
|
+
if (!stat.isFile() || stat.nlink !== 1n || stat.size !== BigInt(contents.byteLength) || !parent.isDirectory())
|
|
1833
|
+
throw new Error("Lifecycle readiness publication identity is invalid.");
|
|
1834
|
+
publicationIdentity = {
|
|
1835
|
+
dev: stat.dev,
|
|
1836
|
+
ino: stat.ino,
|
|
1837
|
+
nlink: stat.nlink,
|
|
1838
|
+
parentDev: parent.dev,
|
|
1839
|
+
parentIno: parent.ino,
|
|
1840
|
+
size: stat.size,
|
|
1841
|
+
mtimeNs: stat.mtimeNs,
|
|
1842
|
+
sha256: createHash("sha256").update(contents).digest("hex"),
|
|
1843
|
+
quarantineName: `.gjc-reap-${randomUUID()}-${path.basename(readyPath)}`,
|
|
1844
|
+
};
|
|
1777
1845
|
await handle.close();
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
await fs.rename(temporary,
|
|
1846
|
+
handle = undefined;
|
|
1847
|
+
if (!canPublish()) throw new Error("Lifecycle readiness cutoff passed before publication.");
|
|
1848
|
+
await fs.rename(temporary, readyPath);
|
|
1849
|
+
published = true;
|
|
1850
|
+
onPublishing?.(revoke);
|
|
1781
1851
|
await syncDirectory(directory);
|
|
1852
|
+
if (!canPublish()) throw new Error("Lifecycle readiness cutoff passed during publication.");
|
|
1853
|
+
onPublished?.();
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
if (published && !(await revoke())) throw new LifecycleReadinessCleanupError(error);
|
|
1856
|
+
throw error;
|
|
1782
1857
|
} finally {
|
|
1783
|
-
await
|
|
1858
|
+
if (handle) await handle.close().catch(() => {});
|
|
1859
|
+
if (!published) await fs.rm(temporary, { force: true });
|
|
1784
1860
|
}
|
|
1785
1861
|
}
|
|
1786
1862
|
|
|
@@ -4,7 +4,13 @@ import * as fs from "node:fs/promises";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { replaceTabs, truncateToWidth } from "@gajae-code/tui";
|
|
6
6
|
import { getAgentDir, logger, sanitizeDisplayLine } from "@gajae-code/utils";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
PUBLIC_COMMAND_DIAGNOSTICS,
|
|
9
|
+
type PublicCommandDiagnosticCode,
|
|
10
|
+
PublicCommandFailure,
|
|
11
|
+
type PublicEffectProof,
|
|
12
|
+
type PublicFailureKind,
|
|
13
|
+
} from "../../cli/public-command-errors";
|
|
8
14
|
import type { EvidenceReference } from "../../cli/public-command-evidence";
|
|
9
15
|
import { repo as resolveGitRepository } from "../../utils/git";
|
|
10
16
|
import { ensureBroker } from "../broker/ensure";
|
|
@@ -20,6 +26,7 @@ import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
|
20
26
|
import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../client";
|
|
21
27
|
import { createBrokerSessionLifecycleService } from "../lifecycle/broker-client";
|
|
22
28
|
import type {
|
|
29
|
+
SessionLifecycleLookupOutcome,
|
|
23
30
|
SessionLifecycleMutationRequest,
|
|
24
31
|
SessionLifecycleOperation,
|
|
25
32
|
SessionLifecycleResult,
|
|
@@ -194,6 +201,10 @@ export function sdkPublicFailure(code: string, details?: unknown, proof?: Public
|
|
|
194
201
|
kind: Object.hasOwn(kinds, code) ? kinds[code]! : "operation_failed",
|
|
195
202
|
proof: code === "wait_timeout" ? "accepted" : proof,
|
|
196
203
|
references,
|
|
204
|
+
// A typed `resource_gone` keeps its public classification; the fixed
|
|
205
|
+
// diagnostic only separates absent resource state from an empty result.
|
|
206
|
+
// It asserts no cause and echoes no host-supplied text.
|
|
207
|
+
...(code === "resource_gone" ? { diagnostics: ["sdk_resource_gone" as const] } : {}),
|
|
197
208
|
});
|
|
198
209
|
}
|
|
199
210
|
|
|
@@ -242,8 +253,15 @@ export function lifecyclePublicFailure(outcome: Extract<SessionLifecycleResult,
|
|
|
242
253
|
// The service emits retryable protocol_error only when requestSent is explicitly false.
|
|
243
254
|
const proof: PublicEffectProof =
|
|
244
255
|
outcome.certainty === "retryable" && outcome.error.code === "protocol_error" ? "pre-send" : "unknown";
|
|
256
|
+
// `operation_failed` cannot distinguish a conflict from any other failed operation, so
|
|
257
|
+
// the conflict survives as one fixed diagnostic. The broker message itself is never
|
|
258
|
+
// rendered: it may carry request keys, paths, or credentials, and its cause is not
|
|
259
|
+
// decidable from the code alone.
|
|
260
|
+
const diagnostics: readonly PublicCommandDiagnosticCode[] | undefined =
|
|
261
|
+
outcome.error.code === "idempotency_conflict" ? ["lifecycle_idempotency_conflict"] : undefined;
|
|
245
262
|
return new PublicCommandFailure({
|
|
246
263
|
...failure.input,
|
|
264
|
+
...(diagnostics === undefined ? {} : { diagnostics }),
|
|
247
265
|
// Generic lifecycle codes cannot override the authoritative, conservative proof.
|
|
248
266
|
kind:
|
|
249
267
|
failure.input.kind === "usage" || failure.input.kind === "invalid_json"
|
|
@@ -2274,6 +2292,22 @@ function sessionCloseRequestKey(
|
|
|
2274
2292
|
return `${SDK_SESSION_CLI_LIFECYCLE_ACTOR.namespace}:session.close:${sessionId}:${authority.endpointGeneration}:${authority.endpointIncarnation}`;
|
|
2275
2293
|
}
|
|
2276
2294
|
|
|
2295
|
+
/**
|
|
2296
|
+
* The lookup route returns its structured outcome directly: it never passes through the
|
|
2297
|
+
* public error envelope (nor its 8192-byte budget), and `stripSecretFields` only drops
|
|
2298
|
+
* secret-shaped field *names*, not a broker-controlled `message` body. A conflict message
|
|
2299
|
+
* can carry request keys, paths, or credentials, so exactly that code carries the same
|
|
2300
|
+
* fixed, cause-neutral text as the public diagnostic. Every other code — including unknown,
|
|
2301
|
+
* near-miss, and prototype-shaped ones — is left undiagnosed and unchanged.
|
|
2302
|
+
*/
|
|
2303
|
+
function sanitizedLookupOutcome(outcome: SessionLifecycleLookupOutcome): SessionLifecycleLookupOutcome {
|
|
2304
|
+
if (outcome.ok || outcome.error.code !== "idempotency_conflict") return outcome;
|
|
2305
|
+
return {
|
|
2306
|
+
...outcome,
|
|
2307
|
+
error: { ...outcome.error, message: PUBLIC_COMMAND_DIAGNOSTICS.lifecycle_idempotency_conflict },
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2277
2311
|
async function runRawGlobal(
|
|
2278
2312
|
agentDir: string,
|
|
2279
2313
|
operation: string,
|
|
@@ -2307,13 +2341,15 @@ async function runRawGlobal(
|
|
|
2307
2341
|
if (!args.idempotencyKey)
|
|
2308
2342
|
throw new SdkSessionCliError("invalid_input", "--idempotency-key is required for session lookup.", 2);
|
|
2309
2343
|
const lifecycle = lifecycleOverride ?? createBrokerSessionLifecycleService(agentDir);
|
|
2310
|
-
return
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2344
|
+
return sanitizedLookupOutcome(
|
|
2345
|
+
await lifecycle.lookup({
|
|
2346
|
+
actor: SDK_SESSION_CLI_LIFECYCLE_ACTOR,
|
|
2347
|
+
capability: "session.lookup",
|
|
2348
|
+
operation: "session.create",
|
|
2349
|
+
requestKey: args.idempotencyKey,
|
|
2350
|
+
target: input,
|
|
2351
|
+
}),
|
|
2352
|
+
);
|
|
2317
2353
|
}
|
|
2318
2354
|
if (!isLifecycleOperation(operation))
|
|
2319
2355
|
throw new SdkSessionCliError("unknown_operation", `Unknown global operation: ${operation}`, 1);
|
|
@@ -2510,7 +2546,7 @@ export async function runSdkSessionCli(
|
|
|
2510
2546
|
const secretError = validateAdapterSecretFields(operation, input);
|
|
2511
2547
|
if (secretError) throw new SdkSessionCliError(secretError.code, secretError.message, 2);
|
|
2512
2548
|
if (kind === "global") {
|
|
2513
|
-
const result = await runRawGlobal(agentDir, operation, input, args);
|
|
2549
|
+
const result = await runRawGlobal(agentDir, operation, input, args, deps.lifecycleService);
|
|
2514
2550
|
writeOutput(stripSecretFields(result));
|
|
2515
2551
|
if (isRecord(result) && result.ok === false) setExitCode(1);
|
|
2516
2552
|
return;
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
DEFAULT_MODEL_SELECTION_RECOVERY_MESSAGE,
|
|
4
4
|
parseDefaultModelSelectionRecovery,
|
|
5
5
|
} from "../../../session/default-model-selection";
|
|
6
|
+
import { MODEL_NOT_SELECTED_CODE, MODEL_NOT_SELECTED_PUBLIC_MESSAGE } from "../../../setup/model-onboarding-guidance";
|
|
6
7
|
import { validateRequiredPromptText } from "../../protocol/adapter-validation";
|
|
7
8
|
import { OPERATIONS, type Operation } from "../../protocol/operation-registry";
|
|
8
9
|
import type { ControlInput, ControlSurface, ControlValue } from "./operations";
|
|
@@ -370,6 +371,11 @@ function errorResponse(id: string, row: Operation, error: unknown): ControlRespo
|
|
|
370
371
|
};
|
|
371
372
|
return failure(id, code, DEFAULT_MODEL_SELECTION_RECOVERY_MESSAGE, undefined, recovery);
|
|
372
373
|
}
|
|
374
|
+
// The missing-model preflight message carries local onboarding guidance
|
|
375
|
+
// (providers, commands, env vars, setup paths); publish the fixed safe text
|
|
376
|
+
// instead so the client still learns the actual, actionable state.
|
|
377
|
+
if (code === MODEL_NOT_SELECTED_CODE && row.errorCodes.includes(code))
|
|
378
|
+
return failure(id, code, MODEL_NOT_SELECTED_PUBLIC_MESSAGE);
|
|
373
379
|
if (code && (row.errorCodes.includes(code) || SHARED_ERROR_CODES.has(code)))
|
|
374
380
|
return failure(id, code, message, undefined, details);
|
|
375
381
|
if (code === "resource_gone" || /not found|gone/i.test(message)) return failure(id, "resource_gone", message);
|