@gajae-code/coding-agent 0.15.6 → 0.16.0

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 (167) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/root-flags.d.ts +14 -0
  4. package/dist/types/cli/update-cli.d.ts +13 -0
  5. package/dist/types/cli.d.ts +7 -0
  6. package/dist/types/commands/launch.d.ts +10 -0
  7. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  8. package/dist/types/config/file-lock.d.ts +5 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/settings-schema.d.ts +10 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  13. package/dist/types/crash/record-loader.d.ts +15 -0
  14. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  15. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  17. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  18. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  19. package/dist/types/master-mode/context.d.ts +21 -0
  20. package/dist/types/master-mode/first-request.d.ts +34 -0
  21. package/dist/types/master-mode/snapshot.d.ts +32 -0
  22. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  24. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  25. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  26. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  27. package/dist/types/modes/print-mode.d.ts +17 -0
  28. package/dist/types/modes/ui-language.d.ts +7 -1
  29. package/dist/types/sdk/broker/broker.d.ts +77 -1
  30. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  31. package/dist/types/sdk/broker/index.d.ts +3 -0
  32. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  33. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  34. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  35. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  36. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  37. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  38. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  39. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  40. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  41. package/dist/types/sdk/bus/index.d.ts +6 -0
  42. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  43. package/dist/types/sdk/cli/index.d.ts +1 -0
  44. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  45. package/dist/types/sdk/cli/rows.d.ts +4 -3
  46. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  47. package/dist/types/sdk/client/client.d.ts +3 -2
  48. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  49. package/dist/types/sdk/host/control/index.d.ts +1 -1
  50. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  51. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  52. package/dist/types/sdk/host/host.d.ts +10 -0
  53. package/dist/types/sdk/host/session-runtime.d.ts +52 -0
  54. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  55. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  56. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  57. package/dist/types/sdk/router/session-router.d.ts +2 -0
  58. package/dist/types/sdk/session.d.ts +16 -0
  59. package/dist/types/sdk/startup-capability.d.ts +2 -1
  60. package/dist/types/session/agent-session.d.ts +5 -0
  61. package/dist/types/session/credential-store-errors.d.ts +1 -0
  62. package/dist/types/session/session-manager.d.ts +65 -0
  63. package/dist/types/session/yield-queue.d.ts +5 -4
  64. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  65. package/dist/types/tools/bash.d.ts +4 -0
  66. package/dist/types/tools/gh.d.ts +8 -0
  67. package/dist/types/tools/index.d.ts +4 -0
  68. package/package.json +7 -7
  69. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  71. package/src/cli/accounts-cli.ts +9 -5
  72. package/src/cli/args.ts +17 -0
  73. package/src/cli/fast-help.ts +1 -0
  74. package/src/cli/root-flags.ts +5 -0
  75. package/src/cli/update-cli.ts +77 -11
  76. package/src/commands/launch.ts +86 -18
  77. package/src/commands/sdk.ts +113 -21
  78. package/src/config/autorouting-tier-map.ts +12 -0
  79. package/src/config/file-lock.ts +158 -83
  80. package/src/config/model-preset-registry.ts +1 -5
  81. package/src/config/model-profiles.ts +5 -5
  82. package/src/config/model-registry.ts +23 -0
  83. package/src/config/model-resolver.ts +5 -0
  84. package/src/config/settings-schema.ts +8 -1
  85. package/src/config/settings.ts +5 -0
  86. package/src/coordinator-mcp/projection-scan.ts +21 -0
  87. package/src/coordinator-mcp/question-state.ts +9 -11
  88. package/src/coordinator-mcp/server.ts +94 -20
  89. package/src/crash/index-store.ts +2 -0
  90. package/src/crash/record-loader.ts +10 -0
  91. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  92. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  93. package/src/gjc-runtime/session-state-lock.ts +79 -6
  94. package/src/gjc-runtime/session-state-sidecar.ts +929 -23
  95. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  96. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  97. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  98. package/src/hooks/skill-state.ts +6 -0
  99. package/src/internal-urls/docs-index.generated.ts +7 -7
  100. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  101. package/src/main.ts +57 -2
  102. package/src/master-mode/context.ts +50 -0
  103. package/src/master-mode/first-request.ts +65 -0
  104. package/src/master-mode/snapshot.ts +205 -0
  105. package/src/modes/acp/acp-agent.ts +605 -156
  106. package/src/modes/components/footer.ts +4 -2
  107. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  108. package/src/modes/components/oauth-selector.ts +6 -1
  109. package/src/modes/components/settings-selector.ts +1 -0
  110. package/src/modes/components/status-line/model-name.ts +53 -0
  111. package/src/modes/components/status-line/priority-row.ts +107 -0
  112. package/src/modes/components/status-line/segments.ts +23 -9
  113. package/src/modes/components/tool-status-header.ts +151 -15
  114. package/src/modes/controllers/input-controller.ts +28 -0
  115. package/src/modes/controllers/selector-controller.ts +191 -96
  116. package/src/modes/print-mode.ts +64 -0
  117. package/src/modes/ui-language.ts +49 -2
  118. package/src/prompts/system/master-mode.md +28 -0
  119. package/src/sdk/broker/broker.ts +1636 -26
  120. package/src/sdk/broker/endpoint-authority.ts +86 -0
  121. package/src/sdk/broker/ensure.ts +5 -0
  122. package/src/sdk/broker/index.ts +3 -0
  123. package/src/sdk/broker/lifecycle.ts +186 -41
  124. package/src/sdk/broker/master-capability.ts +232 -0
  125. package/src/sdk/broker/session-index.ts +121 -17
  126. package/src/sdk/broker/session-scope.ts +272 -0
  127. package/src/sdk/broker/spawn-authority.ts +1031 -0
  128. package/src/sdk/broker/spawn-substrate.ts +548 -0
  129. package/src/sdk/broker/state-version.ts +26 -14
  130. package/src/sdk/broker/transport.ts +1 -0
  131. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  132. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  133. package/src/sdk/bus/chat-daemon-runtime.ts +35 -4
  134. package/src/sdk/bus/index.ts +51 -4
  135. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  136. package/src/sdk/cli/index.ts +1 -0
  137. package/src/sdk/cli/master-cli.ts +259 -0
  138. package/src/sdk/cli/rows.ts +11 -8
  139. package/src/sdk/cli/session-cli.ts +264 -5
  140. package/src/sdk/client/client.ts +14 -8
  141. package/src/sdk/host/control/dispatch.ts +99 -38
  142. package/src/sdk/host/control/index.ts +2 -0
  143. package/src/sdk/host/control/operations.ts +2 -0
  144. package/src/sdk/host/control/runtime-gate.ts +203 -11
  145. package/src/sdk/host/host.ts +27 -2
  146. package/src/sdk/host/session-runtime.test.ts +210 -6
  147. package/src/sdk/host/session-runtime.ts +291 -49
  148. package/src/sdk/lifecycle/broker-client.ts +28 -0
  149. package/src/sdk/lifecycle/service.ts +279 -20
  150. package/src/sdk/lifecycle-session.ts +10 -2
  151. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  152. package/src/sdk/protocol/operation-registry.ts +13 -3
  153. package/src/sdk/router/session-router.ts +67 -59
  154. package/src/sdk/session.ts +357 -155
  155. package/src/sdk/startup-capability.ts +1 -0
  156. package/src/session/agent-session.ts +640 -103
  157. package/src/session/blob-store.ts +15 -5
  158. package/src/session/credential-store-errors.ts +2 -0
  159. package/src/session/internal/managed-session-storage.ts +153 -78
  160. package/src/session/session-manager.ts +337 -24
  161. package/src/session/yield-queue.ts +24 -10
  162. package/src/setup/frictionless-onboarding.ts +2 -0
  163. package/src/telemetry/events.ts +1138 -12
  164. package/src/tools/bash.ts +256 -42
  165. package/src/tools/gh.ts +104 -48
  166. package/src/tools/github-cache.ts +2 -2
  167. package/src/tools/index.ts +4 -0
