@gajae-code/coding-agent 0.16.1 → 0.16.4

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/cli/rows.d.ts +7 -0
  35. package/dist/types/sdk/host/events.d.ts +1 -0
  36. package/dist/types/sdk/host/host.d.ts +2 -0
  37. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  38. package/dist/types/sdk/session-api-key.d.ts +26 -0
  39. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  40. package/dist/types/session/agent-session.d.ts +9 -0
  41. package/dist/types/setup/paseo/announce.d.ts +111 -0
  42. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +2 -1
  45. package/dist/types/tools/fetch.d.ts +7 -0
  46. package/dist/types/tools/index.d.ts +8 -2
  47. package/dist/types/tools/path-utils.d.ts +2 -0
  48. package/dist/types/tools/search.d.ts +6 -0
  49. package/dist/types/utils/external-editor.d.ts +5 -0
  50. package/package.json +7 -7
  51. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  52. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  53. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  54. package/src/cli/args.ts +20 -9
  55. package/src/cli/root-flags.ts +5 -3
  56. package/src/config/autorouting-tier-map.ts +1 -0
  57. package/src/config/file-lock.ts +181 -17
  58. package/src/config/model-preset-registry.ts +5 -3
  59. package/src/config/model-profiles.ts +40 -0
  60. package/src/config/model-registry.ts +57 -11
  61. package/src/config/model-resolver.ts +48 -0
  62. package/src/config/settings-schema.ts +33 -1
  63. package/src/config/settings.ts +1 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  66. package/src/discovery/builtin.ts +13 -4
  67. package/src/discovery/claude-plugins.ts +12 -4
  68. package/src/discovery/helpers.ts +10 -2
  69. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  70. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  71. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  72. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  73. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  74. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  75. package/src/extensibility/shared-events.ts +1 -1
  76. package/src/extensibility/skills.ts +44 -16
  77. package/src/gjc-runtime/repository-binding.ts +1 -1
  78. package/src/gjc-runtime/session-state-lock.ts +94 -6
  79. package/src/internal-urls/docs-index.generated.ts +10 -10
  80. package/src/main.ts +72 -34
  81. package/src/modes/DESIGN.md +54 -0
  82. package/src/modes/acp/acp-agent.ts +29 -14
  83. package/src/modes/components/assistant-message.ts +22 -9
  84. package/src/modes/components/model-selector.ts +166 -33
  85. package/src/modes/components/settings-selector.ts +1075 -235
  86. package/src/modes/components/status-line/command.ts +190 -0
  87. package/src/modes/components/status-line/index.ts +1 -0
  88. package/src/modes/components/status-line/segments.ts +32 -5
  89. package/src/modes/components/status-line/types.ts +5 -0
  90. package/src/modes/components/tool-status-header.ts +393 -21
  91. package/src/modes/controllers/command-controller.ts +2 -0
  92. package/src/modes/controllers/event-controller.ts +148 -5
  93. package/src/modes/controllers/selector-controller.ts +21 -0
  94. package/src/modes/interactive-mode.ts +47 -1
  95. package/src/modes/types.ts +1 -0
  96. package/src/modes/utils/ui-helpers.ts +1 -0
  97. package/src/prompts/system/system-prompt.md +8 -0
  98. package/src/prompts/tools/bash.md +1 -1
  99. package/src/prompts/tools/job.md +1 -1
  100. package/src/prompts/tools/search.md +2 -0
  101. package/src/runtime-mcp/client.ts +60 -17
  102. package/src/runtime-mcp/manager.ts +171 -14
  103. package/src/runtime-mcp/pool.test.ts +209 -0
  104. package/src/runtime-mcp/pool.ts +224 -23
  105. package/src/runtime-mcp/transports/stdio.ts +158 -24
  106. package/src/runtime-mcp/types.ts +24 -0
  107. package/src/sdk/acp/adapter.ts +1 -1
  108. package/src/sdk/broker/broker.ts +5 -4
  109. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  110. package/src/sdk/bus/chat-daemon-runtime.ts +44 -1
  111. package/src/sdk/bus/slack-daemon.ts +92 -2
  112. package/src/sdk/bus/slack-live-provider.ts +12 -0
  113. package/src/sdk/bus/slack-provider.ts +5 -0
  114. package/src/sdk/cli/rows.ts +47 -2
  115. package/src/sdk/cli/session-cli.ts +47 -23
  116. package/src/sdk/host/events.ts +25 -2
  117. package/src/sdk/host/host.ts +4 -1
  118. package/src/sdk/host/session-runtime.test.ts +36 -25
  119. package/src/sdk/host/session-runtime.ts +279 -43
  120. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  121. package/src/sdk/router/session-router.ts +60 -37
  122. package/src/sdk/session-api-key.ts +94 -0
  123. package/src/sdk/session.ts +73 -30
  124. package/src/sdk/transport/serve-cli.ts +3 -3
  125. package/src/session/agent-session.ts +123 -38
  126. package/src/session/contribution-prep.ts +214 -21
  127. package/src/setup/paseo/announce.ts +450 -0
  128. package/src/setup/paseo/setup-deps.ts +15 -3
  129. package/src/slash-commands/builtin-registry.ts +1 -0
  130. package/src/system-prompt.ts +4 -0
  131. package/src/task/executor.ts +2 -1
  132. package/src/task/index.ts +17 -4
  133. package/src/tools/bash.ts +50 -21
  134. package/src/tools/cron.ts +1 -1
  135. package/src/tools/fetch.ts +29 -1
  136. package/src/tools/index.ts +8 -2
  137. package/src/tools/job.ts +8 -2
  138. package/src/tools/monitor.ts +8 -2
  139. package/src/tools/path-utils.ts +7 -0
  140. package/src/tools/search.ts +116 -58
  141. package/src/tools/skill.ts +22 -2
  142. package/src/tools/subagent.ts +3 -1
  143. package/src/tools/tool-catalog.generated.ts +9 -2
  144. package/src/utils/external-editor.ts +16 -2
  145. package/src/web/insane/bridge.ts +113 -23
  146. package/src/web/search/providers/perplexity.ts +102 -41
