@gajae-code/coding-agent 0.13.0 → 0.13.2

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 (119) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/config/file-lock.d.ts +2 -0
  3. package/dist/types/config/keybindings.d.ts +2 -2
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/models-config-schema.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +17 -5
  7. package/dist/types/config/settings.d.ts +10 -1
  8. package/dist/types/edit/streaming.d.ts +5 -5
  9. package/dist/types/eval/py/kernel.d.ts +2 -2
  10. package/dist/types/eval/py/runtime.d.ts +5 -1
  11. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  12. package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
  13. package/dist/types/modes/components/model-selector.d.ts +3 -2
  14. package/dist/types/runtime/process-lifecycle.d.ts +12 -31
  15. package/dist/types/sdk/broker/identity.d.ts +2 -1
  16. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
  17. package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
  18. package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  20. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +4 -0
  22. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
  23. package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
  26. package/dist/types/sdk/host/query/cursor.d.ts +4 -0
  27. package/dist/types/sdk/host/query/handlers.d.ts +6 -0
  28. package/dist/types/sdk/host/session-runtime.d.ts +7 -6
  29. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +1 -0
  31. package/dist/types/sdk/turn-result.d.ts +37 -0
  32. package/dist/types/session/agent-session.d.ts +8 -11
  33. package/dist/types/session/blob-store.d.ts +6 -3
  34. package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
  35. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
  36. package/dist/types/task/executor.d.ts +4 -7
  37. package/dist/types/tools/index.d.ts +1 -1
  38. package/dist/types/tools/read.d.ts +1 -1
  39. package/dist/types/tools/search.d.ts +1 -1
  40. package/dist/types/utils/edit-mode.d.ts +69 -0
  41. package/package.json +8 -8
  42. package/scripts/generate-hotkeys-docs.ts +8 -6
  43. package/src/commands/sdk.ts +23 -2
  44. package/src/config/file-lock.ts +25 -5
  45. package/src/config/keybindings.ts +4 -2
  46. package/src/config/model-registry.ts +20 -0
  47. package/src/config/models-config-schema.ts +1 -0
  48. package/src/config/settings-schema.ts +29 -22
  49. package/src/config/settings.ts +42 -10
  50. package/src/coordinator-mcp/server.ts +87 -106
  51. package/src/edit/index.ts +5 -19
  52. package/src/edit/renderer.ts +3 -2
  53. package/src/edit/streaming.ts +28 -15
  54. package/src/eval/py/executor.ts +108 -10
  55. package/src/eval/py/kernel.ts +86 -12
  56. package/src/eval/py/runtime.ts +200 -16
  57. package/src/exec/bash-executor.ts +36 -37
  58. package/src/gjc-runtime/gc-runtime.ts +25 -3
  59. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  60. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  61. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  62. package/src/internal-urls/docs-index.generated.ts +9 -9
  63. package/src/modes/acp/acp-agent.ts +10 -2
  64. package/src/modes/components/model-selector.ts +28 -8
  65. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  66. package/src/modes/controllers/selector-controller.ts +4 -2
  67. package/src/modes/interactive-mode.ts +4 -0
  68. package/src/runtime/process-lifecycle.ts +126 -27
  69. package/src/runtime-mcp/transports/stdio.ts +11 -6
  70. package/src/sdk/acp/adapter.ts +18 -10
  71. package/src/sdk/broker/broker.ts +55 -4
  72. package/src/sdk/broker/ensure.ts +10 -7
  73. package/src/sdk/broker/identity.ts +10 -0
  74. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  75. package/src/sdk/broker/lifecycle.ts +53 -10
  76. package/src/sdk/broker/process-incarnation.ts +6 -1
  77. package/src/sdk/broker/session-index.ts +28 -11
  78. package/src/sdk/broker/startup-failure.ts +101 -0
  79. package/src/sdk/broker/transport.ts +1 -0
  80. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  81. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  82. package/src/sdk/bus/conversation-store.ts +171 -50
  83. package/src/sdk/bus/index.ts +101 -47
  84. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  85. package/src/sdk/bus/notification-service.ts +3 -3
  86. package/src/sdk/bus/reconciliation-store.ts +16 -0
  87. package/src/sdk/bus/telegram-daemon-contract.ts +13 -2
  88. package/src/sdk/bus/telegram-daemon.ts +151 -41
  89. package/src/sdk/bus/topic-registry.ts +38 -9
  90. package/src/sdk/cli/session-cli.ts +20 -7
  91. package/src/sdk/client/discovery.ts +23 -2
  92. package/src/sdk/host/query/cursor.ts +4 -0
  93. package/src/sdk/host/query/handlers.ts +99 -100
  94. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  95. package/src/sdk/host/session-runtime.test.ts +98 -1
  96. package/src/sdk/host/session-runtime.ts +57 -15
  97. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  98. package/src/sdk/host/websocket-transport.ts +54 -30
  99. package/src/sdk/index.ts +1 -0
  100. package/src/sdk/mcp/server.ts +19 -6
  101. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  102. package/src/sdk/protocol/operation-registry.ts +8 -12
  103. package/src/sdk/session.ts +5 -14
  104. package/src/sdk/turn-result.ts +53 -0
  105. package/src/session/agent-session.ts +112 -69
  106. package/src/session/blob-store.ts +91 -21
  107. package/src/session/internal/managed-session-storage.ts +136 -21
  108. package/src/session/session-manager.ts +408 -81
  109. package/src/session/session-storage.ts +68 -10
  110. package/src/slash-commands/builtin-registry.ts +43 -5
  111. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  112. package/src/task/executor.ts +25 -8
  113. package/src/task/index.ts +5 -4
  114. package/src/tools/bash.ts +11 -9
  115. package/src/tools/index.ts +1 -1
  116. package/src/tools/read.ts +12 -11
  117. package/src/tools/search.ts +7 -7
  118. package/src/utils/edit-mode.ts +180 -9
  119. package/src/web/search/providers/insane.ts +55 -1
