@wrongstack/acp 1.0.8 → 1.0.10

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.
@@ -117,6 +117,15 @@ export interface RunTurnApi {
117
117
  output: string;
118
118
  exitCode: number | null;
119
119
  }>;
120
+ /**
121
+ * Send a `session/update` notification OUTSIDE a prompt turn (e.g. "a
122
+ * background delegation finished; send any message to continue"). ACP only
123
+ * starts turns on `session/prompt`, so this is the agent's one way to tell
124
+ * an idle client something happened. Resolves `false` and sends nothing once
125
+ * the session was closed or deleted. Optional: embedders that build their
126
+ * own `RunTurnApi` may omit it.
127
+ */
128
+ sendSessionUpdate?(update: unknown): Promise<boolean>;
120
129
  }
121
130
  /**
122
131
  * The agent's per-turn work. Streams `SessionUpdate` notifications to
@@ -20,7 +20,7 @@ export declare function errorToJsonRpc(err: unknown): {
20
20
  message: string;
21
21
  data?: unknown;
22
22
  };
23
- export declare function createRunTurnApi(sessionId: string, clientCapabilities: ClientCapabilities, request: (method: string, params: unknown) => Promise<unknown>): RunTurnApi;
23
+ export declare function createRunTurnApi(sessionId: string, clientCapabilities: ClientCapabilities, request: (method: string, params: unknown) => Promise<unknown>, sendSessionUpdate?: (update: unknown) => Promise<boolean>): RunTurnApi;
24
24
  export declare function buildInitializeResult(agentName: string, modes: readonly SessionMode[], configOptions: readonly SessionConfigOption[]): {
25
25
  protocolVersion: 1;
26
26
  agentCapabilities: {
@@ -37,6 +37,16 @@
37
37
  * `agent.run({signal})` and the underlying provider call observes
38
38
  * it. On abort, the adapter maps the resulting `AbortError` to
39
39
  * `{stopReason: 'cancelled'}`.
40
+ *
41
+ * Background delegations: a background `delegate` result is queued for the
42
+ * session's leader and injected by the core agent loop at its next iteration
43
+ * boundary — on ACP that is the next `session/prompt`, because ACP never
44
+ * starts a turn on its own. So the client is not left guessing, the adapter
45
+ * listens for `leader.delivery_pending` on the session agent's event bus and,
46
+ * while no turn is running, sends one coalesced unprompted `session/update`
47
+ * ("Background delegation <id> finished; send any message to continue.")
48
+ * through `RunTurnApi.sendSessionUpdate`. Nothing is sent after the session is
49
+ * disposed, and a notice failure never reaches the event bus.
40
50
  */
41
51
  import type { Agent, AgentInput } from '@wrongstack/core/agent';
42
52
  import type { ContentBlock, McpServer, PlanEntry, StopReason, ToolKind, UsageCost } from '../types/acp-v1.js';
@@ -69,6 +79,19 @@ export interface ACPServerAgentTurnOptions {
69
79
  maxHistoryEntries?: number | undefined;
70
80
  /** Maximum serialized replay bytes retained per session. Default 8 MiB. */
71
81
  maxHistoryBytes?: number | undefined;
82
+ /**
83
+ * Coalescing window for the unprompted "background delegation finished"
84
+ * notice. Deliveries announced within it share one notice. Default 1500 ms.
85
+ */
86
+ deliveryNoticeDebounceMs?: number | undefined;
87
+ /**
88
+ * Live count of results still queued for the session's leader (the host
89
+ * wires the core leader-delivery hub here). When provided, a notice is sent
90
+ * only while something is actually still pending — so a result the agent
91
+ * loop already drained mid-turn is never announced. When omitted, results
92
+ * announced during a running turn are assumed drained by that turn.
93
+ */
94
+ pendingDeliveries?: ((sessionId: string) => number) | undefined;
72
95
  }
73
96
  /** A recorded conversation turn, replayable on `session/load`. */