@@ -6,6 +6,8 @@ export {
6
6
  type ControlResponse,
7
7
  controlRequestFromFrame,
8
8
  dispatchControl,
9
+ type TerminalAbortIdentity,
9
10
  TypedControlError,
11
+ terminalAbortIdentity,
10
12
  } from "./dispatch";
11
13
  export type { AbortScope, ControlInput, ControlSurface, ControlValue } from "./operations";
@@ -11,6 +11,8 @@ export type AbortScope = "turn" | "owned";
11
11
  export interface TerminalAbortInput {
12
12
  mode: "terminal";
13
13
  scope?: AbortScope;
14
+ /** Broker-authorized local override; public SDK callers cannot grant this authority. */
15
+ operator?: boolean;
14
16
  }
15
17
  export type ControlInput = Record<string, unknown>;
16
18
 
@@ -1,13 +1,19 @@
1
+ import { findOperation } from "../../protocol/operation-registry";
2
+
1
3
  /**
2
- * Private field carried only on the Broker's endpoint close request.
4
+ * Private fields carried only on Broker-authorized endpoint controls.
3
5
  *
4
- * The value is the lifecycle effect marker bound to the serving process. It is
5
- * never published in endpoint discovery or lifecycle service results; callers
6
- * that do not hold the Broker's indexed lifecycle record cannot satisfy this
7
- * gate.
6
+ * Their values are lifecycle effect markers bound to the serving process. They
7
+ * are never published in endpoint discovery or lifecycle service results;
8
+ * callers that do not hold the Broker's indexed lifecycle record cannot satisfy
9
+ * either gate.
8
10
  */
