@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
@@ -400,7 +400,11 @@ import type { NotificationSessionController } from "../sdk/bus/session-control";
400
400
  import { buildSyntheticModelId, syntheticNamespaceCollision } from "../sdk/model-profile-model";
401
401
  import { sanitizePromptFailure } from "../sdk/prompt-failure";
402
402
  import type { SecretObfuscator } from "../secrets/obfuscator";
403
- import { formatNoCredentialOnboardingError, formatNoModelOnboardingError } from "../setup/model-onboarding-guidance";
403
+ import {
404
+ formatNoCredentialOnboardingError,
405
+ formatNoModelOnboardingError,
406
+ NoModelSelectedError,
407
+ } from "../setup/model-onboarding-guidance";
404
408
  import {
405
409
  isCanonicalGjcWorkflowSkill,
406
410
  isWorkflowContinuationInert,
@@ -410,6 +414,7 @@ import {
410
414
  import { assertWorkflowMutationAllowed } from "../skill-state/workflow-mutation-guard";
411
415
  import { invalidateHostMetadata } from "../ssh/connection-manager";
412
416
  import { buildVolatileProjectContext } from "../system-prompt";
417
+ import { DelegationHintController } from "../task/delegation-hint";
413
418
  import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
414
419
  import {
415
420
  buildDiscoverableToolSearchIndex,
@@ -3633,6 +3638,7 @@ export class AgentSession {
3633
3638
  /** Idempotent unregister handle for this session's resource-GC registration. */
3634
3639
  #unregisterResourceGc?: () => void;
3635
3640
  #unregisterRuntimeStateFinalizer?: () => void;
3641
+ #coordinatorRuntimeStateFileOverrideForTests: string | undefined = undefined;
3636
3642
  #unregisterBeforeMoveListener?: () => void;
3637
3643
  #unregisterMoveAbortListener?: () => void;
3638
3644
  #unregisterMovePublicationListener?: () => void;
@@ -3658,6 +3664,7 @@ export class AgentSession {
3658
3664
  );
3659
3665
  }
3660
3666
  #unregisterSessionMemorySettings?: () => void;
3667
+ #unregisterDelegationHintSettings?: () => void;
3661
3668
  /**
3662
3669
  * AsyncJobManager owned by this session (top-level only). Subagents leave
3663
3670
  * this undefined and **MUST NOT** dispose the global instance on teardown.
@@ -3830,6 +3837,7 @@ export class AgentSession {
3830
3837
 
3831
3838
  // TTSR manager for time-traveling stream rules
3832
3839
  #ttsrManager: TtsrManager | undefined = undefined;
3840
+ #delegationHint: DelegationHintController;
3833
3841
  #pendingTtsrInjections: Rule[] = [];
3834
3842
  /** Per-tool TTSR rules whose `interruptMode` opted out of aborting the stream.
3835
3843
  * These are folded into the matched tool call's `toolResult` content as an
@@ -4138,6 +4146,8 @@ export class AgentSession {
4138
4146
 
4139
4147
  #appendCoordinatorPersist(run: () => Promise<void>): Promise<void> {
4140
4148
  const queued = this.#coordinatorPersistQueue.then(run, run);
4149
+ const testFailures = this.#coordinatorPersistFailuresForTests;
4150
+ if (testFailures) void queued.catch(error => testFailures.push(error));
4141
4151
  this.#coordinatorPersistQueue = queued.catch(() => {});
4142
4152
  return queued;
4143
4153
  }
@@ -5646,10 +5656,58 @@ export class AgentSession {
5646
5656
  this.#credentialStoreIdentity = config.credentialStoreIdentity;
5647
5657
  this.#providerCacheSessionId = config.providerCacheSessionId;
5648
5658
  this.#asyncJobProviderSessionId = config.asyncJobProviderSessionId;
5659
+ this.#delegationHint = new DelegationHintController({
5660
+ getMode: () => this.settings.get("task.delegationHint.mode"),
5661
+ getAutoroutingEnabled: () => this.settings.getEffectiveAutorouting().active,
5662
+ getAutoroutingTiers: () => this.settings.get("task.autorouting.tiers"),
5663
+ notify: message => this.emitNotice("info", message, "delegation-hint"),
5664
+ });
5665
+ this.#unregisterDelegationHintSettings = this.settings.onChanged(settingPath => {
5666
+ if (settingPath === "task.delegationHint.mode") {
5667
+ this.#delegationHint.setEnabled(this.settings.get("task.delegationHint.mode") === "hint");
5668
+ }
5669
+ });
5649
5670
  // Per-tool TTSR reminders are folded into the matched tool's result via this hook.
5650
- this.agent.afterToolCall = ctx => {
5671
+ this.agent.afterToolCall = (ctx, signal) => {
5651
5672
  settleToolLineageRegistrationWindow(ctx.toolCall.id, this.#ownedRegistrationEndpoint());
5652
- return this.#ttsrAfterToolCall(ctx);
5673
+ const ttsrResult = this.#ttsrAfterToolCall(ctx);
5674
+ const delegationHintEnabled = this.settings.get("task.delegationHint.mode") === "hint";
5675
+ this.#delegationHint.setEnabled(delegationHintEnabled);
5676
+ if (delegationHintEnabled) {
5677
+ try {
5678
+ this.#delegationHint.observeAfterToolCall(
5679
+ {
5680
+ toolName: ctx.toolCall.name,
5681
+ args: ctx.args,
5682
+ isError: ctx.isError,
5683
+ resultError: ctx.result.isError === true,
5684
+ getRemainingContextRatio: () => {
5685
+ const percent = this.getContextUsage()?.percent;
5686
+ return typeof percent === "number" && Number.isFinite(percent)
5687
+ ? Math.min(1, Math.max(0, 1 - percent / 100))
5688
+ : undefined;
5689
+ },
5690
+ getActivePlanStepCount: () => {
5691
+ // Tasks within a phase are peers; do not combine separate ordered phases.
5692
+ return this.getTodoPhases().reduce(
5693
+ (largestPhase, phase) =>
5694
+ Math.max(
5695
+ largestPhase,
5696
+ phase.tasks.filter(
5697
+ task => task.status === "pending" || task.status === "in_progress",
5698
+ ).length,
5699
+ ),
5700
+ 0,
5701
+ );
5702
+ },
5703
+ },
5704
+ signal,
5705
+ );
5706
+ } catch {
5707
+ // Advisory inference never overrides an executed tool result.
5708
+ }
5709
+ }
5710
+ return ttsrResult;
5653
5711
  };
5654
5712
  // Bind immutable lineage/attempt metadata to each tool call id before the
5655
5713
  // tool executes. Background registrations made inside the tool (task, Bash)
@@ -6910,6 +6968,7 @@ export class AgentSession {
6910
6968
 
6911
6969
  /** Serializes sidecar writes in publication order, independent of write latency. */
6912
6970
  #coordinatorPersistQueue: Promise<void> = Promise.resolve();
6971
+ #coordinatorPersistFailuresForTests: unknown[] | undefined;
6913
6972
 
6914
6973
  #recordPostPublicationOutcome(
6915
6974
  context: CoordinatorRuntimeStatePersistContext,
@@ -6975,6 +7034,9 @@ export class AgentSession {
6975
7034
  */
6976
7035
  #runtimeStateMarkerFile(input: { sessionId: string; cwd: string }): string | null {
6977
7036
  if (!input.sessionId.trim()) return null;
7037
+ if (this.#coordinatorRuntimeStateFileOverrideForTests) {
7038
+ return this.#coordinatorRuntimeStateFileOverrideForTests;
7039
+ }
6978
7040
  const pinned = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
6979
7041
  if (this.taskDepth > 0) return sessionRuntimeStatePath(input.cwd, input.sessionId);
6980
7042
  return pinned || sessionRuntimeStatePath(input.cwd, input.sessionId);
@@ -7055,6 +7117,7 @@ export class AgentSession {
7055
7117
  { event: event.type, ...(attempt > 0 ? { retries: attempt } : {}) },
7056
7118
  );
7057
7119
  if (propagateFailure) throw error;
7120
+ this.#coordinatorPersistFailuresForTests?.push(error);
7058
7121
  return;
7059
7122
  }
7060
7123
  }
@@ -7080,11 +7143,18 @@ export class AgentSession {
7080
7143
  async #emitSessionEvent(event: AgentSessionEvent, eventLease?: RunResourceProducerLease): Promise<void> {
7081
7144
  const attemptScope = (event as AgentSessionEvent & { scope?: AttemptScope }).scope;
7082
7145
  if (event.type === "turn_start") {
7146
+ const delegationHintEnabled = this.settings.get("task.delegationHint.mode") === "hint";
7147
+ this.#delegationHint.setEnabled(delegationHintEnabled);
7148
+ if (delegationHintEnabled) this.#delegationHint.onTurnStart();
7083
7149
  this.#extensionTurnGeneration++;
7084
7150
  this.#closedExtensionTurnGeneration = undefined;
7085
7151
  } else if (event.type === "turn_end") {
7086
7152
  this.#closedExtensionTurnGeneration = this.#extensionTurnGeneration;
7087
7153
  }
7154
+ if (event.type === "message_end" && event.message.role === "user") {
7155
+ this.#delegationHint.setEnabled(this.settings.get("task.delegationHint.mode") === "hint");
7156
+ this.#delegationHint.onUserMessage();
7157
+ }
7088
7158
  if (event.type === "message_update") {
7089
7159
  // Fast path: message_update maps to no sidecar state, so we must not
7090
7160
  // build the persistRuntimeState closure here (per-token hot path).
@@ -10152,6 +10222,20 @@ export class AgentSession {
10152
10222
  this.#disposeTimeoutMs = Math.max(0, timeoutMs);
10153
10223
  }
10154
10224
 
10225
+ /** Bind coordinator runtime-state persistence to this session in tests. */
10226
+ setCoordinatorRuntimeStateFileForTests(stateFile: string): void {
10227
+ if (!path.isAbsolute(stateFile)) {
10228
+ throw new Error("Coordinator runtime-state test path must be absolute.");
10229
+ }
10230
+ this.#coordinatorRuntimeStateFileOverrideForTests = path.resolve(stateFile);
10231
+ this.#registerRuntimeStateFinalizer();
10232
+ }
10233
+
10234
+ /** Read the test-only coordinator runtime-state path bound to this session. */
10235
+ getCoordinatorRuntimeStateFileForTests(): string | undefined {
10236
+ return this.#coordinatorRuntimeStateFileOverrideForTests;
10237
+ }
10238
+
10155
10239
  trackPostPromptTaskForTests(task: Promise<void>): void {
10156
10240
  this.#trackPostPromptTask(task, this.#selectionFenceGeneration);
10157
10241
  }
@@ -10332,6 +10416,8 @@ export class AgentSession {
10332
10416
  this.#unregisterResourceGc = undefined;
10333
10417
  this.#unregisterSessionMemorySettings?.();
10334
10418
  this.#unregisterSessionMemorySettings = undefined;
10419
+ this.#unregisterDelegationHintSettings?.();
10420
+ this.#unregisterDelegationHintSettings = undefined;
10335
10421
  if (ownerTerminalContextFromEnvironment() === null) this.#unregisterRuntimeStateFinalizer?.();
10336
10422
  this.#unregisterRuntimeStateFinalizer = undefined;
10337
10423
  this.#unregisterBeforeMoveListener?.();
@@ -10443,6 +10529,8 @@ export class AgentSession {
10443
10529
  this.#unregisterResourceGc = undefined;
10444
10530
  this.#unregisterSessionMemorySettings?.();
10445
10531
  this.#unregisterSessionMemorySettings = undefined;
10532
+ this.#unregisterDelegationHintSettings?.();
10533
+ this.#unregisterDelegationHintSettings = undefined;
10446
10534
  const work = Promise.allSettled([
10447
10535
  // kill:true so a forced exit also reaps spawned-app Chrome we own (headless
10448
10536
  // always closes; connected/attached browsers only disconnect — never killed).
@@ -10594,6 +10682,11 @@ export class AgentSession {
10594
10682
  });
10595
10683
  }
10596
10684
 
10685
+ /** Enable sidecar write failure reporting for retry-test teardown. */
10686
+ trackCoordinatorRuntimeStatePersistenceFailuresForTests(): void {
10687
+ this.#coordinatorPersistFailuresForTests = [];
10688
+ }
10689
+
10597
10690
  /** Test seam: await all currently admitted coordinator sidecar writes. */
10598
10691
  async awaitCoordinatorRuntimeStatePersistenceForTests(): Promise<void> {
10599
10692
  // A terminal abort can schedule a preserved follow-up as a fresh turn after
@@ -10616,6 +10709,11 @@ export class AgentSession {
10616
10709
  }
10617
10710
  await this.#coordinatorPersistQueue;
10618
10711
  await this.#drainUnbarrieredCoordinatorPersists();
10712
+ const failures = this.#coordinatorPersistFailuresForTests;
10713
+ if (failures && failures.length > 0) {
10714
+ this.#coordinatorPersistFailuresForTests = [];
10715
+ throw new AggregateError(failures, "Coordinator runtime-state persistence failed during test");
10716
+ }
10619
10717
  }
10620
10718
  queueCoordinatorRuntimeStatePersistForTests(event: AgentSessionEvent, gate: Promise<void>): Promise<void> {
10621
10719
  this.#agentEventAdmission.set(event, {
@@ -13782,7 +13880,7 @@ export class AgentSession {
13782
13880
 
13783
13881
  // Validate model
13784
13882
  if (!this.model) {
13785
- throw new Error(formatNoModelOnboardingError());
13883
+ throw new NoModelSelectedError();
13786
13884
  }
13787
13885
 
13788
13886
  // Validate API key
@@ -303,7 +303,7 @@ export type ManagedCandidateListing =
303
303
  scope: ManagedScope;
304
304
  owned: readonly ManagedCandidate[];
305
305
  foreignCount: number;
306
- invalid: readonly { code: string }[];
306
+ invalid: readonly { code: string; sessionId?: string }[];
307
307
  }
308
308
  | { kind: "error"; code: "scan_failed" | "unsafe_root" | "invalid_candidate"; message: string };
309
309
 
@@ -347,6 +347,7 @@ export interface ManagedLockReleaseTestEvent {
347
347
  export const ManagedSessionScopeTestHooks: {
348
348
  beforeVerifiedDelete?: (event: ManagedVerifiedDeleteTestEvent) => void | Promise<void>;
349
349
  beforeManagedLockRelease?: (event: ManagedLockReleaseTestEvent) => void | Promise<void>;
350
+ afterCandidatePreflight?: (event: { readonly sessionId?: string }) => void;
350
351
  } = {};
351
352
 
352
353
  async function deleteSessionVerifiedWithFence(
@@ -801,49 +802,86 @@ export function fsyncCanonicalBinding(bindingPath: string, expected: string): vo
801
802
  }
802
803
 
803
804
  type CandidatePreflight =
804
- | { kind: "capture"; identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint } }
805
+ | {
806
+ kind: "capture";
807
+ sessionId: string;
808
+ cwd: string;
809
+ diagnosticSessionId?: string;
810
+ identity: Pick<ManagedFileSnapshot["identity"], "dev" | "ino" | "nlink" | "size" | "mtimeNs" | "ctimeNs">;
811
+ }
805
812
  | {
806
813
  kind: "foreign";
807
814
  }
808
815
  | {
809
816
  kind: "invalid";
810
817
  code: string;
818
+ diagnosticSessionId?: string;
811
819
  };
812
820
 
821
+ function safeManagedCandidateSessionId(value: unknown): string | undefined {
822
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ? value : undefined;
823
+ }
824
+
825
+ function invalidCandidatePreflight(filePath: string, code: string): CandidatePreflight {
826
+ const diagnosticSessionId = safeManagedCandidateSessionId(path.basename(filePath, ".jsonl"));
827
+ return {
828
+ kind: "invalid",
829
+ code,
830
+ ...(diagnosticSessionId === undefined ? {} : { diagnosticSessionId }),
831
+ };
832
+ }
833
+
813
834
  function preflightCandidate(filePath: string, scope: ManagedScope): CandidatePreflight {
814
835
  try {
815
836
  const snapshot = captureManagedFilePrefixNoFollow(filePath, HEADER_MAX_BYTES);
816
837
  const lineEnd = snapshot.bytes.indexOf(0x0a);
817
- if (lineEnd < 0) return { kind: "invalid", code: "invalid_header" };
838
+ if (lineEnd < 0) return invalidCandidatePreflight(filePath, "invalid_header");
818
839
  const value: unknown = JSON.parse(snapshot.bytes.subarray(0, lineEnd).toString("utf8"));
819
840
  if (!value || typeof value !== "object" || Array.isArray(value))
820
- return { kind: "invalid", code: "invalid_header" };
841
+ return invalidCandidatePreflight(filePath, "invalid_header");
821
842
  const header = value as Record<string, unknown>;
822
843
  if (header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string")
823
- return { kind: "invalid", code: "invalid_header" };
844
+ return invalidCandidatePreflight(filePath, "invalid_header");
845
+ const diagnosticSessionId = safeManagedCandidateSessionId(header.id);
824
846
  const candidateIdentity = identityFor(header.cwd);
825
847
  if (
826
848
  candidateIdentity.ok &&
827
849
  (candidateIdentity.platform !== scope.platform || candidateIdentity.canonicalPath !== scope.canonicalCwd)
828
850
  )
829
851
  return { kind: "foreign" };
830
- return { kind: "capture", identity: snapshot.identity };
831
- } catch {
832
- return { kind: "invalid", code: "unreadable_candidate" };
852
+ return {
853
+ kind: "capture",
854
+ sessionId: header.id,
855
+ cwd: header.cwd,
856
+ ...(diagnosticSessionId === undefined ? {} : { diagnosticSessionId }),
857
+ identity: snapshot.identity,
858
+ };
859
+ } catch (error) {
860
+ return invalidCandidatePreflight(
861
+ filePath,
862
+ error instanceof Error && error.message === "source_changed" ? "source_changed" : "unreadable_candidate",
863
+ );
833
864
  }
834
865
  }
835
866
 
836
867
  function matchesPreflightIdentity(candidate: ManagedCandidate, preflight: CandidatePreflight): boolean {
837
868
  return (
838
869
  preflight.kind === "capture" &&
870
+ candidate.sessionId === preflight.sessionId &&
871
+ candidate.cwd === preflight.cwd &&
839
872
  candidate.identity.dev === preflight.identity.dev &&
840
873
  candidate.identity.ino === preflight.identity.ino &&
874
+ candidate.identity.nlink === preflight.identity.nlink &&
841
875
  candidate.identity.size === preflight.identity.size &&
842
- candidate.identity.mtimeNs === preflight.identity.mtimeNs
876
+ candidate.identity.mtimeNs === preflight.identity.mtimeNs &&
877
+ candidate.identity.ctimeNs === preflight.identity.ctimeNs
843
878
  );
844
879
  }
845
880
 
846
- function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): ManagedCandidate | { code: string } {
881
+ function inspectCandidate(
882
+ filePath: string,
883
+ provenance: "v2" | "legacy",
884
+ ): ManagedCandidate | { code: string; sessionId?: string } {
847
885
  try {
848
886
  const snapshot = inspectManagedFileNoFollow(filePath, HEADER_MAX_BYTES);
849
887
  const lineEnd = snapshot.bytes.subarray(0, HEADER_MAX_BYTES).indexOf(0x0a);
@@ -905,7 +943,7 @@ function discoveredLegacyDirectoryNames(scope: ManagedScope): readonly string[]
905
943
  return [...known, ...discovered];
906
944
  }
907
945
 
908
- type CandidateInspection = ManagedCandidate | { code: string } | { foreign: true };
946
+ type CandidateInspection = ManagedCandidate | { code: string; sessionId?: string } | { foreign: true };
909
947
 
910
948
  function listDirectoryCandidates(
911
949
  directory: string,
@@ -926,11 +964,29 @@ function listDirectoryCandidates(
926
964
  .map(entry => {
927
965
  const filePath = path.join(directory, entry.name);
928
966
  const preflight = preflightCandidate(filePath, scope);
929
- if (preflight.kind === "invalid") return { code: preflight.code };
967
+ if (preflight.kind === "invalid")
968
+ return {
969
+ code: preflight.code,
970
+ ...(preflight.diagnosticSessionId === undefined ? {} : { sessionId: preflight.diagnosticSessionId }),
971
+ };
930
972
  if (preflight.kind === "foreign") return { foreign: true };
973
+ ManagedSessionScopeTestHooks.afterCandidatePreflight?.({
974
+ ...(preflight.diagnosticSessionId === undefined ? {} : { sessionId: preflight.diagnosticSessionId }),
975
+ });
931
976
  const candidate = inspectCandidate(filePath, provenance);
932
- if ("code" in candidate) return candidate;
933
- return matchesPreflightIdentity(candidate, preflight) ? candidate : { code: "source_changed" };
977
+ if ("code" in candidate)
978
+ return {
979
+ ...candidate,
980
+ ...(candidate.sessionId === undefined && preflight.diagnosticSessionId !== undefined
981
+ ? { sessionId: preflight.diagnosticSessionId }
982
+ : {}),
983
+ };
984
+ return matchesPreflightIdentity(candidate, preflight)
985
+ ? candidate
986
+ : {
987
+ code: "source_changed",
988
+ ...(preflight.diagnosticSessionId === undefined ? {} : { sessionId: preflight.diagnosticSessionId }),
989
+ };
934
990
  });
935
991
  }
936
992
 
@@ -1645,7 +1701,7 @@ export function listManagedCandidates(scope: ManagedScope): ManagedCandidateList
1645
1701
  if (!root.isDirectory() || root.isSymbolicLink())
1646
1702
  return { kind: "error", code: "unsafe_root", message: "The sessions root is unsafe." };
1647
1703
  const owned: ManagedCandidate[] = [];
1648
- const invalid: { code: string }[] = [];
1704
+ const invalid: { code: string; sessionId?: string }[] = [];
1649
1705
  let foreignCount = 0;
1650
1706
  const directories: Array<{ path: string; provenance: "v2" | "legacy" }> = [
1651
1707
  { path: scope.directoryPath, provenance: "v2" },
@@ -1658,7 +1714,10 @@ export function listManagedCandidates(scope: ManagedScope): ManagedCandidateList
1658
1714
  for (const directory of directories) {
1659
1715
  for (const candidate of listDirectoryCandidates(directory.path, directory.provenance, scope)) {
1660
1716
  if ("code" in candidate) {
1661
- invalid.push({ code: candidate.code });
1717
+ invalid.push({
1718
+ code: candidate.code,
1719
+ ...(candidate.sessionId === undefined ? {} : { sessionId: candidate.sessionId }),
1720
+ });
1662
1721
  continue;
1663
1722
  }
1664
1723
  if ("foreign" in candidate) {
@@ -1667,7 +1726,12 @@ export function listManagedCandidates(scope: ManagedScope): ManagedCandidateList
1667
1726
  }
1668
1727
  const candidateIdentity = identityFor(candidate.cwd);
1669
1728
  if (!candidateIdentity.ok) {
1670
- invalid.push({ code: `cwd_${candidateIdentity.code}` });
1729
+ invalid.push({
1730
+ code: `cwd_${candidateIdentity.code}`,
1731
+ ...(safeManagedCandidateSessionId(candidate.sessionId) === undefined
1732
+ ? {}
1733
+ : { sessionId: candidate.sessionId }),
1734
+ });
1671
1735
  continue;
1672
1736
  }
1673
1737
  if (
@@ -61,6 +61,12 @@ import {
61
61
  Snowflake,
62
62
  toError,
63
63
  } from "@gajae-code/utils";
64
+ import {
65
+ InvalidModelProfileOwnershipError,
66
+ MODEL_PROFILE_OWNERSHIP_ENTRY,
67
+ type ModelProfileOwnershipMarker,
68
+ validateModelProfileOwnershipMarker,
69
+ } from "../config/model-profile-ownership";
64
70
  import { EDIT_SNAPSHOT_EXTERNALIZED_NOTICE, editSnapshotReceipt } from "../edit/renderer";
65
71
  import type { TtsrInjectionRecord } from "../export/ttsr";
66
72
  import { assertSafePathComponent } from "../gjc-runtime/session-layout";
@@ -1195,20 +1201,25 @@ function isValidPersistedReducerState(value: ReducerState): boolean {
1195
1201
  }
1196
1202
 
1197
1203
  function isProviderStateEntry(entry: SessionEntry): boolean {
1198
- return [
1199
- "thinking_level_change",
1200
- "model_change",
1201
- "configured_model_chain",
1202
- "service_tier_change",
1203
- "mcp_tool_selection",
1204
- "discovered_builtin_tool_selection",
1205
- "mode_change",
1206
- "ttsr_injection",
1207
- ].includes(entry.type);
1204
+ return (
1205
+ (entry.type === "custom" && entry.customType === MODEL_PROFILE_OWNERSHIP_ENTRY) ||
1206
+ [
1207
+ "thinking_level_change",
1208
+ "model_change",
1209
+ "configured_model_chain",
1210
+ "service_tier_change",
1211
+ "mcp_tool_selection",
1212
+ "discovered_builtin_tool_selection",
1213
+ "mode_change",
1214
+ "ttsr_injection",
1215
+ ].includes(entry.type)
1216
+ );
1208
1217
  }
1209
1218
 
1210
1219
  function providerStateEntryKey(entry: SessionEntry): string | undefined {
1211
1220
  if (!isProviderStateEntry(entry)) return undefined;
1221
+ if (entry.type === "custom" && entry.customType === MODEL_PROFILE_OWNERSHIP_ENTRY)
1222
+ return `${entry.type}:${entry.customType}`;
1212
1223
  return entry.type === "configured_model_chain" ? `${entry.type}:${entry.role}` : entry.type;
1213
1224
  }
1214
1225
  /** Map a session entry to its rolling-tail record kind. */
@@ -2091,6 +2102,8 @@ export interface SessionContext {
2091
2102
  models: Record<string, string>;
2092
2103
  /** Configured fallback chains for model roles on the active branch. */
2093
2104
  configuredModelChains: Record<string, ConfiguredModelChain>;
2105
+ /** Effective-branch profile owner decision; absent means the legacy inherit marker. */
2106
+ modelProfileOwnershipMarker?: ModelProfileOwnershipMarker;
2094
2107
 
2095
2108
  /** Names of TTSR rules that have been injected this session */
2096
2109
  injectedTtsrRules: string[];
@@ -2986,6 +2999,7 @@ export function buildSessionContext(
2986
2999
  let serviceTier: ServiceTier | undefined;
2987
3000
  const models: Record<string, string> = {};
2988
3001
  const configuredModelChains: Record<string, ConfiguredModelChain> = {};
3002
+ let modelProfileOwnershipMarker: ModelProfileOwnershipMarker | undefined;
2989
3003
 
2990
3004
  let compaction: CompactionEntry | null = null;
2991
3005
  const injectedTtsrRulesSet = new Set<string>();
@@ -3030,6 +3044,10 @@ export function buildSessionContext(
3030
3044
  } else if (configuredChain) {
3031
3045
  configuredModelChains[configuredChain.role] = configuredChain;
3032
3046
  }
3047
+ } else if (entry.type === "custom" && entry.customType === MODEL_PROFILE_OWNERSHIP_ENTRY) {
3048
+ const validated = validateModelProfileOwnershipMarker(entry.data);
3049
+ if (!validated) throw new InvalidModelProfileOwnershipError();
3050
+ modelProfileOwnershipMarker = validated;
3033
3051
  } else if (entry.type === "service_tier_change") {
3034
3052
  serviceTier = entry.serviceTier ?? undefined;
3035
3053
  } else if (entry.type === "message" && entry.message.role === "assistant") {
@@ -3185,6 +3203,7 @@ export function buildSessionContext(
3185
3203
  serviceTier,
3186
3204
  models,
3187
3205
  configuredModelChains,
3206
+ ...(modelProfileOwnershipMarker ? { modelProfileOwnershipMarker } : {}),
3188
3207
 
3189
3208
  injectedTtsrRules,
3190
3209
  injectedTtsrRuleRecords: injectedTtsrRuleRecordsArray,
@@ -3206,6 +3225,9 @@ function cloneSessionContext(context: SessionContext): SessionContext {
3206
3225
  ...context,
3207
3226
  messages,
3208
3227
  models: { ...context.models },
3228
+ modelProfileOwnershipMarker: context.modelProfileOwnershipMarker
3229
+ ? { ...context.modelProfileOwnershipMarker }
3230
+ : undefined,
3209
3231
  configuredModelChains: Object.fromEntries(
3210
3232
  Object.entries(context.configuredModelChains ?? {}).map(([role, chain]) => [
3211
3233
  role,
@@ -6063,7 +6085,6 @@ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?:
6063
6085
  changed = true;
6064
6086
  return [];
6065
6087
  }
6066
-
6067
6088
  return [
6068
6089
  (async () => {
6069
6090
  if (
@@ -18385,6 +18406,42 @@ export class SessionManager {
18385
18406
  return entry.id;
18386
18407
  }
18387
18408
 
18409
+ appendPreparedModelProfileOwnershipMarker(
18410
+ prepared: PreparedNewSession,
18411
+ marker: ModelProfileOwnershipMarker,
18412
+ ): string {
18413
+ const validated = validateModelProfileOwnershipMarker(marker);
18414
+ if (!validated) throw new InvalidModelProfileOwnershipError();
18415
+ const stage = this.#getPreparedNewSessionStage(prepared);
18416
+ const entry: CustomEntry<ModelProfileOwnershipMarker> = {
18417
+ type: "custom",
18418
+ customType: MODEL_PROFILE_OWNERSHIP_ENTRY,
18419
+ data: validated,
18420
+ id: this.#nextPreparedNewSessionEntryId(stage),
18421
+ parentId: this.#preparedNewSessionLeafId(stage),
18422
+ timestamp: new Date().toISOString(),
18423
+ };
18424
+ stage.fileEntries.push(entry);
18425
+ return entry.id;
18426
+ }
18427
+
18428
+ appendModelProfileOwnershipMarker(marker: ModelProfileOwnershipMarker): string {
18429
+ const validated = validateModelProfileOwnershipMarker(marker);
18430
+ if (!validated) throw new InvalidModelProfileOwnershipError();
18431
+ return this.appendCustomEntry(MODEL_PROFILE_OWNERSHIP_ENTRY, validated);
18432
+ }
18433
+
18434
+ getModelProfileOwnershipMarker(): ModelProfileOwnershipMarker | undefined {
18435
+ let marker: ModelProfileOwnershipMarker | undefined;
18436
+ for (const entry of this.getBranch()) {
18437
+ if (entry.type !== "custom" || entry.customType !== MODEL_PROFILE_OWNERSHIP_ENTRY) continue;
18438
+ const validated = validateModelProfileOwnershipMarker(entry.data);
18439
+ if (!validated) throw new InvalidModelProfileOwnershipError();
18440
+ marker = validated;
18441
+ }
18442
+ return marker;
18443
+ }
18444
+
18388
18445
  /**
18389
18446
  * Append a root marker that starts a fresh active branch without changing the
18390
18447
  * session id or deleting earlier durable entries. Subsequent messages descend
@@ -18392,6 +18449,7 @@ export class SessionManager {
18392
18449
  * available for diagnostics/export.
18393
18450
  */
18394
18451
  appendContextClearEntry(data?: Record<string, unknown>): string {
18452
+ const ownershipMarker = this.getModelProfileOwnershipMarker();
18395
18453
  const entry: CustomEntry = {
18396
18454
  type: "custom",
18397
18455
  customType: "context_clear",
@@ -18401,6 +18459,7 @@ export class SessionManager {
18401
18459
  timestamp: new Date().toISOString(),
18402
18460
  };
18403
18461
  this.#appendEntry(entry);
18462
+ if (ownershipMarker) this.appendModelProfileOwnershipMarker(ownershipMarker);
18404
18463
  return entry.id;
18405
18464
  }
18406
18465
 
@@ -27,6 +27,32 @@ export function formatNoModelOnboardingError(): string {
27
27
  return `No model selected.\n\n${formatModelOnboardingGuidance()}`;
28
28
  }
29
29
 
30
+ /** Control-protocol code for a prompt refused because the session has no model. */
31
+ export const MODEL_NOT_SELECTED_CODE = "model_not_selected";
32
+
33
+ /**
34
+ * Fixed public text for {@link MODEL_NOT_SELECTED_CODE}. The onboarding
35
+ * guidance names providers, commands, environment variables and local setup
36
+ * paths, so it stays local-only; external clients get this constant instead.
37
+ */
38
+ export const MODEL_NOT_SELECTED_PUBLIC_MESSAGE =
39
+ "No model is selected for this session. Select a model before submitting a prompt.";
40
+
41
+ /**
42
+ * Missing-model prompt preflight failure. The message keeps the full local
43
+ * onboarding guidance for the TUI and in-process callers; the `code` is what
44
+ * lets the SDK control surface answer with a safe, known diagnostic instead of
45
+ * a generic internal error.
46
+ */
47
+ export class NoModelSelectedError extends Error {
48
+ readonly code = MODEL_NOT_SELECTED_CODE;
49
+
50
+ constructor() {
51
+ super(formatNoModelOnboardingError());
52
+ this.name = "NoModelSelectedError";
53
+ }
54
+ }
55
+
30
56
  export function formatNoCredentialOnboardingError(providerId: string): string {
31
57
  const lines = [
32
58
  `No credentials found for ${providerId}.`,