74
97
  interface SessionReplayUpdate {
@@ -109,6 +132,14 @@ export interface ACPServerAgentTurn {
109
132
  */
110
133
  export declare function makeACPServerAgentTurn(opts: ACPServerAgentTurnOptions): ACPServerAgentTurn;
111
134
  declare function finitePositiveLimit(value: number | undefined, fallback: number): number;
135
+ declare function finiteNonNegativeLimit(value: number | undefined, fallback: number): number;
136
+ /**
137
+ * True when an event's session id belongs to this ACP session: the ACP id
138
+ * itself, or the id the agent's context is bound to.
139
+ */
140
+ declare function agentOwnsSession(agent: Agent, acpSessionId: string, eventSessionId: unknown): boolean;
141
+ /** The client-facing notice for one or more finished background delegations. */
142
+ declare function deliveryNoticeText(deliveryIds: readonly string[]): string;
112
143
  declare function trimHistory(entries: SessionReplayUpdate[], retainedBytes: number, maxEntries: number, maxBytes: number): number;
113
144
  declare function replayEntryBytes(entry: SessionReplayUpdate): number;
114
145
  /**
@@ -184,6 +215,9 @@ declare function extractUsage(result: unknown): {
184
215
  /** Internal deterministic seams used by the per-file coverage suite. */
185
216
  export declare const serverAgentTurnCoverage: {
186
217
  finitePositiveLimit: typeof finitePositiveLimit;
218
+ finiteNonNegativeLimit: typeof finiteNonNegativeLimit;
219
+ agentOwnsSession: typeof agentOwnsSession;
220
+ deliveryNoticeText: typeof deliveryNoticeText;
187
221
  trimHistory: typeof trimHistory;
188
222
  replayEntryBytes: typeof replayEntryBytes;
189
223
  seedAgentContext: typeof seedAgentContext;
package/dist/agent.js CHANGED
@@ -129,7 +129,7 @@ function errorToJsonRpc(err) {
129
129
  const message = err instanceof Error ? err.message : String(err);
130
130
  return { code: -32603, message };
131
131
  }
132
- function createRunTurnApi(sessionId, clientCapabilities, request) {
132
+ function createRunTurnApi(sessionId, clientCapabilities, request, sendSessionUpdate) {
133
133
  return {
134
134
  clientCapabilities,
135
135
  requestPermission: async (req) => {
@@ -173,7 +173,8 @@ function createRunTurnApi(sessionId, clientCapabilities, request) {
173
173
  } catch {
174
174
  }
175
175
  }
176
- }
176
+ },
177
+ ...sendSessionUpdate ? { sendSessionUpdate } : {}
177
178
  };
178
179
  }
179
180
  function buildInitializeResult(agentName, modes, configOptions) {
@@ -435,7 +436,12 @@ async function handleSessionPromptOp(ctx, id, params) {
435
436
  const api = createRunTurnApi(
436
437
  sessionId,
437
438
  ctx.clientCapabilities ?? {},
438
- (method, req) => ctx.request(method, req)
439
+ (method, req) => ctx.request(method, req),
440
+ async (update) => {
441
+ if (ctx.sessions.get(sessionId) !== session) return false;
442
+ await ctx.sendNotification({ sessionId, update });
443
+ return true;
444
+ }
439
445
  );
440
446
  let result;
441
447
  const pendingNotifications = [];
@@ -880,6 +886,76 @@ function makeACPServerAgentTurn(opts) {
880
886
  const timeoutMs = opts.timeoutMs ?? 5 * 6e4;
881
887
  const maxHistoryEntries = finitePositiveLimit(opts.maxHistoryEntries, 1e3);
882
888
  const maxHistoryBytes = finitePositiveLimit(opts.maxHistoryBytes, 8 * 1024 * 1024);
889
+ const deliveryNoticeDebounceMs = finiteNonNegativeLimit(opts.deliveryNoticeDebounceMs, 1500);
890
+ const notifiers = /* @__PURE__ */ new Map();
891
+ const stillPending = (sessionId) => {
892
+ if (!opts.pendingDeliveries) return true;
893
+ try {
894
+ return opts.pendingDeliveries(sessionId) > 0;
895
+ } catch {
896
+ return false;
897
+ }
898
+ };
899
+ const flushDeliveryNotice = async (sessionId, state) => {
900
+ if (notifiers.get(sessionId) !== state || state.running || state.pending.size === 0) return;
901
+ const api = state.api;
902
+ if (!api?.sendSessionUpdate) return;
903
+ const ids = [...state.pending];
904
+ state.pending.clear();
905
+ if (!stillPending(sessionId)) return;
906
+ try {
907
+ await api.sendSessionUpdate({
908
+ sessionUpdate: "agent_message_chunk",
909
+ content: { type: "text", text: deliveryNoticeText(ids) }
910
+ });
911
+ } catch {
912
+ }
913
+ };
914
+ const scheduleDeliveryNotice = (sessionId) => {
915
+ const state = notifiers.get(sessionId);
916
+ if (!state || state.running || state.pending.size === 0 || state.timer) return;
917
+ state.timer = setTimeout(() => {
918
+ state.timer = void 0;
919
+ void flushDeliveryNotice(sessionId, state);
920
+ }, deliveryNoticeDebounceMs);
921
+ state.timer.unref?.();
922
+ };
923
+ const attachDeliveryNotifier = (sessionId, agent) => {
924
+ const existing = notifiers.get(sessionId);
925
+ if (existing) return existing;
926
+ const bus = agent.events;
927
+ if (!bus?.on) return void 0;
928
+ const state = {
929
+ api: void 0,
930
+ pending: /* @__PURE__ */ new Set(),
931
+ running: false,
932
+ timer: void 0,
933
+ unsubscribe: []
934
+ };
935
+ notifiers.set(sessionId, state);
936
+ const owns = (eventSessionId) => agentOwnsSession(agent, sessionId, eventSessionId);
937
+ state.unsubscribe.push(
938
+ bus.on("leader.delivery_pending", (e) => {
939
+ try {
940
+ if (notifiers.get(sessionId) !== state || !owns(e?.sessionId)) return;
941
+ for (const id of Array.isArray(e.deliveryIds) ? e.deliveryIds : []) {
942
+ if (typeof id === "string" && id.length > 0) state.pending.add(id);
943
+ }
944
+ scheduleDeliveryNotice(sessionId);
945
+ } catch {
946
+ }
947
+ }),
948
+ bus.on("delegation.delivered", (e) => {
949
+ try {
950
+ if (!owns(e?.sessionId) || typeof e.delegationId !== "string") return;
951
+ state.pending.delete(e.delegationId);
952
+ state.pending.delete(`${DELEGATION_DELIVERY_PREFIX}${e.delegationId}`);
953
+ } catch {
954
+ }
955
+ })
956
+ );
957
+ return state;
958
+ };
883
959
  const turn = async (input, emit, api) => {
884
960
  let agent = agents.get(input.sessionId);
885
961
  if (!agent) {
@@ -895,6 +971,16 @@ function makeACPServerAgentTurn(opts) {
895
971
  seedAgentContext(agent, history.get(input.sessionId));
896
972
  }
897
973
  }
974
+ const notifier = attachDeliveryNotifier(input.sessionId, agent);
975
+ if (notifier) {
976
+ if (api) notifier.api = api;
977
+ notifier.running = true;
978
+ notifier.pending.clear();
979
+ if (notifier.timer) {
980
+ clearTimeout(notifier.timer);
981
+ notifier.timer = void 0;
982
+ }
983
+ }
898
984
  const turnAbort = new AbortController();
899
985
  const abortForTimeout = () => turnAbort.abort();
900
986
  const onParentAbort = () => turnAbort.abort();
@@ -1007,6 +1093,11 @@ function makeACPServerAgentTurn(opts) {
1007
1093
  timeouts.delete(input.sessionId);
1008
1094
  input.signal.removeEventListener("abort", onParentAbort);
1009
1095
  for (const u of unsub) u();
1096
+ if (notifier && notifiers.get(input.sessionId) === notifier) {
1097
+ notifier.running = false;
1098
+ if (!opts.pendingDeliveries) notifier.pending.clear();
1099
+ scheduleDeliveryNotice(input.sessionId);
1100
+ }
1010
1101
  }
1011
1102
  };
1012
1103
  const replay = (sessionId) => [...history.get(sessionId) ?? []];
@@ -1027,6 +1118,19 @@ function makeACPServerAgentTurn(opts) {
1027
1118
  const timer = timeouts.get(sessionId);
1028
1119
  if (timer) clearTimeout(timer);
1029
1120
  timeouts.delete(sessionId);
1121
+ const notifier = notifiers.get(sessionId);
1122
+ if (notifier) {
1123
+ notifiers.delete(sessionId);
1124
+ if (notifier.timer) clearTimeout(notifier.timer);
1125
+ notifier.timer = void 0;
1126
+ notifier.pending.clear();
1127
+ for (const off of notifier.unsubscribe) {
1128
+ try {
1129
+ off();
1130
+ } catch {
1131
+ }
1132
+ }
1133
+ }
1030
1134
  agents.delete(sessionId);
1031
1135
  history.delete(sessionId);
1032
1136
  historyBytes.delete(sessionId);
@@ -1037,6 +1141,34 @@ function makeACPServerAgentTurn(opts) {
1037
1141
  function finitePositiveLimit(value, fallback) {
1038
1142
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
1039
1143
  }
1144
+ function finiteNonNegativeLimit(value, fallback) {
1145
+ return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
1146
+ }
1147
+ var DELEGATION_DELIVERY_PREFIX = "delegation:";
1148
+ var NOTICE_MAX_IDS = 5;
1149
+ function normalizeSessionKey(id) {
1150
+ return id.trim().replace(/\\/g, "/");
1151
+ }
1152
+ function agentOwnsSession(agent, acpSessionId, eventSessionId) {
1153
+ if (typeof eventSessionId !== "string" || eventSessionId.trim().length === 0) return false;
1154
+ const target = normalizeSessionKey(eventSessionId);
1155
+ const ctx = agent.ctx;
1156
+ const candidates = [acpSessionId, ctx?.session?.id, ctx?.meta?.["sessionId"]];
1157
+ return candidates.some(
1158
+ (c) => typeof c === "string" && c.length > 0 && normalizeSessionKey(c) === target
1159
+ );
1160
+ }
1161
+ function deliveryNoticeText(deliveryIds) {
1162
+ const ids = deliveryIds.map(
1163
+ (id) => id.startsWith(DELEGATION_DELIVERY_PREFIX) ? id.slice(DELEGATION_DELIVERY_PREFIX.length) : id
1164
+ );
1165
+ if (ids.length === 1) {
1166
+ return `Background delegation ${ids[0]} finished; send any message to continue.`;
1167
+ }
1168
+ const shown = ids.slice(0, NOTICE_MAX_IDS).join(", ");
1169
+ const more = ids.length > NOTICE_MAX_IDS ? ` (+${ids.length - NOTICE_MAX_IDS} more)` : "";
1170
+ return `Background delegations ${shown}${more} finished; send any message to continue.`;
1171
+ }
1040
1172
  function trimHistory(entries, retainedBytes, maxEntries, maxBytes) {
1041
1173
  while (entries.length > maxEntries || retainedBytes > maxBytes) {
1042
1174
  const removed = entries.shift();
@@ -1172,7 +1304,7 @@ function extractUsage(result) {
1172
1304
  const r = result;
1173
1305
  if (typeof r.usage === "object" && r.usage !== null) {
1174
1306
  const u = r.usage;
1175
- if (typeof u.used === "number" && typeof u.size === "number") {
1307
+ if (typeof u.used === "number" && Number.isFinite(u.used) && typeof u.size === "number" && Number.isFinite(u.size)) {
1176
1308
  return {
1177
1309
  used: u.used,
1178
1310
  size: u.size,
@@ -103,6 +103,16 @@ export declare class ACPSession {
103
103
  setProvider(providerId: string, config?: Record<string, unknown>): Promise<void>;
104
104
  disableProvider(): Promise<void>;
105
105
  prompt(blocks: ContentBlock[], signal: AbortSignal, onProgress?: ACPProgressHandler): Promise<ACPSessionRunResult>;
106
+ /**
107
+ * Best-effort cancel for a session the server confirmed after we already
108
+ * stopped waiting (the abort-during-creation race). `session/cancel` is a
109
+ * JSON-RPC notification — the server sends no response, so this is a bare
110
+ * transport send bounded by a timer rather than sendRequest, whose
111
+ * pending-tracking would just expire waiting for a reply that never
112
+ * comes. A failure or timeout is surfaced on the warn channel instead of
113
+ * being silently swallowed.
114
+ */
115
+ private cancelLateSession;
106
116
  private closeSession;
107
117
  close(): Promise<void>;
108
118
  private allocId;
package/dist/client.js CHANGED
@@ -1641,6 +1641,7 @@ function finitePositiveLimit(value, fallback) {
1641
1641
  }
1642
1642
 
1643
1643
  // src/client/acp-session.ts
1644
+ var LATE_CANCEL_SEND_TIMEOUT_MS = 1e4;
1644
1645
  var ACPSession = class _ACPSession {
1645
1646
  transport;
1646
1647
  fileServer;
@@ -1941,8 +1942,62 @@ var ACPSession = class _ACPSession {
1941
1942
  if (signal.aborted) {
1942
1943
  return emptyRunResult("cancelled");
1943
1944
  }
1945
+ let cancelled = false;
1946
+ this.promptCallbackAbort = new AbortController();
1947
+ const onAbort = () => {
1948
+ cancelled = true;
1949
+ this.promptCallbackAbort?.abort();
1950
+ if (this.sessionId) {
1951
+ this.transport.send({
1952
+ jsonrpc: "2.0",
1953
+ method: "session/cancel",
1954
+ params: { sessionId: this.sessionId }
1955
+ }).catch(() => {
1956
+ });
1957
+ }
1958
+ };
1959
+ signal.addEventListener("abort", onAbort, { once: true });
1960
+ let rejectCreate;
1961
+ const onCreateAbort = () => {
1962
+ signal.removeEventListener("abort", onAbort);
1963
+ rejectCreate?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
1964
+ };
1944
1965
  if (!this.sessionId) {
1945
- this.sessionId = await this.createSessionWithAuth();
1966
+ let sessionId;
1967
+ const createPromise = this.createSessionWithAuth();
1968
+ try {
1969
+ sessionId = await Promise.race([
1970
+ createPromise,
1971
+ new Promise((_, reject) => {
1972
+ rejectCreate = reject;
1973
+ if (signal.aborted) {
1974
+ reject(new ACPSessionError("aborted", "prompt was aborted by the parent"));
1975
+ return;
1976
+ }
1977
+ signal.addEventListener("abort", onCreateAbort, { once: true });
1978
+ })
1979
+ ]);
1980
+ } catch (err) {
1981
+ signal.removeEventListener("abort", onAbort);
1982
+ signal.removeEventListener("abort", onCreateAbort);
1983
+ rejectCreate = void 0;
1984
+ this.promptCallbackAbort?.abort();
1985
+ this.promptCallbackAbort = null;
1986
+ if (err instanceof ACPSessionError && err.kind === "aborted") {
1987
+ this.cancelLateSession(createPromise);
1988
+ return emptyRunResult("cancelled");
1989
+ }
1990
+ throw err;
1991
+ }
1992
+ signal.removeEventListener("abort", onCreateAbort);
1993
+ rejectCreate = void 0;
1994
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
1995
+ signal.removeEventListener("abort", onAbort);
1996
+ this.promptCallbackAbort?.abort();
1997
+ this.promptCallbackAbort = null;
1998
+ throw new ACPSessionError("protocol_error", "session/new returned no session id");
1999
+ }
2000
+ this.sessionId = sessionId;
1946
2001
  }
1947
2002
  if (signal.aborted) {
1948
2003
  return emptyRunResult("cancelled");
@@ -1950,42 +2005,46 @@ var ACPSession = class _ACPSession {
1950
2005
  this.resetScratch();
1951
2006
  this.progressHandler = onProgress ?? null;
1952
2007
  const promptId = this.allocId();
1953
- const turnPromise = this.sendRequest(
1954
- promptId,
1955
- "session/prompt",
1956
- {
1957
- sessionId: this.sessionId,
1958
- prompt: blocks
1959
- },
1960
- this.timeoutMs
1961
- );
1962
- let cancelled = false;
1963
- this.promptCallbackAbort = new AbortController();
1964
- const onAbort = () => {
1965
- cancelled = true;
1966
- this.promptCallbackAbort?.abort();
1967
- this.transport.send({
1968
- jsonrpc: "2.0",
1969
- method: "session/cancel",
1970
- params: { sessionId: this.sessionId }
1971
- }).catch(() => {
1972
- });
2008
+ let rejectTurn;
2009
+ const onTurnAbort = () => {
2010
+ rejectTurn?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
1973
2011
  };
1974
- signal.addEventListener("abort", onAbort, { once: true });
2012
+ signal.addEventListener("abort", onTurnAbort, { once: true });
2013
+ const turnPromise = Promise.race([
2014
+ this.sendRequest(
2015
+ promptId,
2016
+ "session/prompt",
2017
+ {
2018
+ sessionId: this.sessionId,
2019
+ prompt: blocks
2020
+ },
2021
+ this.timeoutMs
2022
+ ),
2023
+ new Promise((_, reject) => {
2024
+ rejectTurn = reject;
2025
+ })
2026
+ ]);
1975
2027
  this.state = "prompting";
1976
2028
  let response;
1977
2029
  try {
1978
2030
  response = await turnPromise;
1979
2031
  } catch (err) {
1980
2032
  this.state = "done";
1981
- signal.removeEventListener("abort", onAbort);
1982
- if (cancelled || signal.aborted) {
1983
- throw new ACPSessionError("aborted", "prompt was aborted by the parent");
2033
+ const abortedKind = err instanceof ACPSessionError && err.kind === "aborted";
2034
+ if (cancelled || abortedKind) {
2035
+ return emptyRunResult("cancelled");
1984
2036
  }
1985
2037
  const msg = err instanceof Error ? err.message : String(err);
2038
+ if (signal.aborted) {
2039
+ throw new ACPSessionError("aborted", "prompt was aborted by the parent", err);
2040
+ }
1986
2041
  throw new ACPSessionError("prompt_failed", `session/prompt failed: ${msg}`, err);
1987
2042
  } finally {
1988
2043
  signal.removeEventListener("abort", onAbort);
2044
+ signal.removeEventListener("abort", onTurnAbort);
2045
+ signal.removeEventListener("abort", onCreateAbort);
2046
+ rejectTurn = void 0;
2047
+ rejectCreate = void 0;
1989
2048
  this.promptCallbackAbort?.abort();
1990
2049
  this.promptCallbackAbort = null;
1991
2050
  this.progressHandler = null;
@@ -2007,6 +2066,61 @@ var ACPSession = class _ACPSession {
2007
2066
  thoughts: this.scratch.thoughts
2008
2067
  };
2009
2068
  }
2069
+ /**
2070
+ * Best-effort cancel for a session the server confirmed after we already
2071
+ * stopped waiting (the abort-during-creation race). `session/cancel` is a
2072
+ * JSON-RPC notification — the server sends no response, so this is a bare
2073
+ * transport send bounded by a timer rather than sendRequest, whose
2074
+ * pending-tracking would just expire waiting for a reply that never
2075
+ * comes. A failure or timeout is surfaced on the warn channel instead of
2076
+ * being silently swallowed.
2077
+ */
2078
+ cancelLateSession(createPromise) {
2079
+ createPromise.then(async (lateId) => {
2080
+ try {
2081
+ await new Promise((resolve3, reject) => {
2082
+ const timer = setTimeout(() => {
2083
+ reject(new Error("late session/cancel send timed out"));
2084
+ }, LATE_CANCEL_SEND_TIMEOUT_MS);
2085
+ Promise.resolve(
2086
+ this.transport.send({
2087
+ jsonrpc: "2.0",
2088
+ method: "session/cancel",
2089
+ params: { sessionId: lateId }
2090
+ })
2091
+ ).then(
2092
+ () => {
2093
+ clearTimeout(timer);
2094
+ resolve3();
2095
+ },
2096
+ (sendErr) => {
2097
+ clearTimeout(timer);
2098
+ reject(sendErr instanceof Error ? sendErr : new Error(String(sendErr)));
2099
+ }
2100
+ );
2101
+ });
2102
+ } catch (err) {
2103
+ const message = err instanceof Error ? err.message : String(err);
2104
+ console.warn(
2105
+ JSON.stringify({
2106
+ level: "warn",
2107
+ event: "acp_session.late_cancel_failed",
2108
+ sessionId: lateId,
2109
+ message
2110
+ })
2111
+ );
2112
+ }
2113
+ }).catch((reason) => {
2114
+ const message = reason instanceof Error ? reason.message : String(reason);
2115
+ console.warn(
2116
+ JSON.stringify({
2117
+ level: "warn",
2118
+ event: "acp_session.late_cancel_failed",
2119
+ reason: message
2120
+ })
2121
+ );
2122
+ });
2123
+ }
2010
2124
  async closeSession() {
2011
2125
  if (!this.sessionId) return;
2012
2126
  const sid = this.sessionId;
package/dist/index.js CHANGED
@@ -132,7 +132,7 @@ function errorToJsonRpc(err) {
132
132
  const message = err instanceof Error ? err.message : String(err);
133
133
  return { code: -32603, message };
134
134
  }
135
- function createRunTurnApi(sessionId, clientCapabilities, request) {
135
+ function createRunTurnApi(sessionId, clientCapabilities, request, sendSessionUpdate) {
136
136
  return {
137
137
  clientCapabilities,
138
138
  requestPermission: async (req) => {
@@ -176,7 +176,8 @@ function createRunTurnApi(sessionId, clientCapabilities, request) {
176
176
  } catch {
177
177
  }
178
178
  }
179
- }
179
+ },
180
+ ...sendSessionUpdate ? { sendSessionUpdate } : {}
180
181
  };
181
182
  }
182
183
  function buildInitializeResult(agentName, modes, configOptions) {
@@ -438,7 +439,12 @@ async function handleSessionPromptOp(ctx, id, params) {
438
439
  const api = createRunTurnApi(
439
440
  sessionId,
440
441
  ctx.clientCapabilities ?? {},
441
- (method, req) => ctx.request(method, req)
442
+ (method, req) => ctx.request(method, req),
443
+ async (update) => {
444
+ if (ctx.sessions.get(sessionId) !== session) return false;
445
+ await ctx.sendNotification({ sessionId, update });
446
+ return true;
447
+ }
442
448
  );
443
449
  let result;
444
450
  const pendingNotifications = [];
@@ -3044,6 +3050,7 @@ function finitePositiveLimit(value, fallback) {
3044
3050
  }
3045
3051
 
3046
3052
  // src/client/acp-session.ts
3053
+ var LATE_CANCEL_SEND_TIMEOUT_MS = 1e4;
3047
3054
  var ACPSession = class _ACPSession {
3048
3055
  transport;
3049
3056
  fileServer;
@@ -3344,8 +3351,62 @@ var ACPSession = class _ACPSession {
3344
3351
  if (signal.aborted) {
3345
3352
  return emptyRunResult("cancelled");
3346
3353
  }
3354
+ let cancelled = false;
3355
+ this.promptCallbackAbort = new AbortController();
3356
+ const onAbort = () => {
3357
+ cancelled = true;
3358
+ this.promptCallbackAbort?.abort();
3359
+ if (this.sessionId) {
3360
+ this.transport.send({
3361
+ jsonrpc: "2.0",
3362
+ method: "session/cancel",
3363
+ params: { sessionId: this.sessionId }
3364
+ }).catch(() => {
3365
+ });
3366
+ }
3367
+ };
3368
+ signal.addEventListener("abort", onAbort, { once: true });
3369
+ let rejectCreate;
3370
+ const onCreateAbort = () => {
3371
+ signal.removeEventListener("abort", onAbort);
3372
+ rejectCreate?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
3373
+ };
3347
3374
  if (!this.sessionId) {
3348
- this.sessionId = await this.createSessionWithAuth();
3375
+ let sessionId;
3376
+ const createPromise = this.createSessionWithAuth();
3377
+ try {
3378
+ sessionId = await Promise.race([
3379
+ createPromise,
3380
+ new Promise((_, reject) => {
3381
+ rejectCreate = reject;
3382
+ if (signal.aborted) {
3383
+ reject(new ACPSessionError("aborted", "prompt was aborted by the parent"));
3384
+ return;
3385
+ }
3386
+ signal.addEventListener("abort", onCreateAbort, { once: true });
3387
+ })
3388
+ ]);
3389
+ } catch (err) {
3390
+ signal.removeEventListener("abort", onAbort);
3391
+ signal.removeEventListener("abort", onCreateAbort);
3392
+ rejectCreate = void 0;
3393
+ this.promptCallbackAbort?.abort();
3394
+ this.promptCallbackAbort = null;
3395
+ if (err instanceof ACPSessionError && err.kind === "aborted") {
3396
+ this.cancelLateSession(createPromise);
3397
+ return emptyRunResult("cancelled");
3398
+ }
3399
+ throw err;
3400
+ }
3401
+ signal.removeEventListener("abort", onCreateAbort);
3402
+ rejectCreate = void 0;
3403
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
3404
+ signal.removeEventListener("abort", onAbort);
3405
+ this.promptCallbackAbort?.abort();
3406
+ this.promptCallbackAbort = null;
3407
+ throw new ACPSessionError("protocol_error", "session/new returned no session id");
3408
+ }
3409
+ this.sessionId = sessionId;
3349
3410
  }
3350
3411
  if (signal.aborted) {
3351
3412
  return emptyRunResult("cancelled");
@@ -3353,42 +3414,46 @@ var ACPSession = class _ACPSession {
3353
3414
  this.resetScratch();
3354
3415
  this.progressHandler = onProgress ?? null;
3355
3416
  const promptId = this.allocId();
3356
- const turnPromise = this.sendRequest(
3357
- promptId,
3358
- "session/prompt",
3359
- {
3360
- sessionId: this.sessionId,
3361
- prompt: blocks
3362
- },
3363
- this.timeoutMs
3364
- );
3365
- let cancelled = false;
3366
- this.promptCallbackAbort = new AbortController();
3367
- const onAbort = () => {
3368
- cancelled = true;
3369
- this.promptCallbackAbort?.abort();
3370
- this.transport.send({
3371
- jsonrpc: "2.0",
3372
- method: "session/cancel",
3373
- params: { sessionId: this.sessionId }
3374
- }).catch(() => {
3375
- });
3417
+ let rejectTurn;
3418
+ const onTurnAbort = () => {
3419
+ rejectTurn?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
3376
3420
  };
3377
- signal.addEventListener("abort", onAbort, { once: true });
3421
+ signal.addEventListener("abort", onTurnAbort, { once: true });
3422
+ const turnPromise = Promise.race([
3423
+ this.sendRequest(
3424
+ promptId,
3425
+ "session/prompt",
3426
+ {
3427
+ sessionId: this.sessionId,
3428
+ prompt: blocks
3429
+ },
3430
+ this.timeoutMs
3431
+ ),
3432
+ new Promise((_, reject) => {
3433
+ rejectTurn = reject;
3434
+ })
3435
+ ]);
3378
3436
  this.state = "prompting";
3379
3437
  let response;
3380
3438
  try {
3381
3439
  response = await turnPromise;
3382
3440
  } catch (err) {
3383
3441
  this.state = "done";
3384
- signal.removeEventListener("abort", onAbort);
3385
- if (cancelled || signal.aborted) {
3386
- throw new ACPSessionError("aborted", "prompt was aborted by the parent");
3442
+ const abortedKind = err instanceof ACPSessionError && err.kind === "aborted";
3443
+ if (cancelled || abortedKind) {
3444
+ return emptyRunResult("cancelled");
3387
3445
  }
3388
3446
  const msg = err instanceof Error ? err.message : String(err);
3447
+ if (signal.aborted) {
3448
+ throw new ACPSessionError("aborted", "prompt was aborted by the parent", err);
3449
+ }
3389
3450
  throw new ACPSessionError("prompt_failed", `session/prompt failed: ${msg}`, err);
3390
3451
  } finally {
3391
3452
  signal.removeEventListener("abort", onAbort);
3453
+ signal.removeEventListener("abort", onTurnAbort);
3454
+ signal.removeEventListener("abort", onCreateAbort);
3455
+ rejectTurn = void 0;
3456
+ rejectCreate = void 0;
3392
3457
  this.promptCallbackAbort?.abort();
3393
3458
  this.promptCallbackAbort = null;
3394
3459
  this.progressHandler = null;
@@ -3410,6 +3475,61 @@ var ACPSession = class _ACPSession {
3410
3475
  thoughts: this.scratch.thoughts
3411
3476
  };
3412
3477
  }
3478
+ /**
3479
+ * Best-effort cancel for a session the server confirmed after we already
3480
+ * stopped waiting (the abort-during-creation race). `session/cancel` is a
3481
+ * JSON-RPC notification — the server sends no response, so this is a bare
3482
+ * transport send bounded by a timer rather than sendRequest, whose
3483
+ * pending-tracking would just expire waiting for a reply that never
3484
+ * comes. A failure or timeout is surfaced on the warn channel instead of
3485
+ * being silently swallowed.
3486
+ */
3487
+ cancelLateSession(createPromise) {
3488
+ createPromise.then(async (lateId) => {
3489
+ try {
3490
+ await new Promise((resolve4, reject) => {
3491
+ const timer = setTimeout(() => {
3492
+ reject(new Error("late session/cancel send timed out"));
3493
+ }, LATE_CANCEL_SEND_TIMEOUT_MS);
3494
+ Promise.resolve(
3495
+ this.transport.send({
3496
+ jsonrpc: "2.0",
3497
+ method: "session/cancel",
3498
+ params: { sessionId: lateId }
3499
+ })
3500
+ ).then(
3501
+ () => {
3502
+ clearTimeout(timer);
3503
+ resolve4();
3504
+ },
3505
+ (sendErr) => {
3506
+ clearTimeout(timer);
3507
+ reject(sendErr instanceof Error ? sendErr : new Error(String(sendErr)));
3508
+ }
3509
+ );
3510
+ });
3511
+ } catch (err) {
3512
+ const message = err instanceof Error ? err.message : String(err);
3513
+ console.warn(
3514
+ JSON.stringify({
3515
+ level: "warn",
3516
+ event: "acp_session.late_cancel_failed",
3517
+ sessionId: lateId,
3518
+ message
3519
+ })
3520
+ );
3521
+ }
3522
+ }).catch((reason) => {
3523
+ const message = reason instanceof Error ? reason.message : String(reason);
3524
+ console.warn(
3525
+ JSON.stringify({
3526
+ level: "warn",
3527
+ event: "acp_session.late_cancel_failed",
3528
+ reason: message
3529
+ })
3530
+ );
3531
+ });
3532
+ }
3413
3533
  async closeSession() {
3414
3534
  if (!this.sessionId) return;
3415
3535
  const sid = this.sessionId;
@@ -136,7 +136,7 @@ function errorToJsonRpc(err) {
136
136
  const message = err instanceof Error ? err.message : String(err);
137
137
  return { code: -32603, message };
138
138
  }
139
- function createRunTurnApi(sessionId, clientCapabilities, request) {
139
+ function createRunTurnApi(sessionId, clientCapabilities, request, sendSessionUpdate) {
140
140
  return {
141
141
  clientCapabilities,
142
142
  requestPermission: async (req) => {
@@ -180,7 +180,8 @@ function createRunTurnApi(sessionId, clientCapabilities, request) {
180
180
  } catch {
181
181
  }
182
182
  }
183
- }
183
+ },
184
+ ...sendSessionUpdate ? { sendSessionUpdate } : {}
184
185
  };
185
186
  }
186
187
  function buildInitializeResult(agentName, modes, configOptions) {
@@ -442,7 +443,12 @@ async function handleSessionPromptOp(ctx, id, params) {
442
443
  const api = createRunTurnApi(
443
444
  sessionId,
444
445
  ctx.clientCapabilities ?? {},
445
- (method, req) => ctx.request(method, req)
446
+ (method, req) => ctx.request(method, req),
447
+ async (update) => {
448
+ if (ctx.sessions.get(sessionId) !== session) return false;
449
+ await ctx.sendNotification({ sessionId, update });
450
+ return true;
451
+ }
446
452
  );
447
453
  let result;
448
454
  const pendingNotifications = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/acp",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "license": "MIT",
5
5
  "description": "ACP (Agent Client Protocol) integration for WrongStack — client + agent support",
6
6
  "keywords": [
@@ -52,13 +52,13 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@agentclientprotocol/sdk": "^1.4.0",
55
- "@wrongstack/core": "1.0.8",
56
- "@wrongstack/primitives": "1.0.8"
55
+ "@wrongstack/core": "1.0.10",
56
+ "@wrongstack/primitives": "1.0.10"
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^26.2.0",
59
+ "@types/node": "^26.5.1",
60
60
  "typescript": "^7.0.2",
61
- "vitest": "^4.1.11"
61
+ "vitest": "^5.0.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"