@nuvin/session 0.1.1-rc.2 → 0.2.0-rc.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.
Files changed (51) hide show
  1. package/dist/{chunk-WB4FEWBI.js → chunk-3CVYVANJ.js} +1 -1
  2. package/dist/chunk-7LEIACBZ.js +85 -0
  3. package/dist/{chunk-COVRYM64.js → chunk-KQHHBUVY.js} +137 -47
  4. package/dist/client/daemon-client.d.ts.map +1 -1
  5. package/dist/client/data-client.d.ts +10 -15
  6. package/dist/client/data-client.d.ts.map +1 -1
  7. package/dist/client/directory.d.ts +12 -14
  8. package/dist/client/directory.d.ts.map +1 -1
  9. package/dist/client/http-data-client.d.ts +5 -0
  10. package/dist/client/http-data-client.d.ts.map +1 -1
  11. package/dist/client/index.js +299 -40
  12. package/dist/client/session-client.d.ts +30 -4
  13. package/dist/client/session-client.d.ts.map +1 -1
  14. package/dist/client/websocket.d.ts.map +1 -1
  15. package/dist/controller/agent-channel.d.ts +10 -5
  16. package/dist/controller/agent-channel.d.ts.map +1 -1
  17. package/dist/controller/index.js +323 -48
  18. package/dist/controller/session-controller.d.ts +15 -14
  19. package/dist/controller/session-controller.d.ts.map +1 -1
  20. package/dist/controller/test-utils.d.ts +3 -1
  21. package/dist/controller/test-utils.d.ts.map +1 -1
  22. package/dist/protocol/data-rpc.d.ts +13 -14
  23. package/dist/protocol/data-rpc.d.ts.map +1 -1
  24. package/dist/protocol/history-text.d.ts +4 -0
  25. package/dist/protocol/history-text.d.ts.map +1 -0
  26. package/dist/protocol/index.d.ts +1 -0
  27. package/dist/protocol/index.d.ts.map +1 -1
  28. package/dist/protocol/index.js +12 -4
  29. package/dist/protocol/model-identity.d.ts +7 -0
  30. package/dist/protocol/model-identity.d.ts.map +1 -1
  31. package/dist/protocol/types.d.ts +339 -35
  32. package/dist/protocol/types.d.ts.map +1 -1
  33. package/dist/protocol/version.d.ts +1 -1
  34. package/dist/state/approvals.d.ts +4 -15
  35. package/dist/state/approvals.d.ts.map +1 -1
  36. package/dist/state/index.d.ts +2 -0
  37. package/dist/state/index.d.ts.map +1 -1
  38. package/dist/state/index.js +15 -9
  39. package/dist/state/messages.d.ts +12 -2
  40. package/dist/state/messages.d.ts.map +1 -1
  41. package/dist/state/questions.d.ts +5 -0
  42. package/dist/state/questions.d.ts.map +1 -0
  43. package/dist/state/session.d.ts.map +1 -1
  44. package/dist/state/tool-key.d.ts +2 -0
  45. package/dist/state/tool-key.d.ts.map +1 -0
  46. package/dist/state/tool-preview.d.ts +5 -0
  47. package/dist/state/tool-preview.d.ts.map +1 -1
  48. package/dist/state/tool-preview.js +81 -29
  49. package/dist/test-utils/index.js +2 -2
  50. package/package.json +3 -3
  51. package/dist/chunk-7HGJO7ZX.js +0 -9
