@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -156,7 +156,7 @@ Now link the user's phone number as the trusted SMS guardian. Tell the user: "No
156
156
 
157
157
  Load the **guardian-verify-setup** skill to handle the verification flow:
158
158
 
159
- - Call `skill_load` with `skill_id: "guardian-verify-setup"` to load the dependency skill.
159
+ - Call `skill_load` with `skill: "guardian-verify-setup"` to load the dependency skill.
160
160
 
161
161
  When invoking the skill, indicate the channel is `sms`. The guardian-verify-setup skill manages the full outbound verification flow, including:
162
162
 
@@ -69,7 +69,7 @@ Now link the user's Telegram account as the trusted guardian for this bot. Tell
69
69
 
70
70
  Load the **guardian-verify-setup** skill to handle the verification flow:
71
71
 
72
- - Call `skill_load` with `skill_id: "guardian-verify-setup"` to load the dependency skill.
72
+ - Call `skill_load` with `skill: "guardian-verify-setup"` to load the dependency skill.
73
73
 
74
74
  The guardian-verify-setup skill manages the full outbound verification flow for Telegram, including:
75
75
 
@@ -229,7 +229,7 @@ Now link the user's phone number as the trusted guardian for SMS and/or voice ch
229
229
 
230
230
  Load the **guardian-verify-setup** skill to handle the verification flow:
231
231
 
232
- - Call `skill_load` with `skill_id: "guardian-verify-setup"` to load the dependency skill.
232
+ - Call `skill_load` with `skill: "guardian-verify-setup"` to load the dependency skill.
233
233
 
234
234
  The guardian-verify-setup skill manages the full outbound verification flow for **one channel at a time** (sms, voice, or telegram). Each invocation handles:
235
235
 
