@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/public-command-errors.d.ts +2 -0
  3. package/dist/types/commands/sdk.d.ts +7 -1
  4. package/dist/types/config/model-profile-ownership.d.ts +74 -0
  5. package/dist/types/config/model-registry.d.ts +2 -1
  6. package/dist/types/config/settings-schema.d.ts +32 -0
  7. package/dist/types/sdk/broker/lifecycle.d.ts +4 -1
  8. package/dist/types/sdk/lifecycle/service.d.ts +2 -0
  9. package/dist/types/sdk/lifecycle-session.d.ts +5 -1
  10. package/dist/types/sdk/session-directory.d.ts +1 -0
  11. package/dist/types/sdk/session-list.d.ts +11 -0
  12. package/dist/types/sdk/startup-capability.d.ts +1 -1
  13. package/dist/types/session/agent-session.d.ts +6 -0
  14. package/dist/types/session/internal/managed-session-scope.d.ts +4 -0
  15. package/dist/types/session/session-manager.d.ts +6 -0
  16. package/dist/types/setup/model-onboarding-guidance.d.ts +18 -0
  17. package/dist/types/task/delegation-hint.d.ts +53 -0
  18. package/examples/README.md +3 -3
  19. package/examples/custom-tools/README.md +1 -1
  20. package/examples/hooks/README.md +1 -1
  21. package/package.json +7 -7
  22. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  23. package/src/cli/config-cli.ts +2 -0
  24. package/src/cli/public-command-errors.ts +6 -0
  25. package/src/commands/sdk.ts +361 -117
  26. package/src/config/model-profile-ownership.ts +303 -0
  27. package/src/config/model-registry.ts +53 -14
  28. package/src/config/settings-schema.ts +81 -0
  29. package/src/internal-urls/docs-index.generated.ts +18 -17
  30. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  31. package/src/modes/controllers/selector-controller.ts +3 -29
  32. package/src/sdk/broker/broker.ts +120 -23
  33. package/src/sdk/broker/lifecycle.ts +89 -13
  34. package/src/sdk/cli/session-cli.ts +45 -9
  35. package/src/sdk/host/control/dispatch.ts +6 -0
  36. package/src/sdk/lifecycle/service.ts +186 -3
  37. package/src/sdk/lifecycle-session.ts +35 -8
  38. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  39. package/src/sdk/protocol/operation-registry.ts +5 -1
  40. package/src/sdk/session-directory.ts +1 -1
  41. package/src/sdk/session-list.ts +47 -0
  42. package/src/sdk/session.ts +290 -114
  43. package/src/sdk/startup-capability.ts +2 -1
  44. package/src/session/agent-session.ts +102 -4
  45. package/src/session/internal/managed-session-scope.ts +81 -17
  46. package/src/session/session-manager.ts +70 -11
  47. package/src/setup/model-onboarding-guidance.ts +26 -0
  48. 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
- "This removes the preset entry after preserving current role model settings when this preset is active/default.",
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 || defaultProfile === 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(
@@ -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
- const entry =
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 !== requestedFingerprint)
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
- const listed =
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
- const savedSession =
4636
- match &&
4637
- match.sessionId === resolveSessionId &&
4638
- match.identity.nlink !== undefined &&
4639
- match.identity.ctimeNs !== undefined
4640
- ? {
4641
- id: match.sessionId,
4642
- path: match.path,
4643
- identity: {
4644
- dev: match.identity.dev.toString(),
4645
- ino: match.identity.ino.toString(),
4646
- nlink: match.identity.nlink.toString(),
4647
- size: match.identity.size,
4648
- mtimeMs: match.identity.mtimeMs,
4649
- mtimeNs: match.identity.mtimeNs.toString(),
4650
- ctimeNs: match.identity.ctimeNs.toString(),
4651
- sha256: match.identity.sha256,
4652
- },
4653
- }
4654
- : undefined;
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(root: string, id: string, effectMarker: string): Promise<void> {
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 handle = await fs.open(
1769
- temporary,
1770
- fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY,
1771
- 0o600,
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 handle.writeFile(canonicalJson({ pid: process.pid, effectMarker, incarnation }));
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
- } finally {
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
- try {
1780
- await fs.rename(temporary, lifecycleReadyPath(root, id));
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 fs.rm(temporary, { force: true });
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 { PublicCommandFailure, type PublicEffectProof, type PublicFailureKind } from "../../cli/public-command-errors";
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 await lifecycle.lookup({
2311
- actor: SDK_SESSION_CLI_LIFECYCLE_ACTOR,
2312
- capability: "session.lookup",
2313
- operation: "session.create",
2314
- requestKey: args.idempotencyKey,
2315
- target: input,
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);