@@ -1 +1 @@
1
- {"version":3,"file":"agent-channel.d.ts","sourceRoot":"","sources":["../../src/controller/agent-channel.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EACV,cAAc,EACd,sBAAsB,EACtB,OAAO,EACP,2BAA2B,EAC3B,YAAY,EACb,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,kEAAkE;AAClE,MAAM,MAAM,oBAAoB,GAAG;IACjC,aAAa,EAAE,OAAO,EAAE,CAAC;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,UAAU,EAAE,CAAC;CACxB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,YAAY,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE,iBAAiB,KAAK,IAAI,CAAC;AACtE,MAAM,MAAM,qBAAqB,GAAG,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;AAE3E,MAAM,MAAM,eAAe,GAAG,CAC5B,OAAO,EAAE,eAAe,KACrB,OAAO,CAAC,2BAA2B,CAAC,GAAG,2BAA2B,CAAC;AAExE,MAAM,MAAM,mBAAmB,GAAG,CAChC,OAAO,EAAE,sBAAsB,KAC5B,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAAC;AAO9C;;;;;;;;;GASG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsB;IAC9C,OAAO,CAAC,OAAO,CAAgC;IAC/C,OAAO,CAAC,eAAe,CAAoC;IAI3D,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI;IAI9D,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,GAAG,IAAI;IAIxD,mBAAmB,CACjB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,2BAA2B,CAAC,GAAG,2BAA2B;IAOrE,mBAAmB,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,cAAc,CAAC;IAS7E,OAAO,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,IAAI;IAOjD,eAAe,CAAC,QAAQ,EAAE,qBAAqB,GAAG,MAAM,IAAI;IAO5D,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,GAAG,IAAI;IAIrD,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,GAAG,IAAI;CAG9D"}
1
+ {"version":3,"file":"agent-channel.d.ts","sourceRoot":"","sources":["../../src/controller/agent-channel.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EACV,cAAc,EACd,sBAAsB,EACtB,OAAO,EACP,mBAAmB,EACnB,2BAA2B,EAC3B,YAAY,EACb,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EACV,6BAA6B,EAC7B,gBAAgB,EAChB,kCAAkC,EAClC,4BAA4B,EAC5B,yBAAyB,EAC1B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,8DAA8D;AAC9D,MAAM,MAAM,oBAAoB,GAAG;IACjC,aAAa,EAAE,OAAO,EAAE,CAAC;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,UAAU,EAAE,CAAC;CACxB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,6BAA6B,CAAC;IAC3C,gFAAgF;IAChF,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,mBAAmB,CAAC;IACtC,qBAAqB,CAAC,EAAE,CACtB,OAAO,EAAE,4BAA4B,KAClC,OAAO,CAAC,kCAAkC,CAAC,CAAC;IACjD,QAAQ,EAAE,YAAY,CAAC;IACvB,kBAAkB,CAAC,EAAE,yBAAyB,CAAC;CAChD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE,iBAAiB,KAAK,IAAI,CAAC;AACtE,MAAM,MAAM,qBAAqB,GAAG,CAAC,MAAM,EAAE,oBAAoB,KAAK,IAAI,CAAC;AAE3E,MAAM,MAAM,eAAe,GAAG,CAC5B,OAAO,EAAE,eAAe,KACrB,OAAO,CAAC,2BAA2B,CAAC,GAAG,2BAA2B,CAAC;AAExE,MAAM,MAAM,mBAAmB,GAAG,CAChC,OAAO,EAAE,sBAAsB,EAC/B,KAAK,CAAC,EAAE,eAAe,KACpB,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAAC;AAO9C;;;;;;;;;GASG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsB;IAC9C,OAAO,CAAC,OAAO,CAAgC;IAC/C,OAAO,CAAC,eAAe,CAAoC;IAI3D,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI;IAI9D,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,GAAG,IAAI;IAIxD,mBAAmB,CACjB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,2BAA2B,CAAC,GAAG,2BAA2B;IAOrE,mBAAmB,CACjB,OAAO,EAAE,sBAAsB,EAC/B,KAAK,CAAC,EAAE,eAAe,GACtB,OAAO,CAAC,cAAc,CAAC;IAS1B,OAAO,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,IAAI;IAOjD,eAAe,CAAC,QAAQ,EAAE,qBAAqB,GAAG,MAAM,IAAI;IAO5D,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,GAAG,IAAI;IAIrD,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,GAAG,IAAI;CAG9D"}
@@ -5,10 +5,10 @@ import {
5
5
  asJsonObject,
6
6
  createMessageStateFromMessages,
7
7
  createSessionViewState,
8
- isAutoApprovedTool,
9
8
  isToolAutoApprovedForMode,
10
- reduceSessionEvent
11
- } from "../chunk-COVRYM64.js";
9
+ reduceSessionEvent,
10
+ scopedToolKey
11
+ } from "../chunk-KQHHBUVY.js";
12
12
 
13
13
  // src/controller/agent-channel.ts
14
14
  import { EventEmitter } from "events";
@@ -33,11 +33,11 @@ var AgentChannel = class {
33
33
  }
34
34
  return this.decider(request);
35
35
  }