@@ -37,11 +37,12 @@ const APPROVAL_CONVERSATION_TOOL_SCHEMA = {
37
37
  properties: {
38
38
  disposition: {
39
39
  type: 'string',
40
- enum: ['keep_pending', 'approve_once', 'approve_always', 'reject'],
40
+ enum: ['keep_pending', 'approve_once', 'approve_10m', 'approve_thread', 'approve_always', 'reject'],
41
41
  description:
42
42
  'The decision: keep_pending if the user is asking questions or unclear, '
43
- + 'approve_once to approve this single request, approve_always to approve '
44
- + 'this tool permanently, reject to deny the request.',
43
+ + 'approve_once to approve this single request, approve_10m to approve all '
44
+ + 'requests for 10 minutes, approve_thread to approve all requests in this '
45
+ + 'thread, approve_always to approve this tool permanently, reject to deny the request.',
45
46
  },
46
47
  replyText: {
47
48
  type: 'string',
@@ -61,6 +62,8 @@ const APPROVAL_CONVERSATION_TOOL_SCHEMA = {
61
62
  const VALID_DISPOSITIONS: ReadonlySet<string> = new Set([
62
63
  'keep_pending',
63
64
  'approve_once',
65
+ 'approve_10m',
66
+ 'approve_thread',
64
67
  'approve_always',
65
68
  'reject',
66
69
  ]);
@@ -17,8 +17,8 @@ import {
17
17
  getTwilioVoiceWebhookUrl,
18
18
  type IngressConfig,
19
19
  } from '../../inbound/public-ingress-urls.js';
20
+ import { mintDaemonDeliveryToken } from '../../runtime/auth/token-service.js';
20
21
  import { getSecureKey } from '../../security/secure-keys.js';
21
- import { readHttpToken } from '../../util/platform.js';
22
22
  import type { IngressConfigRequest } from '../ipc-protocol.js';
23
23
  import { CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext,log } from './shared.js';
24
24
 
@@ -47,11 +47,7 @@ export function computeGatewayTarget(): string {
47
47
  */
48
48
  export function triggerGatewayReconcile(ingressPublicBaseUrl: string | undefined): void {
49
49
  const gatewayBase = computeGatewayTarget();
50
- const token = readHttpToken();
51
- if (!token) {
52
- log.debug('Skipping gateway reconcile trigger: no HTTP bearer token available');
53
- return;
54
- }
50
+ const token = mintDaemonDeliveryToken();
55
51
 
56
52
  const url = `${gatewayBase}/internal/telegram/reconcile`;
57
53
  const body = JSON.stringify({ ingressPublicBaseUrl: ingressPublicBaseUrl ?? '' });
@@ -8,7 +8,7 @@ import { listGuardianDecisionPrompts } from '../../runtime/routes/guardian-actio
8
8
  import type { GuardianActionDecision, GuardianActionsPendingRequest } from '../ipc-protocol.js';
9
9
  import { defineHandlers, log } from './shared.js';
10
10
 
11
- const VALID_ACTIONS = new Set<string>(['approve_once', 'approve_always', 'reject']);
11
+ const VALID_ACTIONS = new Set<string>(['approve_once', 'approve_10m', 'approve_thread', 'approve_always', 'reject']);
12
12
 
13
13
  export const guardianActionsHandlers = defineHandlers({
14
14
  guardian_actions_pending_request: (msg: GuardianActionsPendingRequest, socket, ctx) => {
@@ -19,7 +19,7 @@ import { GENERATING_TITLE, queueGenerateConversationTitle, UNTITLED_FALLBACK } f
19
19
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
20
20
  import { DAEMON_INTERNAL_ASSISTANT_ID } from '../../runtime/assistant-scope.js';
21
21
  import { routeGuardianReply } from '../../runtime/guardian-reply-router.js';
22
- import { resolveLocalIpcGuardianContext } from '../../runtime/local-actor-identity.js';
22
+ import { resolveLocalIpcAuthContext, resolveLocalIpcGuardianContext } from '../../runtime/local-actor-identity.js';
23
23
  import * as pendingInteractions from '../../runtime/pending-interactions.js';
24
24
  import { checkIngressForSecrets } from '../../security/secret-ingress.js';
25
25
  import { compileCustomPatterns, redactSecrets } from '../../security/secret-scanner.js';
@@ -119,6 +119,7 @@ function makeIpcEventSender(params: {
119
119
  allowlistOptions: event.allowlistOptions,
120
120
  scopeOptions: event.scopeOptions,
121
121
  persistentDecisionsAllowed: event.persistentDecisionsAllowed,
122
+ temporaryOptionsAvailable: event.temporaryOptionsAvailable,
122
123
  },
123
124
  });
124
125
 
@@ -286,6 +287,8 @@ export async function handleUserMessage(
286
287
  // the guardianPrincipalId, and resolveGuardianContext classifies the
287
288
  // local user as 'guardian' via binding match.
288
289
  session.setGuardianContext(resolveLocalIpcGuardianContext(ipcChannel));
290
+ // Align IPC sessions with the same AuthContext shape as HTTP sessions.
291
+ session.setAuthContext(resolveLocalIpcAuthContext(msg.sessionId));
289
292
  session.setCommandIntent(null);
290
293
  // Fire-and-forget: don't block the IPC handler so the connection can
291
294
  // continue receiving messages (e.g. cancel, confirmations, or
@@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid';
6
6
  import { getConfig } from '../../config/loader.js';
7
7
  import type { HeartbeatService } from '../../heartbeat/heartbeat-service.js';
8
8
  import type { SecretPromptResult } from '../../permissions/secret-prompter.js';
9
+ import type { AuthContext } from '../../runtime/auth/types.js';
9
10
  import type { DebouncerMap } from '../../util/debounce.js';
10
11
  import { getLogger } from '../../util/logger.js';
11
12
  import { estimateBase64Bytes } from '../assistant-attachments.js';
@@ -105,6 +106,8 @@ export interface SessionCreateOptions {
105
106
  transport?: SessionTransportMetadata;
106
107
  assistantId?: string;
107
108
  guardianContext?: GuardianRuntimeContext;
109
+ /** Normalized auth context for the session (IPC or HTTP-derived). */
110
+ authContext?: AuthContext;
108
111
  /** Whether this turn can block on interactive approval prompts. */
109
112
  isInteractive?: boolean;
110
113
  memoryScopeId?: string;
@@ -299,11 +299,43 @@ export async function handleSkillsInstall(
299
299
  (s) => s.id === msg.slug && s.source === "bundled",
300
300
  );
301
301
  if (bundled) {
302
+ // Auto-enable the bundled skill so it's immediately usable
303
+ try {
304
+ const raw = loadRawConfig();
305
+ ensureSkillEntry(raw, msg.slug).enabled = true;
306
+ ctx.setSuppressConfigReload(true);
307
+ try {
308
+ saveRawConfig(raw);
309
+ } catch (err) {
310
+ ctx.setSuppressConfigReload(false);
311
+ throw err;
312
+ }
313
+ invalidateConfigCache();
314
+ ctx.debounceTimers.schedule(
315
+ "__suppress_reset__",
316
+ () => {
317
+ ctx.setSuppressConfigReload(false);
318
+ },
319
+ CONFIG_RELOAD_DEBOUNCE_MS,
320
+ );
321
+ ctx.updateConfigFingerprint();
322
+ } catch (err) {
323
+ log.warn(
324
+ { err, skillId: msg.slug },
325
+ "Failed to auto-enable bundled skill",
326
+ );
327
+ }
328
+
302
329
  ctx.send(socket, {
303
330
  type: "skills_operation_response",
304
331
  operation: "install",
305
332
  success: true,
306
333
  });
334
+ ctx.broadcast({
335
+ type: "skills_state_changed",
336
+ name: msg.slug,
337
+ state: "enabled",
338
+ });
307
339
  return;
308
340
  }
309
341
 
@@ -30,7 +30,7 @@ export interface UserMessage {
30
30
  export interface ConfirmationResponse {
31
31
  type: 'confirmation_response';
32
32
  requestId: string;
33
- decision: 'allow' | 'always_allow' | 'always_allow_high_risk' | 'deny' | 'always_deny';
33
+ decision: 'allow' | 'allow_10m' | 'allow_thread' | 'always_allow' | 'always_allow_high_risk' | 'deny' | 'always_deny';
34
34
  selectedPattern?: string;
35
35
  selectedScope?: string;
36
36
  }
@@ -119,6 +119,8 @@ export interface ConfirmationRequest {
119
119
  sessionId?: string;
120
120
  /** When false, the client should hide "always allow" / trust-rule persistence affordances. */
121
121
  persistentDecisionsAllowed?: boolean;
122
+ /** Which temporary approval options the client should render (e.g. "Allow for 10 minutes", "Allow for this thread"). */
123
+ temporaryOptionsAvailable?: Array<'allow_10m' | 'allow_thread'>;
122
124
  }
123
125
 
124
126
  export interface SecretRequest {
@@ -7,6 +7,10 @@ import * as net from 'node:net';
7
7
 
8
8
  import { buildAssistantEvent } from '../runtime/assistant-event.js';
9
9
  import { assistantEventHub } from '../runtime/assistant-event-hub.js';
10
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
11
+ import { CURRENT_POLICY_EPOCH } from '../runtime/auth/policy.js';
12
+ import { resolveScopeProfile } from '../runtime/auth/scopes.js';
13
+ import type { AuthContext } from '../runtime/auth/types.js';
10
14
  import { getLogger } from '../util/logger.js';
11
15
  import { serialize, type ServerMessage } from './ipc-protocol.js';
12
16
 
@@ -78,6 +82,26 @@ export class IpcSender {
78
82
  }
79
83
  }
80
84
 
85
+ /**
86
+ * Build a synthetic AuthContext for an IPC session.
87
+ *
88
+ * IPC connections are local-only (Unix domain socket) and pre-authenticated
89
+ * via the daemon's file-system permission model. This produces the same
90
+ * AuthContext shape that HTTP routes receive from JWT verification, keeping
91
+ * downstream code transport-agnostic.
92
+ */
93
+ export function buildIpcAuthContext(sessionId: string): AuthContext {
94
+ return {
95
+ subject: `ipc:self:${sessionId}`,
96
+ principalType: 'ipc',
97
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
98
+ sessionId,
99
+ scopeProfile: 'ipc_v1',
100
+ scopes: resolveScopeProfile('ipc_v1'),
101
+ policyEpoch: CURRENT_POLICY_EPOCH,
102
+ };
103
+ }
104
+
81
105
  /** Extract sessionId from a ServerMessage if present. */
82
106
  function extractSessionId(msg: ServerMessage): string | undefined {
83
107
  const record = msg as unknown as Record<string, unknown>;
@@ -137,7 +137,7 @@ const HIGH_RISK_VALIDATORS: Record<string, PropertyValidator> = {
137
137
  if (typeof obj.requestId !== 'string' || obj.requestId === '') {
138
138
  return 'confirmation_response requires a non-empty string "requestId"';
139
139
  }
140
- const validDecisions = ['allow', 'always_allow', 'always_allow_high_risk', 'deny', 'always_deny'];
140
+ const validDecisions = ['allow', 'allow_10m', 'allow_thread', 'always_allow', 'always_allow_high_risk', 'deny', 'always_deny'];
141
141
  if (typeof obj.decision !== 'string' || !validDecisions.includes(obj.decision)) {
142
142
  return `confirmation_response "decision" must be one of: ${validDecisions.join(', ')}`;
143
143
  }
@@ -39,11 +39,8 @@ import {
39
39
  emitNotificationSignal,
40
40
  registerBroadcastFn,
41
41
  } from "../notifications/emit-signal.js";
42
- import {
43
- initSigningKey,
44
- loadOrCreateSigningKey,
45
- } from "../runtime/actor-token-service.js";
46
42
  import { assistantEventHub } from "../runtime/assistant-event-hub.js";
43
+ import { initAuthSigningKey, loadOrCreateSigningKey } from "../runtime/auth/token-service.js";
47
44
  import { ensureVellumGuardianBinding } from "../runtime/guardian-vellum-migration.js";
48
45
  import { RuntimeHttpServer } from "../runtime/http-server.js";
49
46
  import { startScheduler } from "../schedule/scheduler.js";
@@ -168,10 +165,11 @@ export async function runDaemon(): Promise<void> {
168
165
  chmodSync(httpTokenPath, 0o600);
169
166
  log.info("Daemon startup: bearer token written");
170
167
 
171
- // Load (or generate + persist) the actor-token signing key so tokens
172
- // survive daemon restarts. Must happen after ensureDataDir() creates
173
- // the protected directory.
174
- initSigningKey(loadOrCreateSigningKey());
168
+ // Load (or generate + persist) the auth signing key so tokens survive
169
+ // daemon restarts. Must happen after ensureDataDir() creates the
170
+ // protected directory.
171
+ const signingKey = loadOrCreateSigningKey();
172
+ initAuthSigningKey(signingKey);
175
173
 
176
174
  log.info("Daemon startup: migrations complete");
177
175
 
@@ -163,6 +163,7 @@ function makePendingInteractionRegistrar(
163
163
  allowlistOptions: msg.allowlistOptions,
164
164
  scopeOptions: msg.scopeOptions,
165
165
  persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
166
+ temporaryOptionsAvailable: msg.temporaryOptionsAvailable,
166
167
  },
167
168
  });
168
169
 
@@ -1056,6 +1057,7 @@ export class DaemonServer {
1056
1057
  options?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
1057
1058
  );
1058
1059
  session.setGuardianContext(options?.guardianContext ?? null);
1060
+ session.setAuthContext(options?.authContext ?? null);
1059
1061
  await session.ensureActorScopedHistory();
1060
1062
  session.setChannelCapabilities(
1061
1063
  resolveChannelCapabilities(sourceChannel, sourceInterface),
@@ -1120,6 +1122,7 @@ export class DaemonServer {
1120
1122
  session
1121
1123
  .runAgentLoop(content, messageId, onEvent, {
1122
1124
  isInteractive: options?.isInteractive ?? false,
1125
+ isUserMessage: true,
1123
1126
  })
1124
1127
  .finally(() => {
1125
1128
  // Only reset if no other caller (e.g. a real IPC client) has rebound
@@ -1259,6 +1262,7 @@ export class DaemonServer {
1259
1262
  try {
1260
1263
  await session.runAgentLoop(resolvedContent, messageId, onEvent, {
1261
1264
  isInteractive: options?.isInteractive ?? false,
1265
+ isUserMessage: true,
1262
1266
  });
1263
1267
  } finally {
1264
1268
  // Only reset if no other caller (e.g. a real IPC client) has rebound
@@ -1284,9 +1288,10 @@ export class DaemonServer {
1284
1288
 
1285
1289
  /**
1286
1290
  * Look up an active session by ID without creating one.
1287
- * Returns undefined if no session with that ID exists.
1291
+ * Checks both normal sessions and computer-use sessions so the HTTP
1292
+ * surface-action path is consistent with IPC dispatch.
1288
1293
  */
1289
- findSession(sessionId: string): Session | undefined {
1290
- return this.sessions.get(sessionId);
1294
+ findSession(sessionId: string): Session | ComputerUseSession | undefined {
1295
+ return this.cuSessions.get(sessionId) ?? this.sessions.get(sessionId);
1291
1296
  }
1292
1297
  }
@@ -160,7 +160,7 @@ export async function runAgentLoopImpl(
160
160
  content: string,
161
161
  userMessageId: string,
162
162
  onEvent: (msg: ServerMessage) => void,
163
- options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; titleText?: string },
163
+ options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; isUserMessage?: boolean; titleText?: string },
164
164
  ): Promise<void> {
165
165
  if (!ctx.abortController) {
166
166
  throw new Error('runAgentLoop called without prior persistUserMessage');
@@ -213,6 +213,24 @@ export async function runAgentLoopImpl(
213
213
  let turnStarted = false;
214
214
 
215
215
  try {
216
+ // Auto-complete stale interactive surfaces from previous turns.
217
+ // Only dismiss when the user sends a new message (not a surface action
218
+ // response), so internal turns (subagent notifications, lifecycle
219
+ // instructions) don't accidentally clear active interactive prompts.
220
+ // Placed inside try so the finally block still runs if onEvent throws.
221
+ if (options?.isUserMessage && !ctx.surfaceActionRequestIds.has(reqId)) {
222
+ for (const [surfaceId, entry] of ctx.pendingSurfaceActions) {
223
+ if (entry.surfaceType === 'dynamic_page') continue;
224
+ onEvent({
225
+ type: 'ui_surface_complete',
226
+ sessionId: ctx.conversationId,
227
+ surfaceId,
228
+ summary: 'Dismissed',
229
+ });
230
+ ctx.pendingSurfaceActions.delete(surfaceId);
231
+ }
232
+ }
233
+
216
234
  const preMessageResult = await getHookManager().trigger('pre-message', {
217
235
  sessionId: ctx.conversationId,
218
236
  messagePreview: truncate(content, 200, ''),
@@ -2,6 +2,7 @@ import { AttachmentUploadError,linkAttachmentToMessage, setAttachmentThumbnail,
2
2
  import { check, classifyRisk, generateAllowlistOptions, generateScopeOptions } from '../permissions/checker.js';
3
3
  import type { PermissionPrompter } from '../permissions/prompter.js';
4
4
  import { addRule } from '../permissions/trust-store.js';
5
+ import { isAllowDecision } from '../permissions/types.js';
5
6
  import type { ContentBlock } from '../providers/types.js';
6
7
  import { getLogger } from '../util/logger.js';
7
8
  import {
@@ -67,7 +68,7 @@ export async function approveHostAttachmentRead(
67
68
  addRule(toolName, response.selectedPattern, response.selectedScope, 'deny');
68
69
  }
69
70
 
70
- return response.decision === 'allow' || response.decision === 'always_allow' || response.decision === 'always_allow_high_risk';
71
+ return isAllowDecision(response.decision);
71
72
  }
72
73
 
73
74
  export function formatAttachmentWarnings(warnings: string[]): string | null {
@@ -264,7 +264,7 @@ export interface HistorySessionContext {
264
264
  content: string,
265
265
  userMessageId: string,
266
266
  onEvent: (msg: ServerMessage) => void,
267
- options?: { skipPreMessageRollback?: boolean; titleText?: string },
267
+ options?: { skipPreMessageRollback?: boolean; isUserMessage?: boolean; titleText?: string },
268
268
  ): Promise<void>;
269
269
  }
270
270
 
@@ -435,5 +435,5 @@ export async function regenerate(
435
435
  session.abortController = new AbortController();
436
436
  session.currentRequestId = requestId ?? uuid();
437
437
 
438
- await session.runAgentLoop(content, existingUserMessageId, onEvent, { skipPreMessageRollback: true });
438
+ await session.runAgentLoop(content, existingUserMessageId, onEvent, { skipPreMessageRollback: true, isUserMessage: true });
439
439
  }
@@ -81,7 +81,7 @@ export interface ProcessSessionContext {
81
81
  content: string,
82
82
  userMessageId: string,
83
83
  onEvent: (msg: ServerMessage) => void,
84
- options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; titleText?: string },
84
+ options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; isUserMessage?: boolean; titleText?: string },
85
85
  ): Promise<void>;
86
86
  getTurnChannelContext(): TurnChannelContext | null;
87
87
  setTurnChannelContext(ctx: TurnChannelContext): void;
@@ -329,13 +329,11 @@ export async function drainQueue(session: ProcessSessionContext, reason: QueueDr
329
329
  // Fire-and-forget: persistUserMessage set session.processing = true
330
330
  // so subsequent messages will still be enqueued.
331
331
  // runAgentLoop's finally block will call drainQueue when this run completes.
332
- const drainLoopOptions: { isInteractive?: boolean; titleText?: string } = {};
332
+ const drainLoopOptions: { isInteractive?: boolean; isUserMessage?: boolean; titleText?: string } = { isUserMessage: true };
333
333
  if (next.isInteractive !== undefined) drainLoopOptions.isInteractive = next.isInteractive;
334
334
  if (agentLoopContent !== resolvedContent) drainLoopOptions.titleText = resolvedContent;
335
335
 
336
- session.runAgentLoop(agentLoopContent, userMessageId, next.onEvent,
337
- Object.keys(drainLoopOptions).length > 0 ? drainLoopOptions : undefined,
338
- ).catch((err) => {
336
+ session.runAgentLoop(agentLoopContent, userMessageId, next.onEvent, drainLoopOptions).catch((err) => {
339
337
  const message = err instanceof Error ? err.message : String(err);
340
338
  log.error({ err, conversationId: session.conversationId, requestId: next.requestId }, 'Error processing queued message');
341
339
  next.onEvent({ type: 'error', message: `Failed to process queued message: ${message}` });
@@ -559,12 +557,10 @@ export async function processMessage(
559
557
  });
560
558
  }
561
559
 
562
- const loopOptions: { isInteractive?: boolean; titleText?: string } = {};
560
+ const loopOptions: { isInteractive?: boolean; isUserMessage?: boolean; titleText?: string } = { isUserMessage: true };
563
561
  if (options?.isInteractive !== undefined) loopOptions.isInteractive = options.isInteractive;
564
562
  if (agentLoopContent !== resolvedContent) loopOptions.titleText = resolvedContent;
565
563
 
566
- await session.runAgentLoop(agentLoopContent, userMessageId, onEvent,
567
- Object.keys(loopOptions).length > 0 ? loopOptions : undefined,
568
- );
564
+ await session.runAgentLoop(agentLoopContent, userMessageId, onEvent, loopOptions);
569
565
  return userMessageId;
570
566
  }
@@ -150,6 +150,7 @@ export interface SurfaceSessionContext {
150
150
  attachments: never[],
151
151
  onEvent: (msg: ServerMessage) => void,
152
152
  requestId: string,
153
+ activeSurfaceId?: string,
153
154
  ): { queued: boolean; rejected?: boolean; requestId: string };
154
155
  getQueueDepth(): number;
155
156
  processMessage(
@@ -425,7 +426,7 @@ export function handleSurfaceAction(ctx: SurfaceSessionContext, surfaceId: strin
425
426
  attributes: { source: 'surface_action', surfaceId, actionId },
426
427
  });
427
428
 
428
- const result = ctx.enqueueMessage(content, [], onEvent, requestId);
429
+ const result = ctx.enqueueMessage(content, [], onEvent, requestId, surfaceId);
429
430
  if (result.queued) {
430
431
  const position = ctx.getQueueDepth();
431
432
  if (!retainPending) {
@@ -631,6 +632,21 @@ export async function surfaceProxyResolver(
631
632
  : INTERACTIVE_SURFACE_TYPES.includes(surfaceType);
632
633
  const awaitAction = (input.await_action as boolean) ?? isInteractive;
633
634
 
635
+ // Only one non-persistent interactive surface at a time. If another
636
+ // surface is already awaiting user input, reject this one so the LLM
637
+ // presents surfaces sequentially.
638
+ if (awaitAction) {
639
+ const hasExistingPending = [...ctx.pendingSurfaceActions.values()].some(
640
+ entry => entry.surfaceType !== 'dynamic_page'
641
+ );
642
+ if (hasExistingPending) {
643
+ return {
644
+ content: 'Another interactive surface is already awaiting user input. Present one at a time — wait for the user to respond to the current surface before showing the next.',
645
+ isError: true,
646
+ };
647
+ }
648
+ }
649
+
634
650
  // Track surface state for ui_update merging
635
651
  ctx.surfaceState.set(surfaceId, { surfaceType, data, title });
636
652