9
11
  export const BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD = "__gjcBrokerCloseCapability";
12
+ /** Private field carried only on the Broker-authorized terminal-abort request. */
13
+ export const BROKER_RUNTIME_ABORT_CAPABILITY_FIELD = "__gjcBrokerAbortCapability";
10
14
  const EXPECTED_BROKER_RUNTIME_CLOSE_CAPABILITY = process.env.GJC_LIFECYCLE_REQUEST_ID;
15
+ const EXPECTED_BROKER_RUNTIME_ABORT_CAPABILITY = process.env.GJC_LIFECYCLE_REQUEST_ID;
16
+ let brokerRuntimeAbortCapabilityOverrideForTest: string | undefined;
11
17
 
12
18
  function record(value: unknown): Record<string, unknown> | undefined {
13
19
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
@@ -18,13 +24,188 @@ export function brokerRuntimeCloseCapability(input: unknown): string | undefined
18
24
  return typeof value === "string" && value.length > 0 ? value : undefined;
19
25
  }
20
26
 
21
- /** Remove the private close capability before a request is exposed to diagnostics. */
22
- export function redactBrokerRuntimeCloseCapability(frame: Record<string, unknown>): Record<string, unknown> {
23
- if (frame.type !== "control_request" || frame.operation !== "session.close") return frame;
27
+ export function brokerRuntimeAbortCapability(input: unknown): string | undefined {
28
+ const value = record(input)?.[BROKER_RUNTIME_ABORT_CAPABILITY_FIELD];
29
+ return typeof value === "string" && value.length > 0 ? value : undefined;
30
+ }
31
+
32
+ /** Remove private Broker capabilities before a request is exposed to diagnostics. */
33
+ export function redactBrokerRuntimeCapabilities(frame: Record<string, unknown>): Record<string, unknown> {
34
+ if (frame.type !== "control_request") return frame;
24
35
  const input = record(frame.input);
25
- if (!input || !Object.hasOwn(input, BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD)) return frame;
26
- const { [BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD]: _capability, ...publicInput } = input;
27
- return { ...frame, input: publicInput };
36
+ if (!input) return frame;
37
+ let redacted = false;
38
+ const publicInput: Record<string, unknown> = {};
39
+ for (const [key, value] of Object.entries(input)) {
40
+ if (key === BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD || key === BROKER_RUNTIME_ABORT_CAPABILITY_FIELD) {
41
+ redacted = true;
42
+ continue;
43
+ }
44
+ publicInput[key] = value;
45
+ }
46
+ return redacted ? { ...frame, input: publicInput } : frame;
47
+ }
48
+
49
+ /**
50
+ * Frame-level keys an observer may see. `SdkFrame` is an open
51
+ * `Record<string, unknown>` and `#observeRequest` runs BEFORE dispatch
52
+ * validation, so an adversarial or malformed frame can carry content under any
53
+ * top-level key. Only structural routing values survive. `idempotencyKey`,
54
+ * `id`, and other correlation values are caller-chosen and are deliberately
55
+ * absent; so are `cursor` (a signed pagination token that can carry
56
+ * session/revision data) and `expectedRevision` (no diagnostic consumer reads
57
+ * it). Even allowlisted routing names are checked against their protocol
58
+ * values below: a field-name allowlist alone is fail-open for scalar secrets.
59
+ */
60
+ const OBSERVABLE_FRAME_FIELDS = new Set(["type", "operation", "query"]);
61
+
62
+ /**
63
+ * Frame keys whose value must match an exact expected type to survive.
64
+ * `confirm` is a structural destructive-op gate, so only the boolean form is
65
+ * routing information; a caller-authored string under that name is content.
66
+ */
67
+ const OBSERVABLE_TYPED_FRAME_FIELDS: Readonly<Record<string, "boolean">> = { confirm: "boolean" };
68
+
69
+ const OBSERVABLE_FRAME_LITERAL_FIELDS: Readonly<Record<string, ReadonlySet<string>>> = {
70
+ type: new Set(["control_request", "query_request"]),
71
+ };
72
+
73
+ const OBSERVER_MARKER_MAX_COUNT = 4096;
74
+
75
+ /**
76
+ * Input fields an observer may see. This is an ALLOWLIST on purpose: a
77
+ * denylist is fail-open, so every unlisted or newly added field would leak by
78
+ * default. Only structural routing/correlation values are preserved; all other
79
+ * values are caller content and are replaced with a shape marker.
80
+ */
81
+ const OBSERVABLE_INPUT_FIELDS = new Set(["mode", "scope", "kind", "level", "on", "confirm"]);
82
+
83
+ /**
84
+ * Input fields that carry a finite protocol value. Correlation identifiers,
85
+ * names, and operation arguments remain redacted even when they look like
86
+ * ordinary identifiers: callers are free to put arbitrary content in them.
87
+ */
88
+ const OBSERVABLE_INPUT_LITERAL_FIELDS: Readonly<Record<string, ReadonlySet<string>>> = {
89
+ mode: new Set([
90
+ "turn",
91
+ "terminal",
92
+ "owned",
93
+ "allow",
94
+ "always-allow",
95
+ "deny",
96
+ "always-deny",
97
+ "prompt",
98
+ "all",
99
+ "one-at-a-time",
100
+ "immediate",
101
+ "wait",
102
+ ]),
103
+ scope: new Set(["turn", "owned"]),
104
+ kind: new Set(["prompt", "skill"]),
105
+ level: new Set(["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"]),
106
+ };
107
+
108
+ const OBSERVABLE_INPUT_TYPED_FIELDS: Readonly<Record<string, "boolean">> = {
109
+ on: "boolean",
110
+ confirm: "boolean",
111
+ };
112
+
113
+ /** Replaces caller content with a shape-preserving marker for observers. */
114
+ function redactedContentMarker(value: unknown): string {
115
+ if (typeof value === "string") {
116
+ const count = Math.min(value.length, OBSERVER_MARKER_MAX_COUNT);
117
+ return `[redacted ${count}${value.length > count ? "+" : ""} chars]`;
118
+ }
119
+ if (Array.isArray(value)) {
120
+ const count = Math.min(value.length, OBSERVER_MARKER_MAX_COUNT);
121
+ return `[redacted ${count}${value.length > count ? "+" : ""} items]`;
122
+ }
123
+ if (value !== null && typeof value === "object") {
124
+ const fields = Object.keys(value).length;
125
+ const count = Math.min(fields, OBSERVER_MARKER_MAX_COUNT);
126
+ return `[redacted ${count}${fields > count ? "+" : ""} fields]`;
127
+ }
128
+ return "[redacted]";
129
+ }
130
+
131
+ function setObservedField(target: Record<string, unknown>, key: string, value: unknown): void {
132
+ Object.defineProperty(target, key, { configurable: true, enumerable: true, writable: true, value });
133
+ }
134
+
135
+ function observableFrameValue(key: string, value: unknown): unknown {
136
+ const requiredType = OBSERVABLE_TYPED_FRAME_FIELDS[key];
137
+ if (requiredType !== undefined) return typeof value === requiredType ? value : redactedContentMarker(value);
138
+ if (key === "operation" || key === "query") {
139
+ const known =
140
+ findOperation("control", String(value)) ??
141
+ findOperation("global", String(value)) ??
142
+ findOperation("query", String(value)) ??
143
+ findOperation("reverse", String(value));
144
+ return typeof value === "string" && value.length <= 128 && known !== undefined
145
+ ? value
146
+ : redactedContentMarker(value);
147
+ }
148
+ const allowed = OBSERVABLE_FRAME_LITERAL_FIELDS[key];
149
+ return allowed?.has(typeof value === "string" ? value : "") ? value : redactedContentMarker(value);
150
+ }
151
+
152
+ /**
153
+ * Strips caller content from a request frame before it reaches a diagnostic
154
+ * observer. Known protocol operation names and finite enum values survive so
155
+ * instrumentation stays useful; caller-chosen ids and all other values are
156
+ * redacted, including nested objects, so a field this module has never heard
157
+ * of cannot leak.
158
+ */
159
+ export function redactObservedRequestContent(frame: Record<string, unknown>): Record<string, unknown> {
160
+ const observed: Record<string, unknown> = {};
161
+ for (const [key, value] of Object.entries(frame)) {
162
+ if (value === undefined) continue;
163
+ if (key === "input") {
164
+ const input = record(value);
165
+ // A non-record payload is content, not "nothing to redact". Returning
166
+ // early here would hand a string or array `input` through verbatim.
167
+ setObservedField(
168
+ observed,
169
+ key,
170
+ input === undefined ? redactedContentMarker(value) : redactObservedInput(input),
171
+ );
172
+ continue;
173
+ }
174
+ // Structural routing scalars survive; every other top-level key, including
175
+ // one this module has never heard of, is redacted.
176
+ setObservedField(
177
+ observed,
178
+ key,
179
+ OBSERVABLE_FRAME_FIELDS.has(key) || Object.hasOwn(OBSERVABLE_TYPED_FRAME_FIELDS, key)
180
+ ? observableFrameValue(key, value)
181
+ : redactedContentMarker(value),
182
+ );
183
+ }
184
+ return observed;
185
+ }
186
+
187
+ /** Redacts one input payload, preserving only structural scalar routing fields. */
188
+ function redactObservedInput(input: Record<string, unknown>): Record<string, unknown> {
189
+ const redacted: Record<string, unknown> = {};
190
+ for (const [field, value] of Object.entries(input)) {
191
+ if (value === undefined) continue;
192
+ const requiredType = OBSERVABLE_INPUT_TYPED_FIELDS[field];
193
+ if (requiredType !== undefined) {
194
+ setObservedField(redacted, field, typeof value === requiredType ? value : redactedContentMarker(value));
195
+ continue;
196
+ }
197
+ const allowed = OBSERVABLE_INPUT_LITERAL_FIELDS[field];
198
+ // A finite protocol value is safe; every caller-chosen scalar, including
199
+ // an allowlisted field with a forged string, is content and is redacted.
200
+ setObservedField(
201
+ redacted,
202
+ field,
203
+ OBSERVABLE_INPUT_FIELDS.has(field) && allowed?.has(typeof value === "string" ? value : "")
204
+ ? value
205
+ : redactedContentMarker(value),
206
+ );
207
+ }
208
+ return redacted;
28
209
  }
29
210
 
30
211
  /**
@@ -38,3 +219,14 @@ export function hasBrokerRuntimeCloseCapability(input: unknown): boolean {
38
219
  const actual = brokerRuntimeCloseCapability(input);
39
220
  return typeof expected === "string" && expected.length > 0 && actual === expected;
40
221
  }
222
+
223
+ /** Test-only override for the abort gate; production authority remains process-local. */
224
+ export function setBrokerRuntimeAbortCapabilityForTest(capability: string | undefined): void {
225
+ brokerRuntimeAbortCapabilityOverrideForTest = capability;
226
+ }
227
+
228
+ export function hasBrokerRuntimeAbortCapability(input: unknown): boolean {
229
+ const expected = brokerRuntimeAbortCapabilityOverrideForTest ?? EXPECTED_BROKER_RUNTIME_ABORT_CAPABILITY;
230
+ const actual = brokerRuntimeAbortCapability(input);
231
+ return typeof expected === "string" && expected.length > 0 && actual === expected;
232
+ }
@@ -1,6 +1,6 @@
1
1
  import { logger } from "@gajae-code/utils";
2
2
  import { AUTOROUTING_INACTIVE_WARNING } from "../../config/autorouting-contract";
3
- import { redactBrokerRuntimeCloseCapability } from "./control/runtime-gate";
3
+ import { redactBrokerRuntimeCapabilities, redactObservedRequestContent } from "./control/runtime-gate";
4
4
  import { type EventFrame, SessionEventStream } from "./events";
5
5
  import { isAutoroutingInactive } from "./internal-autorouting-state";
6
6
  import { type ProviderLease, ReverseLeaseError, ReverseLeaseRuntime } from "./reverse-leases";
@@ -88,6 +88,12 @@ export interface SessionSdkHostOptions extends HostEndpointAdapters {
88
88
  * that fails is never read as authorization.
89
89
  */
90
90
  activationGate?: SessionActivationGate;
91
+ /** Private one-shot master-capability verification; never durable or public SDK surface. */
92
+ masterCapabilityVerify?: (frame: { nonce: unknown; attestationEpoch: unknown; capability: unknown }) => {
93
+ ok: boolean;
94
+ nonce: string;
95
+ attestationEpoch: string;
96
+ };
91
97
  }
92
98
 
93
99
  /** Shared by the replay filter and transport live broadcasts: a connection
@@ -416,6 +422,21 @@ export class SessionSdkHost {
416
422
  return;
417
423
  }
418
424
  switch (frame.type) {
425
+ case "master_capability_verify": {
426
+ const result = this.#options.masterCapabilityVerify?.({
427
+ nonce: frame.nonce,
428
+ attestationEpoch: frame.attestationEpoch,
429
+ capability: frame.capability,
430
+ }) ?? { ok: false, nonce: "", attestationEpoch: "" };
431
+ await this.#send(connectionId, {
432
+ type: "master_capability_verify_result",
433
+ id: typeof frame.id === "string" ? frame.id : "",
434
+ ok: result.ok,
435
+ nonce: result.nonce,
436
+ attestationEpoch: result.attestationEpoch,
437
+ });
438
+ break;
439
+ }
419
440
  case "control_request": {
420
441
  this.#observeRequest("control", connectionId, frame);
421
442
  // Deferred readiness withholds `session_ready`, but the control
@@ -616,7 +637,11 @@ export class SessionSdkHost {
616
637
  }
617
638
  #observeRequest(kind: "control" | "query", connectionId: string, frame: SdkFrame): void {
618
639
  try {
619
- this.#options.onRequest?.(kind, connectionId, redactBrokerRuntimeCloseCapability(frame));
640
+ this.#options.onRequest?.(
641
+ kind,
642
+ connectionId,
643
+ redactObservedRequestContent(redactBrokerRuntimeCapabilities(frame)),
644
+ );
620
645
  } catch {
621
646
  // Diagnostic observers must not change request handling.
622
647
  }
@@ -5,7 +5,7 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { AsyncJobManager } from "../../async";
7
7
  import type { Settings } from "../../config/settings";
8
- import type { ExtensionAPI, ExtensionContext } from "../../extensibility/extensions";
8
+ import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
9
9
  import {
10
10
  registerOwnedRegistration,
11
11
  resetTerminalAbortRegistriesForTests,
@@ -16,7 +16,8 @@ import { Broker } from "../broker/broker";
16
16
  import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
17
17
  import { createPromptReconciliation } from "../bus/prompt-reconciliation";
18
18
  import { createReconciliationStore } from "../bus/reconciliation-store";
19
- import { CursorRegistry, QueryHandlers, RevisionStore } from "./query";
19
+ import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
20
+ import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
20
21
  import {
21
22
  createInvocationReconciliation,
22
23
  createSdkSessionRuntimeExtension,
@@ -90,6 +91,7 @@ function extensionContext(
90
91
  goalState?: unknown;
91
92
  branch?: unknown[];
92
93
  liveState?: { isStreaming: boolean; steeringQueueDepth: number; followupQueueDepth: number };
94
+ transcript?: ExtensionTranscriptEntry[];
93
95
  } = {},
94
96
  ): ExtensionContext {
95
97
  return {
@@ -102,6 +104,7 @@ function extensionContext(
102
104
  getSessionName: () => undefined,
103
105
  getBranch: () => options.branch ?? [],
104
106
  },
107
+ getTranscript: () => options.transcript ?? [],
105
108
  getGoalState: () => options.goalState,
106
109
  } as unknown as ExtensionContext;
107
110
  }
@@ -148,6 +151,97 @@ async function queryGoalState(
148
151
  return response.page?.items[0];
149
152
  }
150
153
 
154
+ async function queryLastAssistant(ctx: ExtensionContext, sessionId: string): Promise<QueryResponse> {
155
+ const surface = createSdkSurfaceFactory({
156
+ ctx,
157
+ id: sessionId,
158
+ api: {} as ExtensionAPI,
159
+ }).query;
160
+ const store = new RevisionStore(sessionId);
161
+ const response = await new QueryHandlers(
162
+ surface,
163
+ sessionId,
164
+ store,
165
+ new CursorRegistry("last-assistant-test-token", store),
166
+ ).dispatch({
167
+ query: "session.last_assistant",
168
+ connectionId: "last-assistant-test-connection",
169
+ });
170
+ return response;
171
+ }
172
+
173
+ test("session.last_assistant returns the latest projected readable text past non-text assistant tails", async () => {
174
+ const sessionId = "last-assistant-readable-tail";
175
+ const transcript: ExtensionTranscriptEntry[] = [
176
+ {
177
+ id: "visible-readable",
178
+ role: "assistant",
179
+ textSummary: "first line second line",
180
+ ts: new Date(1).toISOString(),
181
+ content: [
182
+ { type: "text", text: "first line" },
183
+ { type: "thinking", thinking: "private reasoning" },
184
+ { type: "text", text: "second line" },
185
+ ],
186
+ },
187
+ ...Array.from(
188
+ { length: 2_000 },
189
+ (_, index): ExtensionTranscriptEntry => ({
190
+ id: `tool-tail-${index}`,
191
+ role: "assistant",
192
+ textSummary: "",
193
+ ts: new Date(index + 2).toISOString(),
194
+ body: index % 3 === 0 ? "" : index % 3 === 1 ? " \n\t " : undefined,
195
+ content:
196
+ index % 3 === 2
197
+ ? [
198
+ { type: "thinking", thinking: "not readable" },
199
+ { type: "toolCall", id: `call-${index}`, name: "read", arguments: {} },
200
+ ]
201
+ : undefined,
202
+ }),
203
+ ),
204
+ ];
205
+ const ctx = extensionContext(sessionId, "/tmp", {
206
+ transcript,
207
+ branch: [
208
+ {
209
+ type: "message",
210
+ message: { role: "assistant", content: [{ type: "text", text: "raw private tail" }] },
211
+ },
212
+ ],
213
+ });
214
+
215
+ expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({
216
+ ok: true,
217
+ page: { items: ["first line\nsecond line"] },
218
+ });
219
+ });
220
+
221
+ test("session.last_assistant returns resource_gone when the projected transcript has no readable assistant text", async () => {
222
+ const sessionId = "last-assistant-empty";
223
+ const ctx = extensionContext(sessionId, "/tmp", {
224
+ transcript: [
225
+ {
226
+ id: "user",
227
+ role: "user",
228
+ textSummary: "hello",
229
+ ts: new Date(1).toISOString(),
230
+ body: "hello",
231
+ },
232
+ {
233
+ id: "thinking-only",
234
+ role: "assistant",
235
+ textSummary: "",
236
+ ts: new Date(2).toISOString(),
237
+ content: [{ type: "thinking", thinking: "private reasoning" }],
238
+ },
239
+ ],
240
+ });
241
+
242
+ expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok: false, error: { code: "resource_gone" } });
243
+ });
244
+
151
245
  test("native prompt reconciliation fails closed for an explicitly empty assistant result", () => {
152
246
  const reconciliation = createPromptReconciliation();
153
247
  const correlation = { commandId: "native-empty-command", turnId: "native-empty-turn" };
@@ -1010,6 +1104,8 @@ describe("SessionSdkSessionRuntime", () => {
1010
1104
  });
1011
1105
  test("SDK-only host admits, replays, and conflicts terminal abort requests durably", async () => {
1012
1106
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-abort-"));
1107
+ const operatorCapability = "runtime-terminal-abort-capability";
1108
+ setBrokerRuntimeAbortCapabilityForTest(operatorCapability);
1013
1109
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1014
1110
  const api = {
1015
1111
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
@@ -1045,7 +1141,13 @@ describe("SessionSdkSessionRuntime", () => {
1045
1141
  },
1046
1142
  abortPromptAndWaitWithTerminal: async (handle, options) => {
1047
1143
  seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1048
- return { status: "settled", terminalScope: {} };
1144
+ return {
1145
+ status: "settled",
1146
+ terminalScope: {
1147
+ abortedAttemptEpoch: activeEpoch,
1148
+ lineageIdHash: "runtime-test-lineage",
1149
+ },
1150
+ };
1049
1151
  },
1050
1152
  },
1051
1153
  });
@@ -1140,7 +1242,43 @@ describe("SessionSdkSessionRuntime", () => {
1140
1242
  }),
1141
1243
  ]),
1142
1244
  );
1245
+
1246
+ // A separately connected, explicitly confirmed local operator may stop
1247
+ // the active turn and its exact owned work without weakening ordinary
1248
+ // connection ownership checks.
1249
+ transport.feed("local-operator", {
1250
+ type: "control_request",
1251
+ id: "terminal-operator-abort",
1252
+ operation: "turn.abort",
1253
+ input: {
1254
+ mode: "terminal",
1255
+ scope: "owned",
1256
+ operator: true,
1257
+ [BROKER_RUNTIME_ABORT_CAPABILITY_FIELD]: operatorCapability,
1258
+ },
1259
+ confirm: true,
1260
+ idempotencyKey: "terminal-operator-key",
1261
+ } as SdkFrame);
1262
+ await waitForFrame("terminal-operator-abort");
1263
+ expect(seamCalls).toEqual([
1264
+ { handle: "exact-run-handle", scope: "turn" },
1265
+ { handle: "later-run-handle", scope: "owned" },
1266
+ ]);
1267
+ expect(transport.sent.find(frame => frame.id === "terminal-operator-abort")).toMatchObject({
1268
+ ok: true,
1269
+ result: expect.objectContaining({ turn: "stopped", ownedWork: "stopped" }),
1270
+ });
1271
+ const operatorKeyHash = createHash("sha256").update("terminal-operator-key").digest("hex");
1272
+ const deliveryDeadline = Date.now() + 15_000;
1273
+ while (
1274
+ reconciliationStore.snapshotTerminalScopes().find(record => record.idempotencyKeyHash === operatorKeyHash)
1275
+ ?.responseState !== "sent"
1276
+ ) {
1277
+ if (Date.now() > deliveryDeadline) throw new Error("Timed out waiting for operator delivery receipt");
1278
+ await Bun.sleep(20);
1279
+ }
1143
1280
  } finally {
1281
+ setBrokerRuntimeAbortCapabilityForTest(undefined);
1144
1282
  await handlers.get("session_shutdown")?.({}, ctx);
1145
1283
  await rm(cwd, { recursive: true, force: true });
1146
1284
  }
@@ -1351,7 +1489,71 @@ describe("SessionSdkSessionRuntime", () => {
1351
1489
  }
1352
1490
  });
1353
1491
 
1354
- test("SDK-only host re-reads the active prompt AND its owner after an owner-mismatch reservation wins the race", async () => {
1492
+ test("SDK-only operator abort never adopts a turn that starts after idle admission", async () => {
1493
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-operator-idle-race-"));
1494
+ const capability = "operator-idle-race-capability";
1495
+ setBrokerRuntimeAbortCapabilityForTest(capability);
1496
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1497
+ const api = {
1498
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1499
+ handlers.set(event, handler);
1500
+ },
1501
+ } as unknown as ExtensionAPI;
1502
+ const transport = memoryTransport();
1503
+ const reconciliationStore = createReconciliationStore({
1504
+ sessionFile: path.join(cwd, "session.json"),
1505
+ sessionId: transport.sessionId,
1506
+ });
1507
+ const seamCalls: string[] = [];
1508
+ let promptReads = 0;
1509
+ createSdkSessionRuntimeExtension(api, {
1510
+ agentDir: cwd,
1511
+ createTransport: async () => transport,
1512
+ terminalAbortSeams: {
1513
+ getReconciliationStore: () => reconciliationStore,
1514
+ getTerminalTurnEpoch: () => (promptReads++ === 0 ? undefined : 9),
1515
+ getActivePromptHandle: () => (promptReads === 0 ? undefined : "successor-handle"),
1516
+ getActivePromptOwnerConnectionId: () => "successor-owner",
1517
+ cancelPendingPreflightForTerminalAbort: () => {},
1518
+ abortPromptAndWaitWithTerminal: async handle => {
1519
+ seamCalls.push(handle);
1520
+ return { status: "settled", terminalScope: {} };
1521
+ },
1522
+ },
1523
+ });
1524
+ const ctx = extensionContext(transport.sessionId, cwd);
1525
+ try {
1526
+ await handlers.get("session_start")?.({}, ctx);
1527
+ transport.feed("local-operator", {
1528
+ type: "control_request",
1529
+ id: "operator-idle-race",
1530
+ operation: "turn.abort",
1531
+ input: {
1532
+ mode: "terminal",
1533
+ operator: true,
1534
+ [BROKER_RUNTIME_ABORT_CAPABILITY_FIELD]: capability,
1535
+ },
1536
+ confirm: true,
1537
+ idempotencyKey: "operator-idle-race-key",
1538
+ } as SdkFrame);
1539
+ const deadline = Date.now() + 15_000;
1540
+ while (!transport.sent.some(frame => frame.id === "operator-idle-race")) {
1541
+ if (Date.now() > deadline) throw new Error("Timed out waiting for operator idle-race response");
1542
+ await Bun.sleep(20);
1543
+ }
1544
+ expect(transport.sent.find(frame => frame.id === "operator-idle-race")).toMatchObject({
1545
+ ok: true,
1546
+ result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
1547
+ });
1548
+ expect(seamCalls).toHaveLength(0);
1549
+ } finally {
1550
+ setBrokerRuntimeAbortCapabilityForTest(undefined);
1551
+ await handlers.get("session_shutdown")?.({}, ctx);
1552
+ await rm(cwd, { recursive: true, force: true });
1553
+ }
1554
+ });
1555
+
1556
+ test("SDK-only host re-reads the active prompt AND its owner after an owner-mismatch reservation race", async () => {
1355
1557
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-owner-race-"));
1356
1558
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1357
1559
  const api = {
@@ -3172,6 +3374,7 @@ describe("SessionSdkSessionRuntime", () => {
3172
3374
  },
3173
3375
  } as any;
3174
3376
  const sessionId = "broker-recovery";
3377
+ const endpointUrl = "ws://127.0.0.1:1";
3175
3378
  createSdkSessionRuntimeExtension(api, {
3176
3379
  agentDir,
3177
3380
  createTransport: async ({ stateRoot, token }) => ({
@@ -3183,8 +3386,8 @@ describe("SessionSdkSessionRuntime", () => {
3183
3386
  start: async () => {
3184
3387
  const endpoint = path.join(stateRoot, "sdk", `${sessionId}.json`);
3185
3388
  await mkdir(path.dirname(endpoint), { recursive: true });
3186
- await writeFile(endpoint, JSON.stringify({ sessionId, token, pid: process.pid }));
3187
- return { url: "ws://127.0.0.1:1" };
3389
+ await writeFile(endpoint, JSON.stringify({ sessionId, token, pid: process.pid, url: endpointUrl }));
3390
+ return { url: endpointUrl };
3188
3391
  },
3189
3392
  stop: async () => {},
3190
3393
  }),
@@ -3241,6 +3444,7 @@ describe("SessionSdkSessionRuntime", () => {
3241
3444
  createSdkSessionRuntimeExtension(api, {
3242
3445
  agentDir,
3243
3446
  brokerRegistrationRequired: true,
3447
+ lifecycleRequestId: "broker-required-marker",
3244
3448
  createTransport: async ({ sessionId, stateRoot, token }) => ({
3245
3449
  sessionId,
3246
3450
  stateRoot,