@gajae-code/coding-agent 0.17.4 → 0.17.6

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 (86) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/types/config/autorouting-tier-map.d.ts +1031 -3
  3. package/dist/types/config/file-lock.d.ts +4 -2
  4. package/dist/types/config/model-registry.d.ts +3 -1
  5. package/dist/types/config/model-resolver.d.ts +1 -1
  6. package/dist/types/exec/bash-executor.d.ts +2 -0
  7. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
  8. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  9. package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
  10. package/dist/types/harness-control-plane/finalize.d.ts +14 -3
  11. package/dist/types/main.d.ts +1 -1
  12. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  13. package/dist/types/runtime-mcp/manager.d.ts +13 -6
  14. package/dist/types/sdk/broker/broker.d.ts +4 -2
  15. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  16. package/dist/types/sdk/broker/identity.d.ts +2 -0
  17. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +11 -3
  18. package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
  19. package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
  20. package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
  21. package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
  22. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
  23. package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
  24. package/dist/types/sdk/session.d.ts +3 -3
  25. package/dist/types/session/agent-session.d.ts +7 -2
  26. package/dist/types/task/types.d.ts +2 -0
  27. package/dist/types/tools/bash-interactive.d.ts +1 -0
  28. package/dist/types/tools/fetch.d.ts +10 -1
  29. package/dist/types/tools/subagent.d.ts +8 -1
  30. package/package.json +7 -7
  31. package/src/cli/public-command-registry.ts +8 -2
  32. package/src/cli/setup-cli.ts +1 -0
  33. package/src/cli/stats-cli.ts +11 -1
  34. package/src/config/autorouting-tier-map.ts +529 -3
  35. package/src/config/file-lock.ts +59 -14
  36. package/src/config/model-discovery-manager.ts +4 -4
  37. package/src/config/model-profile-activation.ts +83 -23
  38. package/src/config/model-registry.ts +72 -12
  39. package/src/config/model-resolver.ts +6 -1
  40. package/src/coordinator-mcp/question-state.ts +4 -1
  41. package/src/coordinator-mcp/server.ts +14 -6
  42. package/src/exec/bash-executor.ts +3 -0
  43. package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
  44. package/src/gjc-runtime/state-writer.ts +317 -6
  45. package/src/harness-control-plane/control-endpoint.ts +24 -6
  46. package/src/harness-control-plane/finalize.ts +312 -25
  47. package/src/harness-control-plane/owner.ts +323 -89
  48. package/src/internal-urls/docs-index.generated.ts +6 -4
  49. package/src/main.ts +69 -19
  50. package/src/modes/acp/acp-agent.ts +581 -37
  51. package/src/modes/components/gjc-bundle-settings.ts +21 -1
  52. package/src/modes/components/tool-status-header.ts +72 -6
  53. package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
  54. package/src/runtime-mcp/loader.ts +6 -3
  55. package/src/runtime-mcp/manager.ts +177 -75
  56. package/src/sdk/broker/broker.ts +1058 -150
  57. package/src/sdk/broker/ensure.ts +65 -8
  58. package/src/sdk/broker/identity.ts +13 -0
  59. package/src/sdk/broker/lifecycle-ledger.ts +16 -4
  60. package/src/sdk/broker/managed-task-dag.ts +1927 -0
  61. package/src/sdk/broker/managed-task-verification.ts +642 -0
  62. package/src/sdk/broker/spawn-authority.ts +140 -7
  63. package/src/sdk/broker/startup-failure.ts +80 -1
  64. package/src/sdk/broker/transport.ts +1 -0
  65. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  66. package/src/sdk/bus/index.ts +22 -6
  67. package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
  68. package/src/sdk/cli/session-cli.ts +18 -0
  69. package/src/sdk/host/session-runtime.test.ts +46 -0
  70. package/src/sdk/host/session-runtime.ts +13 -6
  71. package/src/sdk/lifecycle/service.ts +2 -0
  72. package/src/sdk/prompt-deadline-flush.ts +26 -4
  73. package/src/sdk/session.ts +587 -115
  74. package/src/session/agent-session.ts +60 -14
  75. package/src/session/internal/managed-session-scope.ts +10 -0
  76. package/src/session/internal/managed-session-storage.ts +30 -1
  77. package/src/setup/provider-presets.json +2 -1
  78. package/src/slash-commands/builtin-registry.ts +1 -1
  79. package/src/task/executor.ts +46 -17
  80. package/src/task/types.ts +2 -0
  81. package/src/tools/bash-interactive.ts +2 -0
  82. package/src/tools/bash.ts +20 -0
  83. package/src/tools/fetch.ts +21 -5
  84. package/src/tools/subagent-render.ts +24 -1
  85. package/src/tools/subagent.ts +27 -1
  86. package/src/utils/mupdf.ts +11 -4