36
- requestUserQuestion(request) {
36
+ requestUserQuestion(request, scope) {
37
37
  if (!this.questionHandler) {
38
38
  return Promise.reject(new Error("UI is not ready to handle user questions."));
39
39
  }
40
- return Promise.resolve(this.questionHandler(request));
40
+ return Promise.resolve(this.questionHandler(request, scope));
41
41
  }
42
42
  // ---------- UI-side (subscribers) ----------
43
43
  onEvent(listener) {
@@ -64,10 +64,20 @@ var AgentChannel = class {
64
64
  import {
65
65
  isAbortError
66
66
  } from "@nuvin/agent-core/shared";
67
+ function formatBashPolicyDenial(diagnostics) {
68
+ const denied = diagnostics.operations.filter((operation) => operation.decision === "deny");
69
+ if (denied.length === 0) {
70
+ return "Bash command rejected by policy.";
71
+ }
72
+ const details = denied.map((operation) => {
73
+ const rules = operation.matchedRuleIds.length > 0 ? `rule ${operation.matchedRuleIds.join(", ")}` : "an unmatched Deny";
74
+ return `${rules} at source bytes ${operation.start}-${operation.end}`;
75
+ });
76
+ return `Bash command rejected by policy: ${details.join("; ")}.`;
77
+ }
67
78
  var MAX_AUTO_COMPACTION_CHAIN = 10;
68
79
  function createSessionController(deps) {
69
80
  const now = deps.now ?? Date.now;
70
- const isAutoApproved = deps.isAutoApproved ?? isAutoApprovedTool;
71
81
  const errorDetail = deps.errorDetail ?? ((error) => error instanceof Error ? error.message : String(error));
72
82
  let serverCommands = deps.serverCommands ?? [];
73
83
  let compaction = deps.compaction;
@@ -75,14 +85,20 @@ function createSessionController(deps) {
75
85
  let autoCompactionChain = 0;
76
86
  let state = createSessionViewState();
77
87
  let seq = 0;
78
- let approvalMode = deps.approvalMode ?? "ask";
88
+ let approvalMode = deps.approvalMode ?? "readOnly";
79
89
  let abortController = null;
90
+ let activeTurnPromise = null;
91
+ let closePromise = null;
92
+ let closing = false;
93
+ let detached = false;
80
94
  let activeTurnId;
81
95
  let activePolicyDenial;
82
96
  let hostBusyCount = 0;
83
97
  let questionCounter = 0;
84
98
  const listeners = /* @__PURE__ */ new Set();
85
99
  const approvalResolvers = /* @__PURE__ */ new Map();
100
+ const privateApprovals = /* @__PURE__ */ new Map();
101
+ const persistenceInFlight = /* @__PURE__ */ new Set();
86
102
  const questionResolvers = /* @__PURE__ */ new Map();
87
103
  const alwaysApproved = /* @__PURE__ */ new Set();
88
104
  const queue = [];
@@ -133,7 +149,11 @@ function createSessionController(deps) {
133
149
  emit({ type: "agent-event", event, ...scope ? { scope } : {} });
134
150
  });
135
151
  const unsubscribeLoaded = deps.channel.onSessionLoaded((loaded) => {
152
+ if (persistenceInFlight.size > 0) {
153
+ return;
154
+ }
136
155
  queue.length = 0;
156
+ alwaysApproved.clear();
137
157
  abortController?.abort(new Error("Session loaded; the active turn was cancelled."));
138
158
  rejectAllPending("Session loaded; pending interactions were cancelled.");
139
159
  emit({
@@ -151,10 +171,12 @@ function createSessionController(deps) {
151
171
  });
152
172
  });
153
173
  deps.channel.setToolDecider((request) => {
154
- const { toolCall, agentId, parentToolCallId, dirAccess } = request;
174
+ const { toolCall, agentId, parentToolCallId, dirAccess, bashPolicy } = request;
155
175
  const scope = parentToolCallId !== void 0 ? { agentId, parentToolCallId } : void 0;
156
- const approvalKey = `${agentId}:${toolCall.name}`;
157
- const auto = !dirAccess && (alwaysApproved.has(approvalKey) || isToolAutoApprovedForMode(approvalMode, toolCall.name, isAutoApproved));
176
+ const approvalKey = request.approvalKey ?? `${agentId}:${toolCall.name}`;
177
+ const permissionClass = request.permissionClass ?? "always-confirm";
178
+ const hasBashPolicy = toolCall.name === "Bash" && bashPolicy !== void 0;
179
+ const auto = !dirAccess && (alwaysApproved.has(approvalKey) || isToolAutoApprovedForMode(approvalMode, permissionClass));
158
180
  if (dirAccess && approvalMode === "sudo") {
159
181
  emit({
160
182
  type: "agent-event",
@@ -162,9 +184,50 @@ function createSessionController(deps) {
162
184
  toolStatus: "approved",
163
185
  ...scope ? { scope } : {}
164
186
  });
165
- return { action: "run", grantDir: { dir: dirAccess.proposedDir, persist: false } };
187
+ return {
188
+ action: "run",
189
+ grantDir: { dir: dirAccess.proposedDir, persist: false },
190
+ ...toolCall.name === "Bash" ? { approvalSource: "sudo" } : {}
191
+ };
192
+ }
193
+ if (hasBashPolicy && approvalMode === "sudo") {
194
+ emit({
195
+ type: "agent-event",
196
+ event: { type: "tool_call", toolCall },
197
+ toolStatus: "approved",
198
+ ...scope ? { scope } : {}
199
+ });
200
+ return { action: "run", approvalSource: "sudo" };
201
+ }
202
+ if (hasBashPolicy && bashPolicy.decision === "deny") {
203
+ emit({
204
+ type: "agent-event",
205
+ event: { type: "tool_call", toolCall },
206
+ toolStatus: "rejected",
207
+ ...scope ? { scope } : {}
208
+ });
209
+ activePolicyDenial = formatBashPolicyDenial(bashPolicy);
210
+ return { action: "reject", reason: activePolicyDenial };
211
+ }
212
+ if (!dirAccess && hasBashPolicy && bashPolicy.decision === "allow") {
213
+ emit({
214
+ type: "agent-event",
215
+ event: { type: "tool_call", toolCall },
216
+ toolStatus: "approved",
217
+ ...scope ? { scope } : {}
218
+ });
219
+ return { action: "run", approvalSource: "policy" };
220
+ }
221
+ if (!dirAccess && hasBashPolicy && alwaysApproved.has(approvalKey)) {
222
+ emit({
223
+ type: "agent-event",
224
+ event: { type: "tool_call", toolCall },
225
+ toolStatus: "approved",
226
+ ...scope ? { scope } : {}
227
+ });
228
+ return { action: "run", approvalSource: "remembered" };
166
229
  }
167
- if (auto) {
230
+ if (auto && !hasBashPolicy) {
168
231
  emit({
169
232
  type: "agent-event",
170
233
  event: { type: "tool_call", toolCall },
@@ -187,20 +250,33 @@ function createSessionController(deps) {
187
250
  const input = asJsonObject(toolCall.input);
188
251
  const descriptor = {
189
252
  agentId,
190
- summary: findToolSummary(toolCall.id),
253
+ permissionClass,
254
+ summary: findToolSummary(toolCall.id, parentToolCallId),
191
255
  toolCallId: toolCall.id,
192
256
  toolName: toolCall.name,
193
257
  ...dirAccess !== void 0 ? { dirAccess } : {},
194
258
  ...input !== void 0 ? { input } : {},
195
259
  ...parentToolCallId !== void 0 ? { parentToolCallId } : {},
196
- ...nickname !== void 0 ? { nickname } : {}
260
+ ...nickname !== void 0 ? { nickname } : {},
261
+ ...!dirAccess && bashPolicy !== void 0 ? { bashPolicy } : {},
262
+ ...request.workflowBashPolicy !== void 0 ? { workflowBashPolicy: request.workflowBashPolicy } : {}
197
263
  };
198
264
  return new Promise((resolve) => {
199
- approvalResolvers.set(toolCall.id, resolve);
265
+ const toolKey = scopedToolKey(parentToolCallId, toolCall.id);
266
+ approvalResolvers.set(toolKey, {
267
+ resolve,
268
+ toolCallId: toolCall.id,
269
+ ...parentToolCallId !== void 0 ? { parentToolCallId } : {}
270
+ });
271
+ privateApprovals.set(toolKey, {
272
+ approvalKey,
273
+ bashPolicy: hasBashPolicy,
274
+ ...!dirAccess && toolCall.name === "Bash" && bashPolicy?.persistence.eligible && request.persistBashPermission !== void 0 ? { persistBashPermission: request.persistBashPermission } : {}
275
+ });
200
276
  emit({ type: "approval-requested", approval: descriptor });
201
277
  });
202
278
  });
203
- deps.channel.setQuestionHandler((request) => {
279
+ deps.channel.setQuestionHandler((request, scope) => {
204
280
  if (deps.headless) {
205
281
  activePolicyDenial = "Questions are unavailable in headless mode.";
206
282
  return Promise.reject(new Error(activePolicyDenial));
@@ -209,41 +285,71 @@ function createSessionController(deps) {
209
285
  const questionId = `question-${questionCounter}`;
210
286
  return new Promise((resolve, reject) => {
211
287
  questionResolvers.set(questionId, { resolve, reject });
212
- emit({ type: "question-asked", question: { questionId, request } });
288
+ const nickname = nicknameFor(scope?.parentToolCallId);
289
+ emit({
290
+ type: "question-asked",
291
+ question: {
292
+ questionId,
293
+ request,
294
+ ...scope?.agentId !== void 0 ? { agentId: scope.agentId } : {},
295
+ ...scope?.parentToolCallId !== void 0 ? { parentToolCallId: scope.parentToolCallId } : {},
296
+ ...nickname !== void 0 ? { nickname } : {}
297
+ }
298
+ });
213
299
  });
214
300
  });
215
- function findToolSummary(toolCallId) {
216
- const messageId = state.messages.toolMessageIds[toolCallId];
301
+ function findToolSummary(toolCallId, parentToolCallId) {
302
+ const messageId = state.messages.toolMessageIds[scopedToolKey(parentToolCallId, toolCallId)];
217
303
  const message = state.messages.messages.find((entry) => entry.id === messageId);
218
304
  return message?.role === "tool" ? message.summary : "";
219
305
  }
220
306
  function nicknameFor(parentToolCallId) {
221
307
  if (!parentToolCallId) return void 0;
222
- const parentMessageId = state.messages.toolMessageIds[parentToolCallId];
308
+ const parentMessageId = state.messages.toolMessageIds[scopedToolKey(void 0, parentToolCallId)];
223
309
  const parentMessage = state.messages.messages.find((entry) => entry.id === parentMessageId);
224
310
  if (parentMessage?.role !== "tool") return void 0;
225
311
  const nickname = parentMessage.input?.nickname;
226
312
  return typeof nickname === "string" ? nickname : void 0;
227
313
  }
228
- function findDescriptor(toolCallId) {
314
+ function findDescriptor(toolCallId, parentToolCallId) {
229
315
  return [state.approval.active, ...state.approval.pending].find(
230
- (entry) => entry?.toolCallId === toolCallId
316
+ (entry) => entry?.toolCallId === toolCallId && entry.parentToolCallId === parentToolCallId
231
317
  );
232
318
  }
233
319
  function rejectAllPending(reason) {
234
- for (const [toolCallId, resolve] of approvalResolvers) {
235
- resolve({ action: "reject", reason });
236
- emit({ type: "approval-settled", toolCallId, status: "rejected", by: "abort" });
320
+ for (const pending of approvalResolvers.values()) {
321
+ pending.resolve({ action: "reject", reason });
322
+ emit({
323
+ type: "approval-settled",
324
+ toolCallId: pending.toolCallId,
325
+ ...pending.parentToolCallId === void 0 ? {} : { parentToolCallId: pending.parentToolCallId },
326
+ status: "rejected",
327
+ by: "abort"
328
+ });
237
329
  }
238
330
  approvalResolvers.clear();
331
+ privateApprovals.clear();
332
+ persistenceInFlight.clear();
239
333
  for (const [questionId, { reject }] of questionResolvers) {
240
334
  reject(new Error(reason));
241
335
  emit({ type: "question-settled", questionId });
242
336
  }
243
337
  questionResolvers.clear();
244
338
  }
339
+ async function guardQueuedSteering(input) {
340
+ if (!guardPrompt) return { ok: true, prompt: input };
341
+ try {
342
+ const guarded = await guardPrompt(input);
343
+ if ("blocked" in guarded) {
344
+ return { ok: false, message: `Prompt blocked: ${guarded.blocked.reason}` };
345
+ }
346
+ return { ok: true, prompt: guarded.prompt };
347
+ } catch (error) {
348
+ return { ok: false, message: errorDetail(error) };
349
+ }
350
+ }
245
351
  function startNextQueuedSubmission() {
246
- if (abortController || hostBusyCount > 0) return;
352
+ if (closing || abortController || hostBusyCount > 0) return;
247
353
  const next = queue.shift();
248
354
  if (!next) return;
249
355
  if (!next.opts.silent) {
@@ -256,7 +362,12 @@ function createSessionController(deps) {
256
362
  next.opts.turnId
257
363
  );
258
364
  }
259
- void runTurn(next.input, { turnId: next.opts.turnId });
365
+ void startTrackedTurn(next.input, {
366
+ turnId: next.opts.turnId,
367
+ allowSteering: next.opts.turnId === void 0,
368
+ submission: next.opts.submission ?? { origin: "user" },
369
+ ...next.opts.signal === void 0 ? {} : { signal: next.opts.signal }
370
+ });
260
371
  }
261
372
  function finalizeTurn(controller, reason) {
262
373
  if (abortController !== controller) return false;
@@ -267,6 +378,39 @@ function createSessionController(deps) {
267
378
  }
268
379
  async function runTurn(input, opts) {
269
380
  const controller = new AbortController();
381
+ const abortFromCaller = () => controller.abort(opts.signal?.reason);
382
+ if (opts.signal !== void 0) {
383
+ if (opts.signal.aborted) abortFromCaller();
384
+ else opts.signal.addEventListener("abort", abortFromCaller, { once: true });
385
+ }
386
+ const drainSteering = async () => {
387
+ if (abortController !== controller || controller.signal.aborted) return [];
388
+ const barrierIndex = queue.findIndex((entry) => entry.delivery === "next-turn");
389
+ const steeringCount = barrierIndex === -1 ? queue.length : barrierIndex;
390
+ if (steeringCount === 0) return [];
391
+ const drained = queue.splice(0, steeringCount);
392
+ emitTurnStatus();
393
+ const accepted = [];
394
+ for (const entry of drained) {
395
+ const guarded = await guardQueuedSteering(entry.input);
396
+ if (guarded.ok) {
397
+ accepted.push({ input: guarded.prompt, opts: entry.opts });
398
+ } else {
399
+ emit({ type: "error", message: guarded.message });
400
+ }
401
+ }
402
+ if (abortController !== controller || controller.signal.aborted) return [];
403
+ for (const entry of accepted) {
404
+ if (!entry.opts.silent) {
405
+ emit({
406
+ type: "user-message",
407
+ text: entry.opts.displayText,
408
+ ...entry.opts.attachmentLabels?.length ? { attachmentLabels: entry.opts.attachmentLabels } : {}
409
+ });
410
+ }
411
+ }
412
+ return accepted.map((entry) => entry.input);
413
+ };
270
414
  const previousTurnId = activeTurnId;
271
415
  if (opts.turnId !== void 0) activeTurnId = opts.turnId;
272
416
  if (!opts.systemInitiated) activePolicyDenial = void 0;
@@ -296,7 +440,12 @@ function createSessionController(deps) {
296
440
  }
297
441
  finalInput = guarded.prompt;
298
442
  }
299
- const compactionSend = (text) => runTurn(text, { systemInitiated: true });
443
+ const compactionSend = (text) => runTurn(text, {
444
+ systemInitiated: true,
445
+ allowSteering: opts.allowSteering,
446
+ submission: opts.submission,
447
+ ...opts.signal === void 0 ? {} : { signal: opts.signal }
448
+ });
300
449
  const compactionIo = {
301
450
  signal: controller.signal,
302
451
  send: compactionSend,
@@ -319,7 +468,9 @@ function createSessionController(deps) {
319
468
  try {
320
469
  const result = await deps.agent.send(finalInput, {
321
470
  streaming: true,
322
- signal: controller.signal
471
+ signal: controller.signal,
472
+ submission: opts.submission,
473
+ ...opts.allowSteering ? { drainSteering } : {}
323
474
  });
324
475
  if (typeof result === "object" && result !== null && "status" in result && result.status === "aborted") {
325
476
  terminalStatus = "aborted";
@@ -350,6 +501,7 @@ function createSessionController(deps) {
350
501
  }
351
502
  }
352
503
  } finally {
504
+ opts.signal?.removeEventListener("abort", abortFromCaller);
353
505
  const ownedTurn = finalizeTurn(
354
506
  controller,
355
507
  "Turn ended before pending interactions could be resolved."
@@ -374,10 +526,28 @@ function createSessionController(deps) {
374
526
  if (ownedTurn) startNextQueuedSubmission();
375
527
  }
376
528
  }
529
+ function startTrackedTurn(input, opts) {
530
+ let tracked;
531
+ tracked = runTurn(input, opts).finally(() => {
532
+ if (activeTurnPromise === tracked) activeTurnPromise = null;
533
+ });
534
+ activeTurnPromise = tracked;
535
+ return tracked;
536
+ }
537
+ function detachController() {
538
+ if (detached) return;
539
+ detached = true;
540
+ unsubscribeEvents();
541
+ unsubscribeLoaded();
542
+ deps.channel.setToolDecider(null);
543
+ deps.channel.setQuestionHandler(null);
544
+ listeners.clear();
545
+ }
377
546
  return {
378
547
  abort() {
379
548
  queue.length = 0;
380
549
  abortController?.abort(new Error("User aborted the current turn."));
550
+ rejectAllPending("User aborted the current turn.");
381
551
  },
382
552
  attachHostHooks(hooks) {
383
553
  if (hooks.compaction) compaction = hooks.compaction;
@@ -398,13 +568,21 @@ function createSessionController(deps) {
398
568
  emit({ type: "info", message });
399
569
  },
400
570
  close() {
571
+ if (closePromise) return closePromise;
572
+ closing = true;
573
+ const shouldEmitTurnStatus = queue.length > 0 || abortController !== null || hostBusyCount > 0;
574
+ queue.length = 0;
401
575
  abortController?.abort(new Error("Session closed."));
402
576
  rejectAllPending("Session closed.");
403
- unsubscribeEvents();
404
- unsubscribeLoaded();
405
- deps.channel.setToolDecider(null);
406
- deps.channel.setQuestionHandler(null);
407
- listeners.clear();
577
+ if (shouldEmitTurnStatus) emitTurnStatus();
578
+ const active = activeTurnPromise;
579
+ if (!active) {
580
+ detachController();
581
+ closePromise = Promise.resolve();
582
+ return closePromise;
583
+ }
584
+ closePromise = active.then(detachController, detachController);
585
+ return closePromise;
408
586
  },
409
587
  dequeue(queuedId) {
410
588
  const index = queue.findIndex((q) => q.id === queuedId);
@@ -413,20 +591,32 @@ function createSessionController(deps) {
413
591
  emitTurnStatus();
414
592
  return true;
415
593
  },
416
- decideApproval(toolCallId, decision, comment, grantDir) {
417
- const resolve = approvalResolvers.get(toolCallId);
418
- if (!resolve) return false;
419
- approvalResolvers.delete(toolCallId);
420
- const descriptor = findDescriptor(toolCallId);
594
+ decideApproval(toolCallId, decision, comment, grantDir, parentToolCallId) {
595
+ const toolKey = scopedToolKey(parentToolCallId, toolCallId);
596
+ if (persistenceInFlight.has(toolKey)) {
597
+ return false;
598
+ }
599
+ const pending = approvalResolvers.get(toolKey);
600
+ if (!pending) return false;
601
+ approvalResolvers.delete(toolKey);
602
+ persistenceInFlight.delete(toolKey);
603
+ const descriptor = findDescriptor(toolCallId, parentToolCallId);
604
+ const privateApproval = privateApprovals.get(toolKey);
605
+ privateApprovals.delete(toolKey);
421
606
  if (decision === "a" && descriptor && !descriptor.dirAccess) {
422
- alwaysApproved.add(`${descriptor.agentId}:${descriptor.toolName}`);
607
+ alwaysApproved.add(
608
+ privateApproval?.approvalKey ?? `${descriptor.agentId}:${descriptor.toolName}`
609
+ );
423
610
  }
424
611
  const rejected = decision === "n";
425
612
  if (rejected) {
426
613
  const baseReason = `User rejected tool execution (${descriptor?.toolName ?? "unknown tool"})`;
427
- resolve({ action: "reject", reason: comment ? `${baseReason}: ${comment}` : baseReason });
614
+ pending.resolve({
615
+ action: "reject",
616
+ reason: comment ? `${baseReason}: ${comment}` : baseReason
617
+ });
428
618
  } else if (descriptor?.dirAccess) {
429
- resolve({
619
+ pending.resolve({
430
620
  action: "run",
431
621
  grantDir: {
432
622
  dir: resolveGrantDir(
@@ -435,19 +625,85 @@ function createSessionController(deps) {
435
625
  grantDir
436
626
  ),
437
627
  persist: decision === "a"
438
- }
628
+ },
629
+ ...descriptor.toolName === "Bash" && privateApproval?.bashPolicy ? {
630
+ approvalDecision: decision,
631
+ approvalSource: "user"
632
+ } : {}
633
+ });
634
+ } else if (descriptor?.toolName === "Bash" && privateApproval?.bashPolicy) {
635
+ pending.resolve({
636
+ action: "run",
637
+ approvalDecision: decision,
638
+ approvalSource: "user"
439
639
  });
440
640
  } else {
441
- resolve({ action: "run" });
641
+ pending.resolve({ action: "run" });
442
642
  }
443
643
  emit({
444
644
  type: "approval-settled",
445
645
  toolCallId,
646
+ ...parentToolCallId !== void 0 ? { parentToolCallId } : {},
446
647
  status: rejected ? "rejected" : "approved",
447
648
  by: "client"
448
649
  });
449
650
  return true;
450
651
  },
652
+ async persistBashPermission(toolCallId, request, parentToolCallId) {
653
+ const toolKey = scopedToolKey(parentToolCallId, toolCallId);
654
+ const descriptor = state.approval.active;
655
+ const privateApproval = privateApprovals.get(toolKey);
656
+ const handler = privateApproval?.persistBashPermission;
657
+ if (descriptor?.toolCallId !== toolCallId || descriptor.parentToolCallId !== parentToolCallId || descriptor.toolName !== "Bash" || descriptor.dirAccess !== void 0 || descriptor.bashPolicy?.persistence.eligible !== true || handler === void 0 || !approvalResolvers.has(toolKey)) {
658
+ return void 0;
659
+ }
660
+ if (persistenceInFlight.has(toolKey)) {
661
+ return {
662
+ status: "validation-error",
663
+ message: "A persistent Bash permission save is already in progress.",
664
+ fieldErrors: { server: "Wait for the current save to finish." }
665
+ };
666
+ }
667
+ if (hostBusyCount > 0) {
668
+ return {
669
+ status: "validation-error",
670
+ message: "A server operation is already in progress.",
671
+ fieldErrors: { server: "Wait for the current operation to finish." }
672
+ };
673
+ }
674
+ persistenceInFlight.add(toolKey);
675
+ hostBusyCount += 1;
676
+ emitTurnStatus();
677
+ try {
678
+ const outcome = await handler(request);
679
+ if (outcome.status === "validation-error") {
680
+ return outcome;
681
+ }
682
+ const pending = approvalResolvers.get(toolKey);
683
+ if (pending === void 0 || state.approval.active?.toolCallId !== toolCallId || state.approval.active.parentToolCallId !== parentToolCallId || privateApprovals.get(toolKey) !== privateApproval) {
684
+ return void 0;
685
+ }
686
+ approvalResolvers.delete(toolKey);
687
+ privateApprovals.delete(toolKey);
688
+ pending.resolve({
689
+ action: "run",
690
+ executionAuthorization: outcome.executionAuthorization,
691
+ approvalSource: "user"
692
+ });
693
+ emit({
694
+ type: "approval-settled",
695
+ toolCallId,
696
+ ...parentToolCallId !== void 0 ? { parentToolCallId } : {},
697
+ status: "approved",
698
+ by: "client"
699
+ });
700
+ return { status: "approved" };
701
+ } finally {
702
+ persistenceInFlight.delete(toolKey);
703
+ hostBusyCount = Math.max(0, hostBusyCount - 1);
704
+ emitTurnStatus();
705
+ }
706
+ },
451
707
  echoUserMessage(text, attachmentLabels) {
452
708
  emit({
453
709
  type: "user-message",
@@ -461,6 +717,9 @@ function createSessionController(deps) {
461
717
  isHostBusy() {
462
718
  return hostBusyCount > 0;
463
719
  },
720
+ isTurnActive() {
721
+ return abortController !== null;
722
+ },
464
723
  onEvent(listener) {
465
724
  listeners.add(listener);
466
725
  return () => listeners.delete(listener);
@@ -484,12 +743,15 @@ function createSessionController(deps) {
484
743
  emit({ type: "workflow-progress", runId, delta });
485
744
  },
486
745
  reset() {
746
+ if (persistenceInFlight.size > 0) {
747
+ return;
748
+ }
487
749
  queue.length = 0;
488
750
  hostBusyCount = 0;
489
751
  abortController?.abort(new Error("Session reset."));
490
752
  rejectAllPending("Session reset; pending interactions were cancelled.");
491
753
  alwaysApproved.clear();
492
- approvalMode = "ask";
754
+ approvalMode = "readOnly";
493
755
  emit({
494
756
  type: "state-reset",
495
757
  state: { ...createSessionViewState(), meta: state.meta, mcp: state.mcp }
@@ -508,8 +770,15 @@ function createSessionController(deps) {
508
770
  startNextQueuedSubmission();
509
771
  },
510
772
  async submit(input, opts) {
773
+ if (closing) throw new Error("Session is closing.");
774
+ opts.signal?.throwIfAborted();
511
775
  if (abortController || hostBusyCount > 0) {
512
- queue.push({ id: `q-${++queueCounter}`, input, opts });
776
+ queue.push({
777
+ id: `q-${++queueCounter}`,
778
+ input,
779
+ opts,
780
+ delivery: abortController !== null && opts.turnId === void 0 ? "steering" : "next-turn"
781
+ });
513
782
  emitTurnStatus();
514
783
  return;
515
784
  }
@@ -523,11 +792,17 @@ function createSessionController(deps) {
523
792
  opts.turnId
524
793
  );
525
794
  }
526
- await runTurn(input, { turnId: opts.turnId });
795
+ await startTrackedTurn(input, {
796
+ turnId: opts.turnId,
797
+ allowSteering: opts.turnId === void 0,
798
+ submission: opts.submission ?? { origin: "user" },
799
+ ...opts.signal === void 0 ? {} : { signal: opts.signal }
800
+ });
527
801
  }
528
802
  };
529
803
  }
530
804
  export {
531
805
  AgentChannel,
532
- createSessionController
806
+ createSessionController,
807
+ formatBashPolicyDenial
533
808
  };