@@ -1220,6 +1220,7 @@ describe("SessionSdkSessionRuntime", () => {
1220
1220
  const transport = memoryTransport();
1221
1221
  const runtime = new SessionSdkSessionRuntime({
1222
1222
  transport,
1223
+ eventRevision: () => 7,
1223
1224
  control: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { operation: frame.operation } }),
1224
1225
  query: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { query: frame.query } }),
1225
1226
  });
@@ -1239,7 +1240,7 @@ describe("SessionSdkSessionRuntime", () => {
1239
1240
  });
1240
1241
  transport.feed("client", { type: "query_request", id: "query", query: "Q18", input: {} });
1241
1242
  await Bun.sleep(0);
1242
- expect(transport.broadcasts.some(frame => frame.kind === "session_ready")).toBe(true);
1243
+ expect(transport.broadcasts).toContainEqual(expect.objectContaining({ kind: "session_ready", revision: 7 }));
1243
1244
  expect(transport.sent).toEqual(
1244
1245
  expect.arrayContaining([
1245
1246
  expect.objectContaining({ type: "event_replay_result", id: "replay", ok: true }),
@@ -2124,7 +2125,7 @@ describe("SessionSdkSessionRuntime", () => {
2124
2125
  while (!transport.sent.some(frame => frame.id === "predecessor-prompt")) await Bun.sleep(10);
2125
2126
  await handlers.get("agent_start")?.({}, ctx); // lifecycle epoch 1
2126
2127
  await handlers.get("agent_end")?.(
2127
- { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "completed" },
2128
+ { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "compacted" },
2128
2129
  ctx,
2129
2130
  );
2130
2131
  transport.feed("client", {
@@ -2147,7 +2148,7 @@ describe("SessionSdkSessionRuntime", () => {
2147
2148
  while (abortCalls < 1) await Bun.sleep(10);
2148
2149
  await handlers.get("agent_end")?.({ type: "agent_end" }, ctx); // delayed predecessor
2149
2150
  await handlers.get("agent_end")?.(
2150
- { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "completed" },
2151
+ { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "compacted" },
2151
2152
  ctx,
2152
2153
  );
2153
2154
  abortRelease.resolve();
@@ -2833,26 +2834,32 @@ describe("SessionSdkSessionRuntime", () => {
2833
2834
 
2834
2835
  // B's accepted receipt is redacted to {} once its submission rejects,
2835
2836
  // so select its record by the stable clientRef instead.
2836
- // Let the async rejection settle and retire B's pending entry.
2837
- await Bun.sleep(50);
2838
2837
  // The rejected submission must TERMINALIZE as failed (#4668 review
2839
2838
  // P1): agent_failed alone is diagnostic-only, so the settlement also
2840
2839
  // writes agent_end. Before that fix B stayed accepted forever.
2841
2840
  {
2842
2841
  const ids = { clientRef: "fail-b-ref" };
2843
- transport.feed("conn-b", {
2844
- type: "query_request",
2845
- id: "fail-b-status",
2846
- query: "turn.prompt_status",
2847
- input: { kind: "prompt", ...ids },
2848
- } as SdkFrame);
2849
- // waitResponse polls control_response only; queries get query_response.
2850
2842
  const statusDeadline = Date.now() + 15_000;
2851
- while (!transport.sent.some(frame => frame.id === "fail-b-status")) {
2843
+ let status: Record<string, unknown> | undefined;
2844
+ for (let attempt = 0; status === undefined; attempt += 1) {
2845
+ const id = `fail-b-status-${attempt}`;
2846
+ transport.feed("conn-b", {
2847
+ type: "query_request",
2848
+ id,
2849
+ query: "turn.prompt_status",
2850
+ input: { kind: "prompt", ...ids },
2851
+ } as SdkFrame);
2852
+ while (!transport.sent.some(frame => frame.id === id)) {
2853
+ if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
2854
+ await Bun.sleep(20);
2855
+ }
2856
+ const response = transport.sent.find(frame => frame.id === id);
2857
+ const result = response?.result as Record<string, unknown> | undefined;
2858
+ if (result?.status === "failed") status = response;
2859
+ else await Bun.sleep(20);
2852
2860
  if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
2853
- await Bun.sleep(20);
2854
2861
  }
2855
- expect(transport.sent.find(frame => frame.id === "fail-b-status")).toMatchObject({
2862
+ expect(status).toMatchObject({
2856
2863
  ok: true,
2857
2864
  result: expect.objectContaining({ status: "failed" }),
2858
2865
  });
@@ -4765,7 +4772,7 @@ describe("post-acceptance invocation terminalization", () => {
4765
4772
  const harness = await invocationHarness("successor-progress", cwd, {
4766
4773
  settings: {
4767
4774
  get: (key: string) =>
4768
- key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
4775
+ key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
4769
4776
  } as unknown as Settings,
4770
4777
  sendUserMessage: async (_content, options) => {
4771
4778
  prompts += 1;
@@ -4790,7 +4797,7 @@ describe("post-acceptance invocation terminalization", () => {
4790
4797
  await successorStarted.promise;
4791
4798
  for (let index = 0; index < 4; index += 1) {
4792
4799
  await harness.emit("tool_execution_start");
4793
- await Bun.sleep(15);
4800
+ await Bun.sleep(800);
4794
4801
  }
4795
4802
  expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
4796
4803
  result: { status: expect.stringMatching(/accepted|in_flight/) },
@@ -5553,9 +5560,13 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5553
5560
  // renewal wiring at the SDK boundary.
5554
5561
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-renew-attached-"));
5555
5562
  try {
5563
+ const renewalSettings = {
5564
+ get: (key: string) =>
5565
+ key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
5566
+ } as unknown as Settings;
5556
5567
  let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
5557
5568
  const harness = await invocationHarness("renew-attached", cwd, {
5558
- settings: zeroProgressSettings,
5569
+ settings: renewalSettings,
5559
5570
  sendUserMessage: async (content, options) => {
5560
5571
  await options?.onPreflightAcceptCommit?.();
5561
5572
  if (content === "consumed") {
@@ -5574,12 +5585,12 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5574
5585
  expect(consumed.ok).toBe(true);
5575
5586
  promoted?.({ startsOwnRun: false });
5576
5587
  const ids = { commandId: consumed.result?.commandId, turnId: consumed.result?.turnId };
5577
- // Past the 25ms lease, repeated tool activity keeps the attached
5578
- // correlation alive: without renewal it would already be
5579
- // prompt_deadline_exceeded by the first sleep boundary.
5588
+ // Past the initial 2s lease, repeated tool activity keeps the
5589
+ // attached correlation alive. Each interval stays well within one
5590
+ // renewed lease while their cumulative duration exceeds the original.
5580
5591
  for (let i = 0; i < 3; i += 1) {
5581
5592
  await harness.emit("tool_execution_start");
5582
- await Bun.sleep(15);
5593
+ await Bun.sleep(800);
5583
5594
  }
5584
5595
  const midRun = await harness.query("turn.prompt_status", ids);
5585
5596
  expect(midRun.result?.status).not.toBe("failed");
@@ -5783,7 +5794,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5783
5794
  const harness = await invocationHarness("dispatch-race-lease", cwd, {
5784
5795
  settings: {
5785
5796
  get: (key: string) =>
5786
- key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
5797
+ key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
5787
5798
  } as unknown as Settings,
5788
5799
  sendUserMessage: async (content, options) => {
5789
5800
  await options?.onPreflightAcceptCommit?.();
@@ -5808,9 +5819,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5808
5819
  const raced = await harness.control("turn.prompt", { text: "raced" });
5809
5820
  expect(raced.ok).toBe(true);
5810
5821
  const idsRaced = { commandId: raced.result?.commandId, turnId: raced.result?.turnId };
5811
- // Far past the 25ms lease while queued: the acceptance-anchored lease
5822
+ // Far past the 2s lease while queued: the acceptance-anchored lease
5812
5823
  // was dropped at the divert disposition, so no false deadline fire.
5813
- await Bun.sleep(120);
5824
+ await Bun.sleep(2_200);
5814
5825
  const queued = await harness.query("turn.prompt_status", idsRaced);
5815
5826
  expect(queued.result?.status).not.toBe("failed");
5816
5827
  // Real consumption re-leases and attaches to the in-flight run.
@@ -4,7 +4,7 @@ import * as crypto from "node:crypto";
4
4
  import * as fs from "node:fs/promises";
5
5
  import * as path from "node:path";
6
6
  import { promisify } from "node:util";
7
- import { ThinkingLevel } from "@gajae-code/agent-core";
7
+ import { isContinuingMidRunMaintenanceOutcome, ThinkingLevel } from "@gajae-code/agent-core";
8
8
  import type { Api, ImageContent, Model } from "@gajae-code/ai/core";
9
9
  import { logger } from "@gajae-code/utils";
10
10
  import { AsyncJobManager } from "../../async";
@@ -27,7 +27,10 @@ import { resolveModelChainWithAuth, splitSelectorThinkingSuffix } from "../../co
27
27
  import { type ModelSelectorValue, normalizeModelSelectorValue } from "../../config/model-selector-value";
28
28
  import { type Settings, validateSettingPatch } from "../../config/settings";
29
29
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
30
+ import type { AgentEndEvent } from "../../extensibility/shared-events";
30
31
  import { normalizeGoal } from "../../goals/state";
32
+ import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
33
+ import type { AgentSessionEvent } from "../../session/agent-session";
31
34
  import type { SdkRunCapability } from "../../session/sdk-run-capability";
32
35
  import {
33
36
  boundTerminalRetentionState,
@@ -192,6 +195,24 @@ class DiffQueryError extends Error {
192
195
  }
193
196
  }
194
197
 
198
+ /**
199
+ * Normalize an agent-loop stop reason into the published terminal outcome.
200
+ *
201
+ * Only reached for a boundary that actually terminalizes an owned invocation,
202
+ * so every result is a `stopped` disposition: a failed run publishes its own
203
+ * correlated `agent_failed` frame with the sanitized cause instead.
204
+ */
205
+ export function terminalStoppedOutcome(
206
+ stopReason: AgentEndEvent["stopReason"],
207
+ maintenanceOutcome: string | undefined,
208
+ ): Extract<SdkPromptTerminalOutcome, { kind: "stopped" }> {
209
+ // An aborted maintenance checkpoint is the one maintenance path that really
210
+ // ends the run, and it ends it the same way an explicit stop does.
211
+ return stopReason === "cancelled" || maintenanceOutcome === "aborted"
212
+ ? { kind: "stopped", reason: "cancelled", provenance: "client_cancel" }
213
+ : { kind: "stopped", reason: "end_turn", provenance: "agent" };
214
+ }
215
+
195
216
  /** Transport-neutral endpoint contract consumed by the SDK session runtime. */
196
217
  export interface SessionSdkTransport {
197
218
  readonly sessionId: string;
@@ -395,6 +416,28 @@ export class SessionSdkSessionRuntime {
395
416
  this.transport.broadcastFrame?.(event);
396
417
  }
397
418
 
419
+ /**
420
+ * Deliver a frame to specific connections without recording it.
421
+ *
422
+ * Deliberately not `emitEvent`: that assigns a ring position and broadcasts to
423
+ * every authenticated socket. Streamed turn content is high-frequency and
424
+ * unbounded in size, so a count-bounded ring would evict the lifecycle events
425
+ * clients depend on -- including the `agent_end` that settles their prompt --
426
+ * and broadcasting would hand every subscriber a stream only the submitter
427
+ * asked for. Delivery failure is ignored: a disconnected consumer must never
428
+ * disturb the turn producing the content.
429
+ */
430
+ sendFrameTo(connectionIds: Iterable<string>, frame: SdkFrame): void {
431
+ for (const connectionId of connectionIds) {
432
+ try {
433
+ const result = this.transport.sendFrame(connectionId, frame);
434
+ if (result instanceof Promise) result.catch(() => undefined);
435
+ } catch {
436
+ // A dead connection is reaped by the transport's own close handling.
437
+ }
438
+ }
439
+ }
440
+
398
441
  publish(frame: SdkFrame): void {
399
442
  this.emitEvent(frame);
400
443
  }
@@ -577,6 +620,8 @@ function canonicalTerminalOutcome(
577
620
  if (candidate.kind === "stopped") {
578
621
  if (candidate.reason === "cancelled" && candidate.provenance === "client_cancel")
579
622
  return { kind: "stopped", reason: "cancelled", provenance: "client_cancel" };
623
+ if (candidate.reason === "end_turn" && candidate.provenance === "agent")
624
+ return { kind: "stopped", reason: "end_turn", provenance: "agent" };
580
625
  return undefined;
581
626
  }
582
627
  if (candidate.kind === "failed")
@@ -2239,6 +2284,7 @@ function createControlSurface(
2239
2284
  canResolveGate: () => boolean = () => true,
2240
2285
  trackGateResolution: <T>(resolution: Promise<T>) => Promise<T> = async resolution => await resolution,
2241
2286
  onInvocationCompletionReconciledForTests?: (kind: InvocationKind, correlation: InvocationCorrelation) => void,
2287
+ publishLifecycleFrame?: (frame: SdkFrame) => void,
2242
2288
  ): ControlSurface {
2243
2289
  const normalizePromptImages = (value: unknown): ImageContent[] => {
2244
2290
  if (!Array.isArray(value)) return [];
@@ -2376,6 +2422,24 @@ function createControlSurface(
2376
2422
  const correlation = newCorrelation();
2377
2423
  const sdkRunToken = `${correlation.commandId}:${correlation.turnId}`;
2378
2424
  const sdkRunCapability = createSdkRunCapability(sdkRunToken);
2425
+ const publishTerminal = (outcome: InvocationOutcome): void => {
2426
+ publishLifecycleFrame?.({
2427
+ type: "agent_end",
2428
+ sessionId: ctx.sessionManager.getSessionId(),
2429
+ ...correlation,
2430
+ outcome,
2431
+ });
2432
+ };
2433
+ const publishFailure = (error: unknown): void => {
2434
+ const failure = sanitizePromptFailure(error);
2435
+ publishLifecycleFrame?.({
2436
+ type: "agent_failed",
2437
+ sessionId: ctx.sessionManager.getSessionId(),
2438
+ ...correlation,
2439
+ error: failure,
2440
+ });
2441
+ publishTerminal(canonicalFailedOutcome(failure));
2442
+ };
2379
2443
  const preflight = Promise.withResolvers<void>();
2380
2444
  let accepted = false;
2381
2445
  let settled = false;
@@ -2484,11 +2548,25 @@ function createControlSurface(
2484
2548
  retirePendingOwner?.(kind, correlation);
2485
2549
  const attemptTerminalization = async (remaining: number): Promise<void> => {
2486
2550
  try {
2551
+ const before = reconciliation.lookup(kind, correlation) as {
2552
+ status?: string;
2553
+ };
2554
+ const alreadyTerminal = before.status === "terminal_ok" || before.status === "failed";
2487
2555
  await reconciliation.noteTransition(kind, correlation, {
2488
2556
  type: "agent_end",
2489
2557
  ...(typeof result === "string" ? { content: sanitizeTurnResultContent(result) } : {}),
2490
2558
  });
2491
2559
  onInvocationCompletionReconciledForTests?.(kind, correlation);
2560
+ if (!alreadyTerminal) {
2561
+ const settledRecord = reconciliation.lookup(kind, correlation) as {
2562
+ status?: string;
2563
+ outcome?: unknown;
2564
+ error?: { code?: unknown; message?: unknown };
2565
+ };
2566
+ const outcome = canonicalTerminalOutcome(settledRecord.outcome, settledRecord.error);
2567
+ if (outcome?.kind === "failed") publishFailure(settledRecord.error ?? outcome);
2568
+ else if (outcome) publishTerminal(outcome);
2569
+ }
2492
2570
  retirePendingOwner?.(kind, correlation, "always");
2493
2571
  } catch (transitionError) {
2494
2572
  if (kind === "prompt" || kind === "skill") {
@@ -2585,6 +2663,7 @@ function createControlSurface(
2585
2663
  }
2586
2664
  try {
2587
2665
  await reconciliation.noteTransition(kind, correlation, { type: "agent_end" });
2666
+ publishFailure(error);
2588
2667
  } catch (transitionError) {
2589
2668
  if (kind === "prompt" || kind === "skill") {
2590
2669
  retirePendingOwner?.(
@@ -3940,7 +4019,11 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3940
4019
  waitForGateResolutionQuiescence: () => Promise<void>;
3941
4020
  activeInvocation?: { kind: InvocationKind; correlation: InvocationCorrelation };
3942
4021
  drainedInvocations?: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
3943
- attachedInvocations?: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
4022
+ attachedInvocations?: Array<{
4023
+ kind: InvocationKind;
4024
+ correlation: InvocationCorrelation;
4025
+ connectionId: string | undefined;
4026
+ }>;
3944
4027
  openLifecycleBatches: Array<{
3945
4028
  epoch: number;
3946
4029
  invocations: Array<{
@@ -3949,7 +4032,11 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3949
4032
  connectionId: string | undefined;
3950
4033
  sdkRunToken: string;
3951
4034
  }>;
3952
- attachedInvocations: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
4035
+ attachedInvocations: Array<{
4036
+ kind: InvocationKind;
4037
+ correlation: InvocationCorrelation;
4038
+ connectionId: string | undefined;
4039
+ }>;
3953
4040
  }>;
3954
4041
  disposeGate?: () => void;
3955
4042
  lifecycleActive: boolean;
@@ -4142,6 +4229,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4142
4229
  terminalContent?: TurnResultContent,
4143
4230
  terminalHasActivity = false,
4144
4231
  terminalOutcome?: InvocationOutcome,
4232
+ stopReason?: AgentEndEvent["stopReason"],
4233
+ startToken?: string,
4145
4234
  ): Promise<void> => {
4146
4235
  const current = lifecycleOwner?.state ?? active;
4147
4236
  if (!current) return;
@@ -4216,19 +4305,44 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4216
4305
  // turn (queued-while-streaming submissions never push), so a mid-prompt
4217
4306
  // continuation agent_start with an empty queue leaves the current owner
4218
4307
  // untouched (review thread P1).
4219
- const drained = current.pending
4220
- .splice(0)
4308
+ const matchingPending =
4309
+ typeof startToken === "string" && startToken.length > 0
4310
+ ? current.pending.filter(entry => entry.sdkRunToken === startToken)
4311
+ : current.pending.slice();
4312
+ const matchingKeys = new Set(matchingPending.map(entry => entry.sdkRunToken));
4313
+ const pendingSnapshot = current.pending.splice(0);
4314
+ const drained = pendingSnapshot
4315
+ .filter(entry => matchingKeys.has(entry.sdkRunToken))
4221
4316
  .filter(entry => entry.kind !== "prompt" || !current.deadlineManager.isExpiring(entry.correlation));
4317
+ current.pending.push(...pendingSnapshot.filter(entry => !matchingKeys.has(entry.sdkRunToken)));
4318
+ const existingTokenBatch =
4319
+ typeof startToken === "string" && startToken.length > 0
4320
+ ? lifecycleRunOwners.get(startToken)?.batch
4321
+ : undefined;
4222
4322
  if (drained.length > 0) {
4223
- current.openLifecycleBatches.push({
4323
+ const batch: LifecycleBatch = {
4224
4324
  epoch: current.lifecycleEpoch,
4225
4325
  invocations: drained,
4226
4326
  attachedInvocations: [],
4227
- });
4327
+ };
4328
+ current.openLifecycleBatches.push(batch);
4329
+ if (typeof startToken === "string" && startToken.length > 0) {
4330
+ const entry = drained.find(item => item.sdkRunToken === startToken);
4331
+ lifecycleRunOwners.set(startToken, {
4332
+ state: current,
4333
+ batch,
4334
+ ...(entry ? { correlationKey: lifecycleCorrelationKey(entry.correlation) } : {}),
4335
+ });
4336
+ }
4228
4337
  adoptLifecycleBatch(drained);
4229
4338
  if (current.activeInvocation?.kind === "prompt") {
4230
4339
  current.deadlineManager.onAccepted(current.activeInvocation.correlation);
4231
4340
  }
4341
+ } else if (existingTokenBatch && current.openLifecycleBatches.includes(existingTokenBatch)) {
4342
+ // A continuation within the same SDK-owned run carries the same token
4343
+ // but has no newly pending invocation. Preserve the established owner so
4344
+ // later chunks and the final terminal remain directed to its submitters.
4345
+ adoptLifecycleBatch(existingTokenBatch.invocations);
4232
4346
  } else {
4233
4347
  // An empty drain is an agent-initiated successor run. Clear the
4234
4348
  // predecessor's SDK owner and active invocation so abort ownership and
@@ -4240,7 +4354,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4240
4354
  // Pair this agent_end with the oldest unmatched start. A delayed
4241
4355
  // aborted-turn end that lands after a successor agent_start must
4242
4356
  // terminalize the aborted invocation, never the successor.
4243
- const ended = current.openLifecycleBatches[0];
4357
+ const ended = failureBatch ?? current.openLifecycleBatches[0];
4244
4358
  const baseTransitions = ended
4245
4359
  ? ended.invocations.map(({ kind, correlation }) => ({ kind, correlation }))
4246
4360
  : current.activeInvocation
@@ -4364,7 +4478,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4364
4478
  };
4365
4479
  void attempt().finally(() => skillTerminalRecoveryControllers.delete(recoveryKey));
4366
4480
  };
4367
- if (type === "agent_end" && maintenanceOutcome !== undefined && maintenanceOutcome !== "aborted") {
4481
+ if (type === "agent_end" && isContinuingMidRunMaintenanceOutcome(maintenanceOutcome)) {
4368
4482
  try {
4369
4483
  current.runtime.emitEvent({ type, sessionId: ctx.sessionManager.getSessionId() });
4370
4484
  } catch {
@@ -4485,7 +4599,31 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4485
4599
  observed = false;
4486
4600
  }
4487
4601
  }
4602
+ } else if (type === "agent_end" && transitions.length > 0) {
4603
+ // An invocation-owned terminal must carry the identity it was accepted
4604
+ // under and the normalized outcome. A client that submitted this turn
4605
+ // matches the terminal against its own acknowledged correlation and
4606
+ // refuses an unowned one, so the bare `{ type, sessionId }` boundary left
4607
+ // every externally submitted prompt reporting `working` forever even
4608
+ // though the turn had finished -- ACP dropped it as
4609
+ // `incomplete_correlation`, and the notifications runtime has always
4610
+ // stamped both fields for exactly this reason. One frame per invocation,
4611
+ // matching the agent_failed shape above, also lets a shared run attribute
4612
+ // the boundary to each owner instead of one signal nobody can claim. A
4613
+ // continuing maintenance checkpoint never reaches this branch: it returned
4614
+ // above with the one uncorrelated frame, so it cannot settle a live prompt.
4615
+ const outcome = terminalOutcome ?? terminalStoppedOutcome(stopReason, maintenanceOutcome);
4616
+ for (const invocation of transitions) {
4617
+ try {
4618
+ current.runtime.emitEvent({ type, sessionId, ...invocation.correlation, outcome });
4619
+ } catch {
4620
+ observed = false;
4621
+ }
4622
+ }
4488
4623
  } else {
4624
+ // Everything that does not terminalize an owned invocation keeps the one
4625
+ // uncorrelated frame: `agent_start` and a host-originated run (autonomous
4626
+ // continuation, cron, monitor) that owns no invocation.
4489
4627
  try {
4490
4628
  current.runtime.emitEvent({ type, sessionId });
4491
4629
  } catch {
@@ -4495,7 +4633,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4495
4633
  } catch {
4496
4634
  observed = false;
4497
4635
  }
4498
- if (type === "agent_end" && maintenanceOutcome !== undefined && maintenanceOutcome !== "aborted") return;
4636
+ if (type === "agent_end" && isContinuingMidRunMaintenanceOutcome(maintenanceOutcome)) return;
4499
4637
  if (type === "agent_end") {
4500
4638
  if (current.lifecycleEpoch !== eventLifecycleEpoch) {
4501
4639
  // A successor agent_start won the lifecycle race while this event's
@@ -4553,29 +4691,22 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4553
4691
  };
4554
4692
  api.on("agent_start", (event, ctx) => {
4555
4693
  const owner = lifecycleStateForEvent(ctx, "agent_start", event.sdkRunToken);
4556
- return trackLifecycle(async () => {
4557
- await emitLifecycle(
4558
- "agent_start",
4559
- ctx,
4560
- undefined,
4561
- undefined,
4562
- owner ? { state: owner, sessionId: owner.sessionId } : undefined,
4563
- );
4564
- if (owner && typeof event.sdkRunToken === "string" && event.sdkRunToken.length > 0) {
4565
- const batch = owner.openLifecycleBatches.at(-1);
4566
- const entry = batch?.invocations.find(item => item.sdkRunToken === event.sdkRunToken);
4567
- const previous = lifecycleRunOwners.get(event.sdkRunToken);
4568
- lifecycleRunOwners.set(event.sdkRunToken, {
4569
- state: owner,
4570
- batch,
4571
- ...(previous?.correlationKey !== undefined
4572
- ? { correlationKey: previous.correlationKey }
4573
- : entry
4574
- ? { correlationKey: lifecycleCorrelationKey(entry.correlation) }
4575
- : {}),
4576
- });
4577
- }
4578
- }, owner);
4694
+ return trackLifecycle(
4695
+ async () =>
4696
+ emitLifecycle(
4697
+ "agent_start",
4698
+ ctx,
4699
+ undefined,
4700
+ undefined,
4701
+ owner ? { state: owner, sessionId: owner.sessionId } : undefined,
4702
+ undefined,
4703
+ false,
4704
+ undefined,
4705
+ undefined,
4706
+ typeof event.sdkRunToken === "string" ? event.sdkRunToken : undefined,
4707
+ ),
4708
+ owner,
4709
+ );
4579
4710
  });
4580
4711
  api.on("agent_end", (event, ctx) => {
4581
4712
  const tokenBinding =
@@ -4608,11 +4739,20 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4608
4739
  const failureAlreadyPublished = failure === undefined || (hasExistingFailure && genericFailureKeys.length === 0);
4609
4740
  const terminalEvidence = promptTerminalEvidenceFromAgentEnd(event);
4610
4741
  const terminalOutcome =
4611
- event.stopReason === "cancelled"
4612
- ? ({ kind: "stopped", reason: "cancelled", provenance: "client_cancel" } as const)
4613
- : failure === undefined
4614
- ? undefined
4615
- : canonicalFailedOutcome(failure);
4742
+ event.stopReason === "cancelled" ||
4743
+ (event.stopReason === "maintenance" && event.maintenanceOutcome === "aborted")
4744
+ ? terminalStoppedOutcome(
4745
+ event.stopReason,
4746
+ event.stopReason === "maintenance" ? event.maintenanceOutcome : undefined,
4747
+ )
4748
+ : failure !== undefined
4749
+ ? canonicalFailedOutcome(failure)
4750
+ : terminalEvidence.content?.text.trim() || terminalEvidence.hasActivity
4751
+ ? terminalStoppedOutcome(
4752
+ event.stopReason,
4753
+ event.stopReason === "maintenance" ? event.maintenanceOutcome : undefined,
4754
+ )
4755
+ : canonicalFailedOutcome(EMPTY_PROMPT_FAILURE);
4616
4756
  return trackLifecycle(async () => {
4617
4757
  if (failure && !failureAlreadyPublished) {
4618
4758
  for (const key of genericFailureKeys) owner?.failureDiagnosticKeys.delete(key);
@@ -4627,6 +4767,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4627
4767
  terminalEvidence.content,
4628
4768
  terminalEvidence.hasActivity,
4629
4769
  terminalOutcome,
4770
+ event.stopReason,
4630
4771
  );
4631
4772
  }, owner).finally(() => {
4632
4773
  if (typeof event.sdkRunToken === "string" && lifecycleRunOwners.get(event.sdkRunToken)?.state === owner)
@@ -4716,11 +4857,84 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4716
4857
  current.deadlineManager.onAttributableEvent(invocation.correlation, eventType);
4717
4858
  }
4718
4859
  };
4719
- api.on("tool_execution_start", async (_event, ctx) => {
4860
+ /**
4861
+ * Stream one turn event to the connections that submitted the running turn.
4862
+ *
4863
+ * This is the same frame the notifications runtime sends for an ACP-launched
4864
+ * session (`{ type: "event", kind, payload: { event_type, event }, ...correlation }`),
4865
+ * built from the same shared producer, so an ACP client attached to a live
4866
+ * interactive session renders assistant text and tool calls exactly as it does
4867
+ * for a session it started itself. Without it an attached client could drive
4868
+ * the session and see the turn settle but never receive a single word of the
4869
+ * answer.
4870
+ *
4871
+ * Scoped to the owning invocations of the batch that is actually running: a
4872
+ * turn nobody submitted over the SDK (an ordinary terminal prompt, an
4873
+ * autonomous continuation, cron, monitor) has no owner connection and streams
4874
+ * nothing at all, so a session with no attached client pays one map lookup.
4875
+ */
4876
+ const STREAMED_TURN_EVENT_TYPES: ReadonlySet<string> = new Set([
4877
+ "message_update",
4878
+ "message_end",
4879
+ "tool_execution_start",
4880
+ "tool_execution_update",
4881
+ "tool_execution_end",
4882
+ ]);
4883
+ const streamTurnEvent = (event: { type?: unknown } | null | undefined, ctx: ExtensionContext): void => {
4884
+ const current = lifecycleStateForContext(ctx, "agent_start");
4885
+ const activeInvocation = current?.activeInvocation;
4886
+ if (!current) return;
4887
+ const batch = activeInvocation
4888
+ ? current.openLifecycleBatches.find(candidate =>
4889
+ candidate.invocations.some(
4890
+ entry =>
4891
+ entry.correlation.commandId === activeInvocation.correlation.commandId &&
4892
+ entry.correlation.turnId === activeInvocation.correlation.turnId,
4893
+ ),
4894
+ )
4895
+ : undefined;
4896
+ const invocations = batch
4897
+ ? [...batch.invocations, ...batch.attachedInvocations]
4898
+ : current.lifecycleActive
4899
+ ? (current.attachedInvocations ?? [])
4900
+ : [];
4901
+ if (invocations.length === 0) return;
4902
+ // The handlers below also renew prompt deadlines, so streaming must never
4903
+ // throw out of them: a frame the wire producer does not recognize is dropped.
4904
+ if (!event || typeof event.type !== "string" || !STREAMED_TURN_EVENT_TYPES.has(event.type)) return;
4905
+ try {
4906
+ const payload = toAgentWireEventPayload(event as AgentSessionEvent);
4907
+ // One frame per owning invocation, each carrying its own correlation, so a
4908
+ // shared run lets every submitter attribute the content to its own prompt.
4909
+ for (const invocation of invocations) {
4910
+ if (invocation.connectionId === undefined) continue;
4911
+ current.runtime.sendFrameTo([invocation.connectionId], {
4912
+ type: "event",
4913
+ kind: event.type,
4914
+ payload,
4915
+ ...invocation.correlation,
4916
+ });
4917
+ }
4918
+ } catch {
4919
+ // Streamed content is best-effort; the turn producing it is authoritative.
4920
+ }
4921
+ };
4922
+ api.on("tool_execution_start", async (event, ctx) => {
4720
4923
  renewAttributableProgress("tool_execution_start", ctx);
4924
+ streamTurnEvent(event, ctx);
4925
+ });
4926
+ api.on("tool_execution_update", async (event, ctx) => {
4927
+ streamTurnEvent(event, ctx);
4721
4928
  });
4722
- api.on("tool_execution_end", async (_event, ctx) => {
4929
+ api.on("tool_execution_end", async (event, ctx) => {
4723
4930
  renewAttributableProgress("tool_execution_end", ctx);
4931
+ streamTurnEvent(event, ctx);
4932
+ });
4933
+ api.on("message_update", async (event, ctx) => {
4934
+ streamTurnEvent(event, ctx);
4935
+ });
4936
+ api.on("message_end", async (event, ctx) => {
4937
+ streamTurnEvent(event, ctx);
4724
4938
  });
4725
4939
  const errorCode = (error: unknown): string | undefined =>
4726
4940
  typeof error === "object" &&
@@ -4784,7 +4998,11 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4784
4998
  connectionId: string | undefined;
4785
4999
  sdkRunToken: string;
4786
5000
  }>;
4787
- attachedInvocations: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
5001
+ attachedInvocations: Array<{
5002
+ kind: InvocationKind;
5003
+ correlation: InvocationCorrelation;
5004
+ connectionId: string | undefined;
5005
+ }>;
4788
5006
  }> = [];
4789
5007
  const configRevision = { current: 0 };
4790
5008
  let acceptingGateResolutions = true;
@@ -4913,6 +5131,19 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4913
5131
  // 2. Terminal boundary. The durable error from step 1 makes this
4914
5132
  // classify as failed, never terminal_ok.
4915
5133
  await reconciliation.noteTransition(kind, correlation, { type: "agent_end" });
5134
+ const failure = sanitizePromptFailure(Object.assign(new Error(error.message), { code: error.code }));
5135
+ runtime.emitEvent({
5136
+ type: "agent_failed",
5137
+ sessionId,
5138
+ ...correlation,
5139
+ error: failure,
5140
+ });
5141
+ runtime.emitEvent({
5142
+ type: "agent_end",
5143
+ sessionId,
5144
+ ...correlation,
5145
+ outcome: canonicalFailedOutcome(failure),
5146
+ });
4916
5147
  // 3. Release recovery ownership ONLY after durable terminalization.
4917
5148
  deadlineManager.clear(correlation);
4918
5149
  } catch (transitionError) {
@@ -5065,7 +5296,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5065
5296
  entry.correlation.turnId === correlation.turnId,
5066
5297
  )
5067
5298
  )
5068
- current.attachedInvocations.push({ kind, correlation });
5299
+ current.attachedInvocations.push({ kind, correlation, connectionId });
5069
5300
  if (
5070
5301
  activeBatch &&
5071
5302
  !activeBatch.attachedInvocations.some(
@@ -5074,7 +5305,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5074
5305
  entry.correlation.turnId === correlation.turnId,
5075
5306
  )
5076
5307
  )
5077
- activeBatch.attachedInvocations.push({ kind, correlation });
5308
+ activeBatch.attachedInvocations.push({ kind, correlation, connectionId });
5078
5309
  bindPromotedToken(activeBatch);
5079
5310
  return;
5080
5311
  }
@@ -5157,6 +5388,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5157
5388
  () => acceptingGateResolutions,
5158
5389
  trackGateResolution,
5159
5390
  options.onInvocationCompletionReconciledForTests,
5391
+ frame => runtime.emitEvent(frame),
5160
5392
  );
5161
5393
  const installProviderDefinitions = (capability: string, definitions: unknown): void => {
5162
5394
  if (capability === "permission") {
@@ -5224,6 +5456,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5224
5456
  };
5225
5457
  runtime = new SessionSdkSessionRuntime({
5226
5458
  transport,
5459
+ eventRevision: () =>
5460
+ typeof (ctx as Partial<ExtensionContext>).getTranscript === "function"
5461
+ ? ctx.getTranscript().length
5462
+ : undefined,
5227
5463
  masterCapabilityVerify: frame =>
5228
5464
  verifyMasterCapabilityFrame({
5229
5465
  frame,