@@ -170,7 +170,8 @@ export type SpawnClaimDecision =
170
170
  | { kind: "replay"; claim: SpawnClaimV2 }
171
171
  | { kind: "terminal"; claim: SpawnClaimV2 }
172
172
  | { kind: "terminal_uncertain"; claim: SpawnClaimV2 }
173
- | { kind: "idempotency_conflict"; claim: SpawnClaimV2 };
173
+ | { kind: "idempotency_conflict"; claim: SpawnClaimV2 }
174
+ | { kind: "managed_key_conflict" };
174
175
 
175
176
  type StoredClaim = {
176
177
  version: 1;
@@ -179,6 +180,15 @@ type StoredClaim = {
179
180
  integrity: string;
180
181
  };
181
182
 
183
+ type ManagedKeyReservation = {
184
+ version: 1;
185
+ kind: "managed_key_reservation";
186
+ aliasIdentities: string[];
187
+ managedIdentity: string;
188
+ controlRoot: string;
189
+ integrity: string;
190
+ };
191
+
182
192
  type SpawnAuthorityDurableWrite = {
183
193
  claim: SpawnClaimV2;
184
194
  authority?: SpawnAuthorityV1;
@@ -560,6 +570,8 @@ export class SpawnAuthorityStore {
560
570
  readonly #options: SpawnAuthorityStoreOptions;
561
571
  readonly #latest = new Map<string, SpawnClaimV2>();
562
572
  readonly #authorities = new Map<string, SpawnAuthorityV1>();
573
+ readonly #managedKeyAliases = new Map<string, Map<string, string>>();
574
+ readonly #managedIdentityBindings = new Map<string, { aliasIdentities: string[]; controlRoot: string }>();
563
575
  readonly #active = new Set<string>();
564
576
  #tail: Promise<void> = Promise.resolve();
565
577
 
@@ -592,6 +604,8 @@ export class SpawnAuthorityStore {
592
604
  await fs.mkdir(path.dirname(this.#file), { recursive: true, mode: 0o700 });
593
605
  this.#latest.clear();
594
606
  this.#authorities.clear();
607
+ this.#managedKeyAliases.clear();
608
+ this.#managedIdentityBindings.clear();
595
609
  let source = "";
596
610
  try {
597
611
  source = await Bun.file(this.#file).text();
@@ -606,6 +620,12 @@ export class SpawnAuthorityStore {
606
620
  } catch {
607
621
  throw new Error("Spawn authority journal contains malformed durable evidence.");
608
622
  }
623
+ if (this.#isManagedKeyReservation(row)) {
624
+ const reservation = row as ManagedKeyReservation;
625
+ if (!this.#rememberManagedKeyReservation(reservation))
626
+ throw new Error("Spawn authority journal managed key history is invalid.");
627
+ continue;
628
+ }
609
629
  if (!this.#isStoredClaim(row))
610
630
  throw new Error("Spawn authority journal contains invalid durable evidence.");
611
631
  const stored = row as StoredClaim;
@@ -616,6 +636,52 @@ export class SpawnAuthorityStore {
616
636
  if (stored.authority) this.#authorities.set(stored.claim.lifecycleIdentity, stored.authority);
617
637
  else this.#authorities.delete(stored.claim.lifecycleIdentity);
618
638
  }
639
+ for (const alias of this.#managedKeyAliases.keys())
640
+ if (this.#latest.has(alias)) throw new Error("Spawn authority journal aliases an ordinary claim.");
641
+ });
642
+ }
643
+
644
+ /**
645
+ * Durably reserves an ordinary session.spawn idempotency identity for one
646
+ * managed native identity. Multiple managed roots may share the alias, but an
647
+ * ordinary claim and a managed reservation can never both win.
648
+ */
649
+ async reserveManagedKeyAliases(
650
+ aliasIdentities: readonly string[],
651
+ managedIdentity: string,
652
+ controlRoot: string,
653
+ ): Promise<boolean> {
654
+ const aliases = [...new Set(aliasIdentities)].sort();
655
+ if (
656
+ aliases.length === 0 ||
657
+ aliases.some(identity => !/^[0-9a-f]{64}$/i.test(identity)) ||
658
+ !/^[0-9a-f]{64}$/i.test(managedIdentity) ||
659
+ !path.isAbsolute(controlRoot) ||
660
+ path.normalize(controlRoot) !== controlRoot
661
+ )
662
+ throw new Error("Invalid managed spawn key reservation.");
663
+ return await this.#serial(async () => {
664
+ if (aliases.some(alias => this.#latest.has(alias))) return false;
665
+ const prior = this.#managedIdentityBindings.get(managedIdentity);
666
+ if (prior)
667
+ return prior.controlRoot === controlRoot && canonicalJson(prior.aliasIdentities) === canonicalJson(aliases);
668
+ for (const alias of aliases) {
669
+ const roots = this.#managedKeyAliases.get(alias);
670
+ const existingRoot = roots?.get(managedIdentity);
671
+ if (existingRoot !== undefined && existingRoot !== controlRoot) return false;
672
+ }
673
+ const reservation: ManagedKeyReservation = {
674
+ version: 1,
675
+ kind: "managed_key_reservation",
676
+ aliasIdentities: aliases,
677
+ managedIdentity,
678
+ controlRoot,
679
+ integrity: "",
680
+ };
681
+ reservation.integrity = this.#managedKeyReservationIntegrity(reservation);
682
+ await this.#appendRow(reservation);
683
+ this.#rememberManagedKeyReservation(reservation);
684
+ return true;
619
685
  });
620
686
  }
621
687
 
@@ -632,6 +698,7 @@ export class SpawnAuthorityStore {
632
698
  )
633
699
  throw new Error("Invalid opaque spawn claim key.");
634
700
  return await this.#serial(async () => {
701
+ if (this.#managedKeyAliases.has(lifecycleIdentity)) return { kind: "managed_key_conflict" };
635
702
  const current = this.#latest.get(lifecycleIdentity);
636
703
  if (!current) {
637
704
  if (bindingMac === undefined) throw new Error("A canonical spawn binding is required for a new claim.");
@@ -964,6 +1031,68 @@ export class SpawnAuthorityStore {
964
1031
  .digest("hex");
965
1032
  }
966
1033
 
1034
+ #managedKeyReservationIntegrity(reservation: ManagedKeyReservation): string {
1035
+ const { integrity: _integrity, ...facts } = reservation;
1036
+ return createHmac("sha256", this.#identityKey).update(canonicalJson(facts)).digest("hex");
1037
+ }
1038
+
1039
+ #isManagedKeyReservation(value: unknown): value is ManagedKeyReservation {
1040
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1041
+ const row = value as Partial<ManagedKeyReservation>;
1042
+ return (
1043
+ Object.keys(row).every(
1044
+ key =>
1045
+ key === "version" ||
1046
+ key === "kind" ||
1047
+ key === "aliasIdentities" ||
1048
+ key === "managedIdentity" ||
1049
+ key === "controlRoot" ||
1050
+ key === "integrity",
1051
+ ) &&
1052
+ row.version === 1 &&
1053
+ row.kind === "managed_key_reservation" &&
1054
+ Array.isArray(row.aliasIdentities) &&
1055
+ row.aliasIdentities.length > 0 &&
1056
+ row.aliasIdentities.every(identity => typeof identity === "string" && /^[0-9a-f]{64}$/i.test(identity)) &&
1057
+ new Set(row.aliasIdentities).size === row.aliasIdentities.length &&
1058
+ canonicalJson(row.aliasIdentities) === canonicalJson([...row.aliasIdentities].sort()) &&
1059
+ typeof row.managedIdentity === "string" &&
1060
+ /^[0-9a-f]{64}$/i.test(row.managedIdentity) &&
1061
+ typeof row.controlRoot === "string" &&
1062
+ path.isAbsolute(row.controlRoot) &&
1063
+ path.normalize(row.controlRoot) === row.controlRoot &&
1064
+ typeof row.integrity === "string" &&
1065
+ /^[0-9a-f]{64}$/i.test(row.integrity) &&
1066
+ timingSafeEqual(
1067
+ Buffer.from(row.integrity, "hex"),
1068
+ Buffer.from(this.#managedKeyReservationIntegrity(row as ManagedKeyReservation), "hex"),
1069
+ )
1070
+ );
1071
+ }
1072
+
1073
+ #rememberManagedKeyReservation(reservation: ManagedKeyReservation): boolean {
1074
+ const prior = this.#managedIdentityBindings.get(reservation.managedIdentity);
1075
+ if (
1076
+ prior &&
1077
+ (prior.controlRoot !== reservation.controlRoot ||
1078
+ canonicalJson(prior.aliasIdentities) !== canonicalJson(reservation.aliasIdentities))
1079
+ )
1080
+ return false;
1081
+ if (prior) return false;
1082
+ this.#managedIdentityBindings.set(reservation.managedIdentity, {
1083
+ aliasIdentities: [...reservation.aliasIdentities],
1084
+ controlRoot: reservation.controlRoot,
1085
+ });
1086
+ for (const alias of reservation.aliasIdentities) {
1087
+ const roots = this.#managedKeyAliases.get(alias) ?? new Map<string, string>();
1088
+ const existingRoot = roots.get(reservation.managedIdentity);
1089
+ if (existingRoot !== undefined && existingRoot !== reservation.controlRoot) return false;
1090
+ roots.set(reservation.managedIdentity, reservation.controlRoot);
1091
+ this.#managedKeyAliases.set(alias, roots);
1092
+ }
1093
+ return true;
1094
+ }
1095
+
967
1096
  #isStoredClaim(value: unknown): value is StoredClaim {
968
1097
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
969
1098
  const row = value as Partial<StoredClaim>;
@@ -989,6 +1118,13 @@ export class SpawnAuthorityStore {
989
1118
  throw new Error("Refusing to persist invalid spawn authority evidence.");
990
1119
  const write: SpawnAuthorityDurableWrite = { claim, ...(authority === undefined ? {} : { authority }) };
991
1120
  const row: StoredClaim = { version: 1, ...write, integrity: this.#integrity(claim, authority) };
1121
+ await this.#appendRow(row, write);
1122
+ this.#latest.set(claim.lifecycleIdentity, claim);
1123
+ if (authority) this.#authorities.set(claim.lifecycleIdentity, authority);
1124
+ else this.#authorities.delete(claim.lifecycleIdentity);
1125
+ }
1126
+
1127
+ async #appendRow(row: unknown, write?: SpawnAuthorityDurableWrite): Promise<void> {
992
1128
  // The row is appended before the durability barrier, so a barrier failure
993
1129
  // can leave a physically written line that in-memory state never accepted.
994
1130
  // A retry would then append a second row for the same claim and reopen
@@ -1009,13 +1145,13 @@ export class SpawnAuthorityStore {
1009
1145
  try {
1010
1146
  try {
1011
1147
  await handle.writeFile(`${canonicalJson(row)}\n`);
1012
- await this.#options.beforeSyncForTest?.(write);
1148
+ if (write) await this.#options.beforeSyncForTest?.(write);
1013
1149
  await handle.sync();
1014
1150
  // The parent-directory barrier belongs INSIDE the rolled-back region:
1015
1151
  // a throw here would otherwise leave a durable row that in-memory
1016
1152
  // state never accepted, and the retry would append a duplicate that
1017
1153
  // makes the journal unreopenable.
1018
- await this.#options.beforeDirectorySyncForTest?.(write);
1154
+ if (write) await this.#options.beforeDirectorySyncForTest?.(write);
1019
1155
  const directory = await fs.open(path.dirname(this.#file), fsSync.constants.O_RDONLY);
1020
1156
  try {
1021
1157
  await directory.sync();
@@ -1039,10 +1175,7 @@ export class SpawnAuthorityStore {
1039
1175
  } finally {
1040
1176
  await handle.close();
1041
1177
  }
1042
- this.#latest.set(claim.lifecycleIdentity, claim);
1043
- if (authority) this.#authorities.set(claim.lifecycleIdentity, authority);
1044
- else this.#authorities.delete(claim.lifecycleIdentity);
1045
- await this.#options.afterSyncForTest?.(write);
1178
+ if (write) await this.#options.afterSyncForTest?.(write);
1046
1179
  }
1047
1180
 
1048
1181
  async #serial<T>(operation: () => Promise<T>): Promise<T> {
@@ -5,6 +5,8 @@ import { processIncarnation } from "./process-incarnation";
5
5
  export interface BrokerStartupFailureMarker {
6
6
  version: 2;
7
7
  reason: string;
8
+ /** Validated manual lock cleanup command, stored separately when it cannot fit in `reason`. */
9
+ cleanupCommand?: string;
8
10
  exitCode: number | null;
9
11
  signal: string | null;
10
12
  writtenAt: number;
@@ -23,11 +25,82 @@ export interface BrokerStartupFailureMarker {
23
25
 
24
26
  const BROKER_STARTUP_FAILURE_FILE = "broker.startup-failure.json";
25
27
  const MAX_BROKER_STARTUP_FAILURE_REASON = 512;
28
+ // Windows extended paths can be 32,767 UTF-16 code units and PowerShell quoting
29
+ // can double every apostrophe; this also comfortably covers POSIX PATH_MAX paths.
30
+ const MAX_BROKER_STARTUP_FAILURE_CLEANUP_COMMAND = 66_000;
31
+ const FILE_LOCK_CLEANUP_GUIDANCE =
32
+ "; manual cleanup is appropriate only after independently verifying this exact directory still belongs to the dead owner and no successor has taken it: ";
33
+ const FILE_LOCK_OWNER_GUIDANCE =
34
+ "; a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying";
35
+ const COMPACT_FILE_LOCK_CLEANUP_GUIDANCE =
36
+ "Manual cleanup is appropriate only after independently verifying this exact directory still belongs to the dead owner and no successor has taken it: ";
37
+
38
+ function decodeFileLockCleanupCommand(command: string): string | undefined {
39
+ if (command.length === 0 || command.length > MAX_BROKER_STARTUP_FAILURE_CLEANUP_COMMAND) return undefined;
40
+ const prefix = process.platform === "win32" ? "Remove-Item -LiteralPath '" : "rm -rf -- '";
41
+ const suffix = process.platform === "win32" ? "' -Recurse -Force" : "'";
42
+ if (!command.startsWith(prefix) || !command.endsWith(suffix)) return undefined;
43
+
44
+ const encodedPath = command.slice(prefix.length, -suffix.length);
45
+ const quoteEscape = process.platform === "win32" ? "''" : "'\\''";
46
+ const decodedPath = encodedPath.replaceAll(quoteEscape, "'");
47
+ if (!decodedPath || decodedPath.replaceAll("'", quoteEscape) !== encodedPath) return undefined;
48
+ return decodedPath;
49
+ }
50
+
51
+ function validatedFileLockCleanupCommand(reason: string): string | undefined {
52
+ let guidanceIndex = reason.indexOf(FILE_LOCK_CLEANUP_GUIDANCE);
53
+ while (guidanceIndex >= 0) {
54
+ const command = reason.slice(guidanceIndex + FILE_LOCK_CLEANUP_GUIDANCE.length);
55
+ const lockPath = decodeFileLockCleanupCommand(command);
56
+ if (lockPath) {
57
+ const beforeCleanup = reason.slice(0, guidanceIndex);
58
+ const lockAndOwnerGuidance = ` (${lockPath})${FILE_LOCK_OWNER_GUIDANCE}`;
59
+ const acquisition = beforeCleanup.endsWith(lockAndOwnerGuidance)
60
+ ? beforeCleanup.slice(0, -lockAndOwnerGuidance.length)
61
+ : "";
62
+ if (/^Failed to acquire lock for [\s\S]+ after \d+ attempts: [\s\S]+$/.test(acquisition)) {
63
+ return command;
64
+ }
65
+ }
66
+ guidanceIndex = reason.indexOf(FILE_LOCK_CLEANUP_GUIDANCE, guidanceIndex + 1);
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function boundedFailureDetails(reason: string): { reason: string; cleanupCommand?: string } {
72
+ if (reason.length <= MAX_BROKER_STARTUP_FAILURE_REASON) return { reason };
73
+
74
+ const cleanupCommand = validatedFileLockCleanupCommand(reason);
75
+ if (cleanupCommand) {
76
+ const compactReason = `${COMPACT_FILE_LOCK_CLEANUP_GUIDANCE}${cleanupCommand}`;
77
+ if (compactReason.length <= MAX_BROKER_STARTUP_FAILURE_REASON) return { reason: compactReason };
78
+ return { reason: COMPACT_FILE_LOCK_CLEANUP_GUIDANCE, cleanupCommand };
79
+ }
80
+
81
+ return { reason: reason.slice(0, MAX_BROKER_STARTUP_FAILURE_REASON) };
82
+ }
26
83
 
27
84
  export function brokerStartupFailurePath(agentDir: string): string {
28
85
  return path.join(agentDir, "sdk", BROKER_STARTUP_FAILURE_FILE);
29
86
  }
30
87
 
88
+ /** Whether validated compact cleanup guidance targets the exact lock path. */
89
+ export function brokerStartupFailureCleanupTargetsLock(
90
+ marker: BrokerStartupFailureMarker | undefined,
91
+ lockPath: string,
92
+ ): boolean {
93
+ if (!marker) return false;
94
+ if (marker.cleanupCommand !== undefined) {
95
+ return (
96
+ marker.reason === COMPACT_FILE_LOCK_CLEANUP_GUIDANCE &&
97
+ decodeFileLockCleanupCommand(marker.cleanupCommand) === lockPath
98
+ );
99
+ }
100
+ if (!marker.reason.startsWith(COMPACT_FILE_LOCK_CLEANUP_GUIDANCE)) return false;
101
+ return decodeFileLockCleanupCommand(marker.reason.slice(COMPACT_FILE_LOCK_CLEANUP_GUIDANCE.length)) === lockPath;
102
+ }
103
+
31
104
  function boundedMarker(
32
105
  reason: string,
33
106
  exitCode: number | null,
@@ -35,9 +108,10 @@ function boundedMarker(
35
108
  pid: number,
36
109
  incarnation: string,
37
110
  ): BrokerStartupFailureMarker {
111
+ const boundedFailure = boundedFailureDetails(reason);
38
112
  return {
39
113
  version: 2,
40
- reason: reason.slice(0, MAX_BROKER_STARTUP_FAILURE_REASON),
114
+ ...boundedFailure,
41
115
  exitCode,
42
116
  signal,
43
117
  writtenAt: Date.now(),
@@ -84,6 +158,11 @@ export async function readBrokerStartupFailureMarker(
84
158
  marker.version !== 2 ||
85
159
  typeof marker.reason !== "string" ||
86
160
  marker.reason.length === 0 ||
161
+ marker.reason.length > MAX_BROKER_STARTUP_FAILURE_REASON ||
162
+ (marker.cleanupCommand !== undefined &&
163
+ (typeof marker.cleanupCommand !== "string" ||
164
+ marker.reason !== COMPACT_FILE_LOCK_CLEANUP_GUIDANCE ||
165
+ decodeFileLockCleanupCommand(marker.cleanupCommand) === undefined)) ||
87
166
  (marker.exitCode !== null && typeof marker.exitCode !== "number") ||
88
167
  (marker.signal !== null && typeof marker.signal !== "string") ||
89
168
  typeof marker.writtenAt !== "number" ||
@@ -25,6 +25,7 @@ const BROKER_OPERATIONS = new Set([
25
25
  "broker.lookup_lifecycle",
26
26
  "model.resolve",
27
27
  "session.spawn",
28
+ "task.dag",
28
29
  ]);
29
30
  type RequestInput = Record<string, unknown>;
30
31
  type BrokerRequest = {
@@ -141,8 +141,8 @@ export type ChatDaemonAction = "stop" | "reload";
141
141
  * crashes, recovery, and attachment retirement cannot replay ambiguous work.
142
142
  */
143
143
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
144
- discord: 79,
145
- slack: 86,
144
+ discord: 80,
145
+ slack: 87,
146
146
  };
147
147
 
148
148
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -4874,6 +4874,8 @@ export function createNotificationsExtension(
4874
4874
  const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? true);
4875
4875
  gatePresentations.setPublicationSuspended(true);
4876
4876
  let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
4877
+ let negotiatedCapabilitiesHandler: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;
4878
+ let connectionCloseHandler: ((connectionId: string) => void) | undefined;
4877
4879
  const inFlightGateResolutions = new Set<Promise<void>>();
4878
4880
  const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
4879
4881
  const quiesced = resolution.then(
@@ -6009,6 +6011,7 @@ export function createNotificationsExtension(
6009
6011
  const explicitOptIn = hasExplicitSetting === true && configured === true;
6010
6012
  await runBoundedDeadlineFlush(signal =>
6011
6013
  flushWorktreeOnPromptDeadline(ctx.cwd, {
6014
+ agentDir: settings?.getAgentDir(),
6012
6015
  explicitOptIn,
6013
6016
  isCurrent: () =>
6014
6017
  deadlineAttemptStatus(promptSubmissionKey(correlation), submission, deadlineAttempt) ===
@@ -7402,6 +7405,18 @@ export function createNotificationsExtension(
7402
7405
  start: async () => await server.start(),
7403
7406
  stop: async () => await server.stopAndWait(),
7404
7407
  broadcastFrame: frame => broadcastEventFrame(frame),
7408
+ onConnectionClose(handler) {
7409
+ connectionCloseHandler = handler;
7410
+ return () => {
7411
+ if (connectionCloseHandler === handler) connectionCloseHandler = undefined;
7412
+ };
7413
+ },
7414
+ onNegotiatedCapabilities(handler) {
7415
+ negotiatedCapabilitiesHandler = handler;
7416
+ return () => {
7417
+ if (negotiatedCapabilitiesHandler === handler) negotiatedCapabilitiesHandler = undefined;
7418
+ };
7419
+ },
7405
7420
  },
7406
7421
  ...(preparesExistingThread ? { readiness: "deferred" as const } : {}),
7407
7422
  ...(activationGate ? { activationGate } : {}),
@@ -7985,19 +8000,20 @@ export function createNotificationsExtension(
7985
8000
  const tuple = Array.isArray(connectionId) ? (connectionId as unknown[]) : undefined;
7986
8001
  const id = tuple?.[0] ?? connectionId;
7987
8002
  const negotiated = tuple?.[1] ?? capabilities;
7988
- if (typeof id === "string" && Array.isArray(negotiated))
7989
- rememberHostCapabilities(
7990
- id,
7991
- negotiated.filter((capability): capability is string => typeof capability === "string"),
7992
- );
8003
+ if (typeof id !== "string" || !Array.isArray(negotiated)) return;
8004
+ const normalized = negotiated.filter((capability): capability is string => typeof capability === "string");
8005
+ rememberHostCapabilities(id, normalized);
8006
+ if (liveHostCapabilities(id) === undefined) return;
8007
+ hostAttachedConnections.add(id);
8008
+ negotiatedCapabilitiesHandler?.(id, normalized);
7993
8009
  });
7994
8010
  server.onConnectionClose((_err, connectionId) => {
7995
8011
  if (!connectionId) return;
7996
8012
  closeHostConnection(connectionId);
8013
+ connectionCloseHandler?.(connectionId);
7997
8014
  void controlSurface
7998
8015
  .cancelPendingPreflightsForConnection(connectionId)
7999
8016
  .catch(error => logger.warn(`sdk: failed to cancel disconnected preflight: ${String(error)}`));
8000
- host.handleDisconnect(connectionId);
8001
8017
  // The socket is gone, so its fence has nothing left to refuse. Dropping the
8002
8018
  // entry keeps the set bounded by live connections instead of growing forever.
8003
8019
  fencedConnections.delete(connectionId);
@@ -292,8 +292,12 @@ export const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1;
292
292
  * replaced across this upgrade.
293
293
  * Generation 190 acknowledges the protected prompt-deadline boundary change,
294
294
  * so generation-189 owners are replaced instead of retaining the old path.
295
+ * Generation 192 makes session-host liveness a reference-counted work lease,
296
+ * so generation-191 owners cannot reap hosts with queued or promoted work.
297
+ * Generation 193 gates streamed content on negotiated observer capabilities,
298
+ * so existing owners are replaced before exposing the new observer path.
295
299
  */
296
- export const DAEMON_GENERATION = 192;
300
+ export const DAEMON_GENERATION = 194;
297
301
 
298
302
  /**
299
303
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 7
@@ -2341,6 +2341,20 @@ function rawKind(action: string, args: SdkSessionCliArgs): SdkSessionCliRawKind
2341
2341
  return action === "control" || action === "query" || action === "global" ? action : undefined;
2342
2342
  }
2343
2343
 
2344
+ function warnIfRepoIgnoredForExactSession(args: SdkSessionCliArgs): void {
2345
+ const action = args.action;
2346
+ const exactSessionAction =
2347
+ action === "inspect" ||
2348
+ action === "send" ||
2349
+ action === "status" ||
2350
+ action === "query" ||
2351
+ (action === "raw" && args.rawAction === "query");
2352
+ if (args.repo !== undefined && exactSessionAction)
2353
+ process.stderr.write(
2354
+ "Warning: --repo is ignored for exact-session commands; the session ID selects the broker target.\n",
2355
+ );
2356
+ }
2357
+
2344
2358
  /** Runs the broker-bound `gjc sdk session` command family without exposing endpoint credentials. */
2345
2359
  export async function runSdkSessionCli(
2346
2360
  args: SdkSessionCliArgs,
@@ -2383,10 +2397,12 @@ export async function runSdkSessionCli(
2383
2397
  }
2384
2398
  if (action === "inspect") {
2385
2399
  writeOutput(stripSecretFields(await runInspect(agentDir, requireValue(args.sessionId, "<sessionId>"))));
2400
+ warnIfRepoIgnoredForExactSession(args);
2386
2401
  return;
2387
2402
  }
2388
2403
  if (action === "send") {
2389
2404
  writeOutput(stripSecretFields(await runSend(agentDir, requireValue(args.sessionId, "<sessionId>"), args)));
2405
+ warnIfRepoIgnoredForExactSession(args);
2390
2406
  return;
2391
2407
  }
2392
2408
  if (action === "status") {
@@ -2400,6 +2416,7 @@ export async function runSdkSessionCli(
2400
2416
  ),
2401
2417
  ),
2402
2418
  );
2419
+ warnIfRepoIgnoredForExactSession(args);
2403
2420
  return;
2404
2421
  }
2405
2422
  if (action === "tail") {
@@ -2506,6 +2523,7 @@ export async function runSdkSessionCli(
2506
2523
  : await runRawQuery(agentDir, sessionId, operation, input, args),
2507
2524
  ),
2508
2525
  );
2526
+ warnIfRepoIgnoredForExactSession(args);
2509
2527
  } catch (error) {
2510
2528
  throw normalizeSessionFailure(error, args);
2511
2529
  }
@@ -1282,6 +1282,52 @@ describe("SessionSdkSessionRuntime", () => {
1282
1282
  expect(transport.sent).toContainEqual({ type: "event", kind: "message_update", payload: {} });
1283
1283
  await runtime.stop();
1284
1284
  });
1285
+ test("ignores late negotiated capabilities after the authoritative connection closes", async () => {
1286
+ const transport = memoryTransport();
1287
+ let closeConnection: ((connectionId: string) => void) | undefined;
1288
+ let negotiateCapabilities: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;
1289
+ const hookedTransport: SessionSdkTransport = {
1290
+ ...transport,
1291
+ onConnectionClose(handler) {
1292
+ closeConnection = handler;
1293
+ return () => {
1294
+ if (closeConnection === handler) closeConnection = undefined;
1295
+ };
1296
+ },
1297
+ onNegotiatedCapabilities(handler) {
1298
+ negotiateCapabilities = handler;
1299
+ return () => {
1300
+ if (negotiateCapabilities === handler) negotiateCapabilities = undefined;
1301
+ };
1302
+ },
1303
+ };
1304
+ const liveCapabilities = new Map<string, ReadonlySet<string>>();
1305
+ const providerReads: string[] = [];
1306
+ const runtime = new SessionSdkSessionRuntime({
1307
+ transport: hookedTransport,
1308
+ connectionCapabilities: connectionId => {
1309
+ providerReads.push(connectionId);
1310
+ return liveCapabilities.get(connectionId);
1311
+ },
1312
+ });
1313
+ await runtime.start();
1314
+ try {
1315
+ if (!closeConnection || !negotiateCapabilities) throw new Error("transport callbacks were not registered");
1316
+ const capabilities = [TURN_STREAM_CAPABILITY, SESSION_HOST_OBSERVER_CAPABILITY];
1317
+ liveCapabilities.set("observer", new Set(capabilities));
1318
+ negotiateCapabilities("observer", capabilities);
1319
+ expect(runtime.connectionIdsWithCapabilities(capabilities)).toEqual(["observer"]);
1320
+
1321
+ liveCapabilities.delete("observer");
1322
+ closeConnection("observer");
1323
+ negotiateCapabilities("observer", capabilities);
1324
+ providerReads.length = 0;
1325
+ expect(runtime.connectionIdsWithCapabilities(capabilities)).toEqual([]);
1326
+ expect(providerReads).toEqual([]);
1327
+ } finally {
1328
+ await runtime.stop();
1329
+ }
1330
+ });
1285
1331
  test("SDK-only host publishes one replayable bash_folded frame per fold and drops the subscription on shutdown", async () => {
1286
1332
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-bash-folded-"));
1287
1333
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
@@ -437,6 +437,7 @@ export class SessionSdkSessionRuntime {
437
437
  this.host.handleDisconnect(connectionId);
438
438
  });
439
439
  this.#capabilitiesDisposer = options.transport.onNegotiatedCapabilities?.((connectionId, negotiated) => {
440
+ if (options.connectionCapabilities && this.#connectionCapabilitiesProvider(connectionId) === undefined) return;
440
441
  this.#connectionIds.add(connectionId);
441
442
  this.#connectionCapabilities.set(connectionId, new Set(negotiated));
442
443
  });
@@ -500,7 +501,6 @@ export class SessionSdkSessionRuntime {
500
501
  : [];
501
502
  });
502
503
  }
503
-
504
504
  /** Deliver a non-replayable frame to connections with an explicit capability intersection. */
505
505
  sendFrameToCapabilities(
506
506
  required: readonly string[],
@@ -4535,10 +4535,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4535
4535
  return task;
4536
4536
  };
4537
4537
  /**
4538
- * Publish one content frame per owning invocation, each carrying its own
4539
- * correlation, so a shared run lets every submitter attribute the content to
4540
- * its own prompt. Content is best-effort and bypasses the lifecycle replay
4541
- * ring; the turn producing it is authoritative.
4538
+ * Publish one correlated content frame per owning invocation, plus one
4539
+ * unpositioned copy for connections that explicitly negotiated observer
4540
+ * streaming. A shared run therefore lets every submitter attribute the content
4541
+ * to its own prompt without exposing it to ordinary attached clients.
4542
+ * Content is best-effort and bypasses the lifecycle replay ring; the turn
4543
+ * producing it is authoritative.
4542
4544
  */
4543
4545
  const publishContentFrames = (
4544
4546
  current: RuntimeState,
@@ -5438,7 +5440,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5438
5440
  ? options.settings.has("sdk.flushWorktreeOnDeadline" as never)
5439
5441
  : configured === true;
5440
5442
  const explicitOptIn = hasExplicitSetting === true && configured === true;
5441
- await flushWorktreeOnPromptDeadline(ctx.cwd, { explicitOptIn, isCurrent, signal });
5443
+ await flushWorktreeOnPromptDeadline(ctx.cwd, {
5444
+ agentDir: options.settings?.getAgentDir(),
5445
+ explicitOptIn,
5446
+ isCurrent,
5447
+ signal,
5448
+ });
5442
5449
  },
5443
5450
  onExpired: (correlation, deadlineOutcome) => {
5444
5451
  const owner = lifecycleOwnerHolder.state;
@@ -804,6 +804,7 @@ const TRANSPORT_ERROR_CODES = new Set([
804
804
  "timeout",
805
805
  "connection_closed",
806
806
  "reconnect_exhausted",
807
+ "uncertain_after_send",
807
808
  "unavailable",
808
809
  "protocol_error",
809
810
  ]);
@@ -812,6 +813,7 @@ function certaintyForThrownError(error: {
812
813
  readonly code: string;
813
814
  readonly requestSent?: boolean;
814
815
  }): SessionLifecycleCertainty {
816
+ if (error.code === "uncertain_after_send") return "uncertain";
815
817
  if (error.code === "protocol_error") return error.requestSent === false ? "retryable" : "uncertain";
816
818
  if (!TRANSPORT_ERROR_CODES.has(error.code)) return certaintyForBrokerCode(error.code);
817
819
  if (error.requestSent === false) return "retryable";