@@ -38,13 +38,34 @@ export type SdkSessionEndpointScope = "default" | "chat";
38
38
  function endpointDirectory(repo: string, scope: SdkSessionEndpointScope = "default"): string {
39
39
  return scope === "chat" ? path.join(repo, ".gjc", "state", "chat", "sdk") : path.join(repo, ".gjc", "state", "sdk");
40
40
  }
41
+ function isUsableSessionId(sessionId: string): boolean {
42
+ return (
43
+ sessionId.length > 0 &&
44
+ sessionId !== "." &&
45
+ sessionId !== ".." &&
46
+ !sessionId.includes("/") &&
47
+ !sessionId.includes("\\") &&
48
+ !sessionId.includes("\0")
49
+ );
50
+ }
41
51
 
42
52
  function parseEndpoint(sessionId: string, file: string, value: unknown): SdkSessionEndpoint {
53
+ if (!isUsableSessionId(sessionId))
54
+ throw new SdkDiscoveryError(file, "SDK endpoint discovery filename does not contain a usable session id.");
43
55
  if (!value || typeof value !== "object")
44
56
  throw new SdkDiscoveryError(file, "SDK endpoint discovery record must be an object.");
45
- const endpoint = value as { version?: unknown; url?: unknown; token?: unknown; pid?: unknown; stale?: unknown };
57
+ const endpoint = value as {
58
+ version?: unknown;
59
+ sessionId?: unknown;
60
+ url?: unknown;
61
+ token?: unknown;
62
+ pid?: unknown;
63
+ stale?: unknown;
64
+ };
46
65
  if (typeof endpoint.version === "number" && endpoint.version > 1)
47
66
  throw new SdkDiscoveryError(file, "Unsupported SDK endpoint discovery state version.");
67
+ if (endpoint.sessionId !== undefined && endpoint.sessionId !== sessionId)
68
+ throw new SdkDiscoveryError(file, "SDK endpoint discovery record session id does not match its filename.");
48
69
  if (typeof endpoint.url !== "string" || !endpoint.url)
49
70
  throw new SdkDiscoveryError(file, "SDK endpoint discovery record is invalid.");
50
71
  const stale = typeof endpoint.stale === "boolean" ? endpoint.stale : undefined;
@@ -108,7 +129,7 @@ export async function readSdkSessionEndpoint(
108
129
  sessionId: string,
109
130
  scope: SdkSessionEndpointScope = "default",
110
131
  ): Promise<SdkSessionEndpoint | null> {
111
- if (!sessionId || sessionId.includes(path.sep) || sessionId.includes("/")) return null;
132
+ if (!isUsableSessionId(sessionId)) return null;
112
133
  const file = path.join(endpointDirectory(repo, scope), `${sessionId}.json`);
113
134
  try {
114
135
  return parseEndpoint(sessionId, file, JSON.parse(await fs.readFile(file, "utf8")));
@@ -32,6 +32,10 @@ export interface CursorSelector {
32
32
  resourceKind?: string;
33
33
  resourceId?: string;
34
34
  itemId?: string;
35
+ kind?: string;
36
+ clientRef?: string;
37
+ commandId?: string;
38
+ turnId?: string;
35
39
  }
36
40
 
37
41
  export interface CursorPosition {
@@ -55,6 +55,12 @@ export interface SessionSurface {
55
55
  turnId?: string;
56
56
  clientRef?: string;
57
57
  }): unknown | Promise<unknown>;
58
+ getTurnResult?(selector: {
59
+ kind: "prompt" | "skill";
60
+ clientRef?: string;
61
+ commandId?: string;
62
+ turnId?: string;
63
+ }): unknown | Promise<unknown>;
58
64
  /** Q27 effective model-profile catalog from the live session registry. */
59
65
  getModelProfiles?(): unknown[] | Promise<unknown[]>;
60
66
  /**
@@ -167,7 +173,7 @@ const names = [
167
173
  "resource.body",
168
174
  "artifact.read",
169
175
  "runtime.jobs.list",
170
- "turn.prompt_status",
176
+ "turn.result",
171
177
  "models.profiles.list",
172
178
  "skill.invoke_status",
173
179
  "providers.list/active",
@@ -183,11 +189,35 @@ export class QueryHandlers {
183
189
  ) {}
184
190
  async dispatch(request: QueryRequest): Promise<QueryResponse> {
185
191
  try {
192
+ if (request.query === "turn.result") {
193
+ if (this.surface.installedQueries instanceof Set && !this.surface.installedQueries.has("turn.result"))
194
+ return this.#error(
195
+ request,
196
+ "operation_not_session_owned",
197
+ false,
198
+ "turn.result is not installed for this session.",
199
+ );
200
+ return await this.#turnResult(request);
201
+ }
202
+ if (request.query === "turn.prompt_status") return await this.#promptTurnResult(request);
203
+ if (request.query === "skill.invoke_status") return await this.#skillTurnResult(request);
204
+
186
205
  const query = request.query.startsWith("Q")
187
206
  ? request.query
188
207
  : request.query === "models.list" || request.query === "models.current"
189
208
  ? "Q10"
190
209
  : `Q${String(names.indexOf(request.query) + 1).padStart(2, "0")}`;
210
+ if (query === "Q26") {
211
+ if (this.surface.installedQueries instanceof Set && !this.surface.installedQueries.has("turn.result"))
212
+ return this.#error(
213
+ request,
214
+ "operation_not_session_owned",
215
+ false,
216
+ "turn.result is not installed for this session.",
217
+ );
218
+ return await this.#turnResult(request);
219
+ }
220
+ if (query === "Q28") return await this.#skillTurnResult(request);
191
221
  if (
192
222
  this.surface.installedQueries instanceof Set &&
193
223
  !this.surface.installedQueries.has(names[Number(query.slice(1)) - 1] ?? "")
@@ -220,8 +250,6 @@ export class QueryHandlers {
220
250
  }
221
251
  if (query === "Q02") return await this.#transcriptBody(request);
222
252
  if (query === "Q23") return await this.#resourceBody(request);
223
- if (query === "Q26") return await this.#promptStatus(request);
224
- if (query === "Q28") return await this.#skillInvokeStatus(request);
225
253
  if (query === "Q24") return await this.#artifact(request);
226
254
  if (query === "Q30") return await this.#checkpoint(request);
227
255
  if (query === "Q27" && request.input && Object.keys(request.input).length > 0)
@@ -532,6 +560,35 @@ export class QueryHandlers {
532
560
  if (!range) return this.#error(request, "resource_gone");
533
561
  return this.#chunkRange(request, "transcript", "default", revision, range, selector, { entryId, highWatermark });
534
562
  }
563
+ async #turnResult(request: QueryRequest): Promise<QueryResponse> {
564
+ const input = request.input ?? {};
565
+ const keys = Object.keys(input);
566
+ const kind = input.kind;
567
+ const rawClientRef = typeof input.clientRef === "string" ? input.clientRef : undefined;
568
+ const clientRef = rawClientRef?.trim();
569
+ const hasClient = clientRef !== undefined;
570
+ const hasPair = typeof input.commandId === "string" && typeof input.turnId === "string";
571
+ if (
572
+ (kind !== "prompt" && kind !== "skill") ||
573
+ hasClient === hasPair ||
574
+ keys.some(k => !["kind", "clientRef", "commandId", "turnId"].includes(k)) ||
575
+ (hasClient && (!clientRef || clientRef.length > PROMPT_CLIENT_REF_MAX_LENGTH || keys.length !== 2)) ||
576
+ (hasPair && (!input.commandId || !input.turnId || keys.length !== 3))
577
+ )
578
+ return this.#error(request, "invalid_request", false, "turn.result requires kind and exactly one selector");
579
+ // Turn results carry bounded content (≤ TURN_RESULT_CONTENT_MAX_BYTES, 16 KiB)
580
+ // and fit inline within a single query response. Cursored continuation is
581
+ // unreachable: the bounded content cap is well under both RESPONSE_CEILING_BYTES
582
+ // and TARGET_PAGE_BYTES, so no revision is ever created. Reject cursors
583
+ // upfront instead of advertising a continuation path that can never fire.
584
+ if (request.cursor) return this.#error(request, "invalid_cursor", false, "turn.result does not support cursors.");
585
+ if (typeof this.surface.getTurnResult !== "function") return this.#error(request, "unavailable");
586
+ const result = await this.surface.getTurnResult({
587
+ kind,
588
+ ...(hasClient ? { clientRef } : { commandId: input.commandId as string, turnId: input.turnId as string }),
589
+ });
590
+ return { id: request.id, ok: true, result };
591
+ }
535
592
 
536
593
  async #resourceBody(request: QueryRequest): Promise<QueryResponse> {
537
594
  let selector = selectorFor("Q23", request.input);
@@ -566,6 +623,32 @@ export class QueryHandlers {
566
623
  });
567
624
  }
568
625
 
626
+ async #promptTurnResult(request: QueryRequest): Promise<QueryResponse> {
627
+ if (this.surface.installedQueries instanceof Set && !this.surface.installedQueries.has("turn.result"))
628
+ return this.#error(
629
+ request,
630
+ "operation_not_session_owned",
631
+ false,
632
+ "turn.result is not installed for this session.",
633
+ );
634
+ const input = request.input ?? {};
635
+ if (input.kind !== undefined && input.kind !== "prompt")
636
+ return this.#error(request, "invalid_request", false, "turn.prompt_status only accepts prompt selectors.");
637
+ return await this.#turnResult({ ...request, input: { ...input, kind: "prompt" } });
638
+ }
639
+ async #skillTurnResult(request: QueryRequest): Promise<QueryResponse> {
640
+ if (this.surface.installedQueries instanceof Set && !this.surface.installedQueries.has("skill.invoke_status"))
641
+ return this.#error(
642
+ request,
643
+ "operation_not_session_owned",
644
+ false,
645
+ "skill.invoke_status is not installed for this session.",
646
+ );
647
+ const input = request.input ?? {};
648
+ if (input.kind !== undefined && input.kind !== "skill")
649
+ return this.#error(request, "invalid_request", false, "skill.invoke_status only accepts skill selectors.");
650
+ return await this.#turnResult({ ...request, input: { ...input, kind: "skill" } });
651
+ }
569
652
  async #artifact(request: QueryRequest): Promise<QueryResponse> {
570
653
  const input = request.input ?? {};
571
654
  const artifactId = String(input.artifactId ?? "");
@@ -696,102 +779,6 @@ export class QueryHandlers {
696
779
  return { id: request.id, ok: true, page };
697
780
  }
698
781
 
699
- async #skillInvokeStatus(request: QueryRequest): Promise<QueryResponse> {
700
- if (request.cursor)
701
- return this.#error(request, "invalid_request", false, "skill.invoke_status does not support cursors.");
702
- const input = request.input ?? {};
703
- for (const key of Object.keys(input))
704
- if (key !== "commandId" && key !== "turnId" && key !== "clientRef")
705
- return this.#error(
706
- request,
707
- "invalid_request",
708
- false,
709
- `skill.invoke_status does not accept selector field "${key}".`,
710
- );
711
- const commandId = typeof input.commandId === "string" && input.commandId ? input.commandId : undefined;
712
- const turnId = typeof input.turnId === "string" && input.turnId ? input.turnId : undefined;
713
- const rawClientRef = typeof input.clientRef === "string" ? input.clientRef : undefined;
714
- const trimmedClientRef = rawClientRef?.trim();
715
- if (rawClientRef !== undefined && (!trimmedClientRef || trimmedClientRef.length > PROMPT_CLIENT_REF_MAX_LENGTH))
716
- return this.#error(
717
- request,
718
- "invalid_request",
719
- false,
720
- "clientRef must be a non-empty string of at most 128 characters.",
721
- );
722
- const clientRef = trimmedClientRef || undefined;
723
- if ((commandId === undefined) !== (turnId === undefined))
724
- return this.#error(request, "invalid_request", false, "commandId and turnId must be provided together.");
725
- if (commandId !== undefined && clientRef !== undefined)
726
- return this.#error(
727
- request,
728
- "invalid_request",
729
- false,
730
- "Provide exactly one selector: a commandId/turnId pair or a clientRef.",
731
- );
732
- if (commandId === undefined && clientRef === undefined)
733
- return this.#error(
734
- request,
735
- "invalid_request",
736
- false,
737
- "skill.invoke_status requires a commandId/turnId pair or a clientRef.",
738
- );
739
- if (typeof this.surface.getSkillInvokeStatus !== "function")
740
- return this.#error(request, "unavailable", false, "skill.invoke_status is unavailable for this session.");
741
- const result = await this.surface.getSkillInvokeStatus(
742
- clientRef !== undefined ? { clientRef } : { commandId: commandId!, turnId: turnId! },
743
- );
744
- return { id: request.id, ok: true, result };
745
- }
746
-
747
- async #promptStatus(request: QueryRequest): Promise<QueryResponse> {
748
- if (request.cursor)
749
- return this.#error(request, "invalid_request", false, "turn.prompt_status does not support cursors.");
750
- const input = request.input ?? {};
751
- for (const key of Object.keys(input))
752
- if (key !== "commandId" && key !== "turnId" && key !== "clientRef")
753
- return this.#error(
754
- request,
755
- "invalid_request",
756
- false,
757
- `turn.prompt_status does not accept selector field "${key}".`,
758
- );
759
- const commandId = typeof input.commandId === "string" && input.commandId ? input.commandId : undefined;
760
- const turnId = typeof input.turnId === "string" && input.turnId ? input.turnId : undefined;
761
- const rawClientRef = typeof input.clientRef === "string" ? input.clientRef : undefined;
762
- const trimmedClientRef = rawClientRef?.trim();
763
- if (rawClientRef !== undefined && (!trimmedClientRef || trimmedClientRef.length > PROMPT_CLIENT_REF_MAX_LENGTH))
764
- return this.#error(
765
- request,
766
- "invalid_request",
767
- false,
768
- "clientRef must be a non-empty string of at most 128 characters.",
769
- );
770
- const clientRef = trimmedClientRef || undefined;
771
- if ((commandId === undefined) !== (turnId === undefined))
772
- return this.#error(request, "invalid_request", false, "commandId and turnId must be provided together.");
773
- if (commandId !== undefined && clientRef !== undefined)
774
- return this.#error(
775
- request,
776
- "invalid_request",
777
- false,
778
- "Provide exactly one selector: a commandId/turnId pair or a clientRef.",
779
- );
780
- if (commandId === undefined && clientRef === undefined)
781
- return this.#error(
782
- request,
783
- "invalid_request",
784
- false,
785
- "turn.prompt_status requires a commandId/turnId pair or a clientRef.",
786
- );
787
- if (typeof this.surface.getPromptStatus !== "function")
788
- return this.#error(request, "unavailable", false, "turn.prompt_status is unavailable for this session.");
789
- const result = await this.surface.getPromptStatus(
790
- clientRef !== undefined ? { clientRef } : { commandId: commandId!, turnId: turnId! },
791
- );
792
- return { id: request.id, ok: true, result };
793
- }
794
-
795
782
  #error(request: QueryRequest, code: string, restartQuery = false, message = code, details?: unknown): QueryResponse {
796
783
  return {
797
784
  id: request.id,
@@ -802,7 +789,19 @@ export class QueryHandlers {
802
789
  }
803
790
  function selectorFor(queryId: string, input: Record<string, unknown> | undefined): CursorSelector {
804
791
  const selector: CursorSelector = { queryId };
805
- for (const key of ["entryId", "field", "fileId", "hunkId", "resourceKind", "resourceId", "itemId"] as const)
792
+ for (const key of [
793
+ "entryId",
794
+ "field",
795
+ "fileId",
796
+ "hunkId",
797
+ "resourceKind",
798
+ "resourceId",
799
+ "itemId",
800
+ "kind",
801
+ "clientRef",
802
+ "commandId",
803
+ "turnId",
804
+ ] as const)
806
805
  if (input?.[key] !== undefined) selector[key] = String(input[key]);
807
806
  return selector;
808
807
  }
@@ -114,7 +114,7 @@ describe("SDK surface parity", () => {
114
114
  expect([...nativePolicy.installedControls]).toEqual([...loopbackPolicy.installedControls]);
115
115
  expect([...nativePolicy.installedQueries]).toEqual([...loopbackPolicy.installedQueries]);
116
116
  expect(createSdkCapabilities(nativePolicy, false)).toEqual(createSdkCapabilities(loopbackPolicy, false));
117
- expect(nativePolicy.installedQueries).toContain("turn.prompt_status");
117
+ expect(nativePolicy.installedQueries).toContain("turn.result");
118
118
  expect(nativePolicy.installedQueries).toContain("skill.invoke_status");
119
119
  expect(nativePolicy.installedQueries).toContain("session.checkpoint");
120
120
  });
@@ -440,6 +440,7 @@ async function invocationHarness(
440
440
  sendUserMessage?: (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => Promise<void>;
441
441
  invokeSkill?: (name: string, args?: string, options?: PreflightHooks) => Promise<unknown>;
442
442
  abort?: () => void;
443
+ isIdle?: () => boolean;
443
444
  },
444
445
  ): Promise<InvocationHarness> {
445
446
  const waiters = new Map<string, (frame: ResponseFrame) => void>();
@@ -477,7 +478,7 @@ async function invocationHarness(
477
478
  cwd,
478
479
  workflowGate: undefined,
479
480
  sdkBindings: () => (hooks.invokeSkill ? ["invokeSkill"] : []),
480
- isIdle: () => true,
481
+ isIdle: hooks.isIdle ?? (() => true),
481
482
  abort: hooks.abort ?? (() => {}),
482
483
  ...(hooks.invokeSkill ? { invokeSkill: hooks.invokeSkill } : {}),
483
484
  sessionManager: { getSessionId: () => sessionId, getSessionName: () => undefined },
@@ -595,6 +596,102 @@ describe("post-acceptance invocation terminalization", () => {
595
596
  await rm(cwd, { recursive: true, force: true });
596
597
  }
597
598
  });
599
+ test("a completed prompt reports a terminal successful status", async () => {
600
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-completed-prompt-"));
601
+ try {
602
+ const harness = await invocationHarness("terminalize-completed-prompt", cwd, {
603
+ sendUserMessage: async (_content, options) => {
604
+ await options?.onPreflightAcceptCommit?.();
605
+ },
606
+ });
607
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
608
+ expect(accepted.ok).toBe(true);
609
+ const { commandId, turnId } = accepted.result ?? {};
610
+ expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
611
+ status: "terminal_ok",
612
+ terminalAt: expect.any(Number),
613
+ });
614
+ await harness.stop();
615
+ } finally {
616
+ await Bun.sleep(50);
617
+ await rm(cwd, { recursive: true, force: true });
618
+ }
619
+ });
620
+ test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
621
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-followup-"));
622
+ try {
623
+ const turnRunning = Promise.withResolvers<void>();
624
+ const harness = await invocationHarness("terminalize-followup", cwd, {
625
+ sendUserMessage: async (_content, options) => {
626
+ if (options?.deliverAs === "followUp") {
627
+ await options?.onPreflightAcceptCommit?.();
628
+ // #queueFollowUp resolves immediately; the turn has not run yet.
629
+ return;
630
+ }
631
+ await options?.onPreflightAcceptCommit?.();
632
+ await turnRunning.promise;
633
+ },
634
+ });
635
+ const accepted = await harness.control("turn.follow_up", { text: "hello" });
636
+ expect(accepted.ok).toBe(true);
637
+ const { commandId, turnId } = accepted.result ?? {};
638
+ // The follow-up submission must NOT report terminal_ok while the turn is still pending.
639
+ const status = await harness.query("turn.prompt_status", { commandId, turnId });
640
+ expect(status.result?.status).toMatch(/accepted|in_flight|unknown/);
641
+ await harness.stop();
642
+ } finally {
643
+ await Bun.sleep(50);
644
+ await rm(cwd, { recursive: true, force: true });
645
+ }
646
+ });
647
+ test("a prompt queued as steer while streaming is not terminalized before the turn runs", async () => {
648
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-while-busy-"));
649
+ try {
650
+ const harness = await invocationHarness("terminalize-prompt-while-busy", cwd, {
651
+ sendUserMessage: async (_content, options) => {
652
+ // Session is streaming: sendUserMessage diverts to #queueSteer and resolves.
653
+ await options?.onPreflightAcceptCommit?.();
654
+ },
655
+ isIdle: () => false,
656
+ });
657
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
658
+ expect(accepted.ok).toBe(true);
659
+ const { commandId, turnId } = accepted.result ?? {};
660
+ // The diverted prompt must NOT report terminal_ok while the turn is still pending.
661
+ const status = await harness.query("turn.prompt_status", { commandId, turnId });
662
+ expect(status.result?.status).toMatch(/accepted|in_flight|unknown/);
663
+ await harness.stop();
664
+ } finally {
665
+ await Bun.sleep(50);
666
+ await rm(cwd, { recursive: true, force: true });
667
+ }
668
+ });
669
+ test("a queued prompt stays non-terminal even if isIdle flips during the accept window", async () => {
670
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-race-"));
671
+ let idle = false;
672
+ try {
673
+ const harness = await invocationHarness("terminalize-race", cwd, {
674
+ sendUserMessage: async (_content, options) => {
675
+ // The session is streaming when the submission starts (divert to steer).
676
+ // During accept()->persist(), the prior turn unwinds and isIdle flips to true.
677
+ await options?.onPreflightAcceptCommit?.();
678
+ idle = true;
679
+ },
680
+ isIdle: () => idle,
681
+ });
682
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
683
+ expect(accepted.ok).toBe(true);
684
+ const { commandId, turnId } = accepted.result ?? {};
685
+ // The snapshot taken at dispatch time (idle=false) must hold: the prompt was
686
+ // queued, so it must not report terminal_ok even though isIdle is now true.
687
+ const status = await harness.query("turn.prompt_status", { commandId, turnId });
688
+ expect(status.result?.status).toMatch(/accepted|in_flight|unknown/);
689
+ await harness.stop();
690
+ } finally {
691
+ await Bun.sleep(50);
692
+ await rm(cwd, { recursive: true, force: true });
693
+ }
694
+ });
598
695
 
599
696
  test("a pre-acceptance failure rejects the submission without creating a record", async () => {
600
697
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-preflight-"));
@@ -270,6 +270,7 @@ export interface InvocationReconciliation {
270
270
  frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
271
271
  ): Promise<void>;
272
272
  lookup(kind: InvocationKind, selector: { commandId?: string; turnId?: string; clientRef?: string }): unknown;
273
+ lookupResult(kind: InvocationKind, selector: { commandId?: string; turnId?: string; clientRef?: string }): unknown;
273
274
  hydrate(): Promise<void>;
274
275
  }
275
276
 
@@ -473,6 +474,10 @@ export function createInvocationReconciliation(
473
474
  ...(record.error === undefined ? {} : { error: record.error }),
474
475
  };
475
476
  },
477
+ lookupResult(kind, selector) {
478
+ const result = this.lookup(kind, selector) as Record<string, unknown>;
479
+ return result.status === "unknown" ? result : { kind, ...result };
480
+ },
476
481
  hydrate,
477
482
  };
478
483
  }
@@ -487,8 +492,12 @@ export interface SdkSurfaceFactoryOptions {
487
492
  configOverrides?: ReadonlyMap<string, unknown>;
488
493
  /** Session settings; used for model-usage preferences in profile-limit resolution. */
489
494
  settings?: Settings;
490
- promptStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
491
- skillStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
495
+ turnResultLookup?: (selector: {
496
+ kind: "prompt" | "skill";
497
+ commandId?: string;
498
+ turnId?: string;
499
+ clientRef?: string;
500
+ }) => unknown;
492
501
  hostTools?: boolean | (() => boolean);
493
502
  /** Q30 event-ring watermark source (the live session event stream); synchronous. */
494
503
  getEventWatermark?: () => { generation: number; seq: number };
@@ -513,8 +522,12 @@ function createQuerySurface(
513
522
  configOverrides?: ReadonlyMap<string, unknown>;
514
523
  /** Session settings; used for model-usage preferences in profile-limit resolution. */
515
524
  settings?: Settings;
516
- promptStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
517
- skillStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
525
+ turnResultLookup?: (selector: {
526
+ kind: "prompt" | "skill";
527
+ commandId?: string;
528
+ turnId?: string;
529
+ clientRef?: string;
530
+ }) => unknown;
518
531
  hostTools?: boolean | (() => boolean);
519
532
  /** Q30 event-ring watermark source (the live session event stream); synchronous. */
520
533
  getEventWatermark?: () => { generation: number; seq: number };
@@ -782,9 +795,15 @@ function createQuerySurface(
782
795
  getArtifactRange: (artifactId, offset, length) => ctx.getArtifactRange?.(artifactId, offset, length),
783
796
  getJobs: () => ctx.getJobs(),
784
797
  getPromptStatus: (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
785
- (options.promptStatusLookup ?? (value => reconciliation.lookup("prompt", value)))(selector),
798
+ reconciliation.lookup("prompt", selector),
786
799
  getSkillInvokeStatus: (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
787
- (options.skillStatusLookup ?? (value => reconciliation.lookup("skill", value)))(selector),
800
+ reconciliation.lookup("skill", selector),
801
+ getTurnResult: (selector: {
802
+ kind: "prompt" | "skill";
803
+ commandId?: string;
804
+ turnId?: string;
805
+ clientRef?: string;
806
+ }) => (options.turnResultLookup ?? (value => reconciliation.lookupResult(value.kind, value)))(selector),
788
807
  getModelProfiles: async () => {
789
808
  const profiles = ctx.modelRegistry.getModelProfiles();
790
809
  const authenticatedProviders = await collectAuthenticatedProfileProviders(profiles, provider =>
@@ -845,8 +864,7 @@ export function createSdkSurfaceFactory(
845
864
  getLiveState: options.getLiveState,
846
865
  configOverrides: options.configOverrides,
847
866
  settings: options.settings,
848
- promptStatusLookup: options.promptStatusLookup,
849
- skillStatusLookup: options.skillStatusLookup,
867
+ turnResultLookup: options.turnResultLookup,
850
868
  hostTools: options.hostTools,
851
869
  getEventWatermark: options.getEventWatermark,
852
870
  });
@@ -1039,9 +1057,10 @@ function createControlSurface(
1039
1057
  run: (options: {
1040
1058
  onPreflightAccepted: () => void;
1041
1059
  onPreflightAcceptCommit: () => Promise<void>;
1042
- }) => Promise<void>,
1060
+ }) => Promise<unknown>,
1043
1061
  acceptedFields?: () => Record<string, unknown>,
1044
1062
  allowCompletionFallback = false,
1063
+ alwaysQueued = false,
1045
1064
  ): Promise<unknown> => {
1046
1065
  const retainedClientRef = normalizeClientRef(clientRef);
1047
1066
  reconciliation.admit(kind, retainedClientRef);
@@ -1051,6 +1070,7 @@ function createControlSurface(
1051
1070
  let settled = false;
1052
1071
  const accept = async (): Promise<void> => {
1053
1072
  if (settled) return;
1073
+
1054
1074
  try {
1055
1075
  await reconciliation.noteAccepted(kind, correlation, retainedClientRef);
1056
1076
  accepted = true;
@@ -1063,6 +1083,11 @@ function createControlSurface(
1063
1083
  throw error;
1064
1084
  }
1065
1085
  };
1086
+ // Snapshot before run(): if the session is streaming when the submission starts,
1087
+ // sendUserMessage will divert to steer-queue and resolve before the turn runs.
1088
+ // Re-reading ctx.isIdle() after run() would race — accept() does async fs I/O that
1089
+ // yields, so isStreaming can flip during the persist window.
1090
+ const queuedAtDispatch = alwaysQueued || !ctx.isIdle();
1066
1091
  try {
1067
1092
  const submission = Promise.resolve(
1068
1093
  run({
@@ -1071,9 +1096,20 @@ function createControlSurface(
1071
1096
  }),
1072
1097
  );
1073
1098
  void submission.then(
1074
- () => {
1099
+ result => {
1075
1100
  if (settled) {
1076
- if (kind === "skill") void reconciliation.noteTransition(kind, correlation, { type: "agent_end" });
1101
+ // A resolved submission after preflight acceptance means the work is over
1102
+ // for every kind. `noteTransition` ignores an already-terminal record, so
1103
+ // terminalizing here is safe — unless the submission resolved at queue time
1104
+ // (followUp, or a prompt diverted to steer while streaming), in which case
1105
+ // the turn's own lifecycle events drive terminalization.
1106
+ if (!queuedAtDispatch)
1107
+ void reconciliation.noteTransition(kind, correlation, {
1108
+ type: "agent_end",
1109
+ ...(typeof result === "string"
1110
+ ? { content: { version: 1, type: "text", text: result, byteLength: 0, truncated: false } }
1111
+ : {}),
1112
+ });
1077
1113
  return;
1078
1114
  }
1079
1115
  if (allowCompletionFallback) {
@@ -1128,7 +1164,14 @@ function createControlSurface(
1128
1164
  return { commandId: crypto.randomUUID(), accepted: true };
1129
1165
  },
1130
1166
  followUp: async text =>
1131
- submit("prompt", undefined, options => api.sendUserMessage(text, { ...options, deliverAs: "followUp" })),
1167
+ submit(
1168
+ "prompt",
1169
+ undefined,
1170
+ options => api.sendUserMessage(text, { ...options, deliverAs: "followUp" }),
1171
+ undefined,
1172
+ false,
1173
+ true,
1174
+ ),
1132
1175
  abort: () => {
1133
1176
  ctx.abort();
1134
1177
  return { aborted: true };
@@ -1168,7 +1211,7 @@ function createControlSurface(
1168
1211
  onSkillPrepared: meta => {
1169
1212
  prepared = meta;
1170
1213
  },
1171
- }).then(() => undefined),
1214
+ }).then(result => result),
1172
1215
  () => ({
1173
1216
  name: prepared?.name ?? String(name),
1174
1217
  path: prepared?.path ?? "",
@@ -1413,8 +1456,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1413
1456
  id: sessionId,
1414
1457
  api,
1415
1458
  reconciliation,
1416
- promptStatusLookup: selector => reconciliation.lookup("prompt", selector),
1417
- skillStatusLookup: selector => reconciliation.lookup("skill", selector),
1459
+ turnResultLookup: selector => reconciliation.lookupResult(selector.kind, selector),
1418
1460
  configOverrides: options.configOverrides,
1419
1461
  settings: options.settings,
1420
1462
  getEventWatermark: () => eventWatermarkSource(),