@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
@@ -1,14 +1,22 @@
1
- import { API_KEY_PROVIDERS, getConfig, invalidateConfigCache } from '../../config/loader.js';
2
- import { initializeProviders } from '../../providers/registry.js';
3
- import { deleteSecureKey, setSecureKey } from '../../security/secure-keys.js';
4
- import { assertMetadataWritable, deleteCredentialMetadata, upsertCredentialMetadata } from '../../tools/credentials/metadata-store.js';
5
- import { getLogger } from '../../util/logger.js';
6
- import { httpError } from '../http-errors.js';
1
+ import {
2
+ API_KEY_PROVIDERS,
3
+ getConfig,
4
+ invalidateConfigCache,
5
+ } from "../../config/loader.js";
6
+ import { initializeProviders } from "../../providers/registry.js";
7
+ import { deleteSecureKey, setSecureKey } from "../../security/secure-keys.js";
8
+ import {
9
+ assertMetadataWritable,
10
+ deleteCredentialMetadata,
11
+ upsertCredentialMetadata,
12
+ } from "../../tools/credentials/metadata-store.js";
13
+ import { getLogger } from "../../util/logger.js";
14
+ import { httpError } from "../http-errors.js";
7
15
 
8
- const log = getLogger('runtime-http');
16
+ const log = getLogger("runtime-http");
9
17
 
10
18
  export async function handleAddSecret(req: Request): Promise<Response> {
11
- const body = await req.json() as {
19
+ const body = (await req.json()) as {
12
20
  type?: string;
13
21
  name?: string;
14
22
  value?: string;
@@ -16,35 +24,49 @@ export async function handleAddSecret(req: Request): Promise<Response> {
16
24
 
17
25
  const { type, name, value } = body;
18
26
 
19
- if (!type || typeof type !== 'string') {
20
- return httpError('BAD_REQUEST', 'type is required', 400);
27
+ if (!type || typeof type !== "string") {
28
+ return httpError("BAD_REQUEST", "type is required", 400);
21
29
  }
22
- if (!name || typeof name !== 'string') {
23
- return httpError('BAD_REQUEST', 'name is required', 400);
30
+ if (!name || typeof name !== "string") {
31
+ return httpError("BAD_REQUEST", "name is required", 400);
24
32
  }
25
- if (!value || typeof value !== 'string') {
26
- return httpError('BAD_REQUEST', 'value is required', 400);
33
+ if (!value || typeof value !== "string") {
34
+ return httpError("BAD_REQUEST", "value is required", 400);
27
35
  }
28
36
 
29
37
  try {
30
- if (type === 'api_key') {
31
- if (!API_KEY_PROVIDERS.includes(name as typeof API_KEY_PROVIDERS[number])) {
32
- return httpError('BAD_REQUEST', `Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(', ')}`, 400);
38
+ if (type === "api_key") {
39
+ if (
40
+ !API_KEY_PROVIDERS.includes(name as (typeof API_KEY_PROVIDERS)[number])
41
+ ) {
42
+ return httpError(
43
+ "BAD_REQUEST",
44
+ `Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(", ")}`,
45
+ 400,
46
+ );
33
47
  }
34
48
  const stored = setSecureKey(name, value);
35
49
  if (!stored) {
36
- return httpError('INTERNAL_ERROR', 'Failed to store API key in secure storage', 500);
50
+ return httpError(
51
+ "INTERNAL_ERROR",
52
+ "Failed to store API key in secure storage",
53
+ 500,
54
+ );
37
55
  }
38
56
  invalidateConfigCache();
39
57
  initializeProviders(getConfig());
40
- log.info({ provider: name }, 'API key updated via HTTP');
58
+ log.info({ provider: name }, "API key updated via HTTP");
41
59
  return Response.json({ success: true, type, name }, { status: 201 });
42
60
  }
43
61
 
44
- if (type === 'credential') {
45
- const colonIdx = name.indexOf(':');
62
+ if (type === "credential") {
63
+ const colonIdx = name.indexOf(":");
46
64
  if (colonIdx < 1 || colonIdx === name.length - 1) {
47
- return httpError('BAD_REQUEST', 'For credential type, name must be in "service:field" format (e.g. "github:api_token")', 400);
65
+ return httpError(
66
+ "BAD_REQUEST",
67
+ 'For credential type, name must be in "service:field" format (e.g. "github:api_token")',
68
+ 400,
69
+ );
48
70
  }
49
71
  assertMetadataWritable();
50
72
  const service = name.slice(0, colonIdx);
@@ -52,72 +74,95 @@ export async function handleAddSecret(req: Request): Promise<Response> {
52
74
  const key = `credential:${service}:${field}`;
53
75
  const stored = setSecureKey(key, value);
54
76
  if (!stored) {
55
- return httpError('INTERNAL_ERROR', 'Failed to store credential in secure storage', 500);
77
+ return httpError(
78
+ "INTERNAL_ERROR",
79
+ "Failed to store credential in secure storage",
80
+ 500,
81
+ );
56
82
  }
57
83
  upsertCredentialMetadata(service, field, {});
58
- log.info({ service, field }, 'Credential added via HTTP');
84
+ log.info({ service, field }, "Credential added via HTTP");
59
85
  return Response.json({ success: true, type, name }, { status: 201 });
60
86
  }
61
87
 
62
- return httpError('BAD_REQUEST', `Unknown secret type: ${type}. Valid types: api_key, credential`, 400);
88
+ return httpError(
89
+ "BAD_REQUEST",
90
+ `Unknown secret type: ${type}. Valid types: api_key, credential`,
91
+ 400,
92
+ );
63
93
  } catch (err) {
64
94
  const message = err instanceof Error ? err.message : String(err);
65
- log.error({ err, type, name }, 'Failed to add secret via HTTP');
66
- return httpError('INTERNAL_ERROR', message, 500);
95
+ log.error({ err, type, name }, "Failed to add secret via HTTP");
96
+ return httpError("INTERNAL_ERROR", message, 500);
67
97
  }
68
98
  }
69
99
 
70
100
  export async function handleDeleteSecret(req: Request): Promise<Response> {
71
- const body = await req.json() as {
101
+ const body = (await req.json()) as {
72
102
  type?: string;
73
103
  name?: string;
74
104
  };
75
105
 
76
106
  const { type, name } = body;
77
107
 
78
- if (!type || typeof type !== 'string') {
79
- return httpError('BAD_REQUEST', 'type is required', 400);
108
+ if (!type || typeof type !== "string") {
109
+ return httpError("BAD_REQUEST", "type is required", 400);
80
110
  }
81
- if (!name || typeof name !== 'string') {
82
- return httpError('BAD_REQUEST', 'name is required', 400);
111
+ if (!name || typeof name !== "string") {
112
+ return httpError("BAD_REQUEST", "name is required", 400);
83
113
  }
84
114
 
85
115
  try {
86
- if (type === 'api_key') {
87
- if (!API_KEY_PROVIDERS.includes(name as typeof API_KEY_PROVIDERS[number])) {
88
- return httpError('BAD_REQUEST', `Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(', ')}`, 400);
116
+ if (type === "api_key") {
117
+ if (
118
+ !API_KEY_PROVIDERS.includes(name as (typeof API_KEY_PROVIDERS)[number])
119
+ ) {
120
+ return httpError(
121
+ "BAD_REQUEST",
122
+ `Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(", ")}`,
123
+ 400,
124
+ );
89
125
  }
90
126
  const deleted = deleteSecureKey(name);
91
127
  if (!deleted) {
92
- return httpError('NOT_FOUND', `API key not found: ${name}`, 404);
128
+ return httpError("NOT_FOUND", `API key not found: ${name}`, 404);
93
129
  }
94
130
  invalidateConfigCache();
95
131
  initializeProviders(getConfig());
96
- log.info({ provider: name }, 'API key deleted via HTTP');
132
+ log.info({ provider: name }, "API key deleted via HTTP");
97
133
  return Response.json({ success: true, type, name });
98
134
  }
99
135
 
100
- if (type === 'credential') {
101
- const colonIdx = name.indexOf(':');
136
+ if (type === "credential") {
137
+ const colonIdx = name.indexOf(":");
102
138
  if (colonIdx < 1 || colonIdx === name.length - 1) {
103
- return httpError('BAD_REQUEST', 'For credential type, name must be in "service:field" format (e.g. "github:api_token")', 400);
139
+ return httpError(
140
+ "BAD_REQUEST",
141
+ 'For credential type, name must be in "service:field" format (e.g. "github:api_token")',
142
+ 400,
143
+ );
104
144
  }
105
145
  const service = name.slice(0, colonIdx);
106
146
  const field = name.slice(colonIdx + 1);
147
+ assertMetadataWritable();
107
148
  const key = `credential:${service}:${field}`;
108
149
  const deleted = deleteSecureKey(key);
109
150
  if (!deleted) {
110
- return httpError('NOT_FOUND', `Credential not found: ${name}`, 404);
151
+ return httpError("NOT_FOUND", `Credential not found: ${name}`, 404);
111
152
  }
112
153
  deleteCredentialMetadata(service, field);
113
- log.info({ service, field }, 'Credential deleted via HTTP');
154
+ log.info({ service, field }, "Credential deleted via HTTP");
114
155
  return Response.json({ success: true, type, name });
115
156
  }
116
157
 
117
- return httpError('BAD_REQUEST', `Unknown secret type: ${type}. Valid types: api_key, credential`, 400);
158
+ return httpError(
159
+ "BAD_REQUEST",
160
+ `Unknown secret type: ${type}. Valid types: api_key, credential`,
161
+ 400,
162
+ );
118
163
  } catch (err) {
119
164
  const message = err instanceof Error ? err.message : String(err);
120
- log.error({ err, type, name }, 'Failed to delete secret via HTTP');
121
- return httpError('INTERNAL_ERROR', message, 500);
165
+ log.error({ err, type, name }, "Failed to delete secret via HTTP");
166
+ return httpError("INTERNAL_ERROR", message, 500);
122
167
  }
123
168
  }
@@ -4,13 +4,23 @@
4
4
  * POST /v1/surface-actions — dispatch a surface action to an active session.
5
5
  * Requires the session to already exist (does not create new sessions).
6
6
  */
7
- import type { Session } from "../../daemon/session.js";
8
7
  import { getLogger } from "../../util/logger.js";
9
8
  import { httpError } from "../http-errors.js";
10
9
 
11
10
  const log = getLogger("surface-action-routes");
12
11
 
13
- export type SessionLookup = (sessionId: string) => Session | undefined;
12
+ /** Any object that can handle a surface action (Session or ComputerUseSession). */
13
+ interface SurfaceActionTarget {
14
+ handleSurfaceAction(
15
+ surfaceId: string,
16
+ actionId: string,
17
+ data?: Record<string, unknown>,
18
+ ): void;
19
+ }
20
+
21
+ export type SessionLookup = (
22
+ sessionId: string,
23
+ ) => SurfaceActionTarget | undefined;
14
24
 
15
25
  /**
16
26
  * POST /v1/surface-actions — handle a UI surface action.
@@ -123,6 +123,19 @@ export async function handleUpdateTrustRuleManage(
123
123
  priority?: number;
124
124
  };
125
125
 
126
+ if (body.decision !== undefined) {
127
+ const validDecisions = ["allow", "deny", "ask"] as const;
128
+ if (
129
+ !validDecisions.includes(body.decision as (typeof validDecisions)[number])
130
+ ) {
131
+ return httpError(
132
+ "BAD_REQUEST",
133
+ "decision must be one of: allow, deny, ask",
134
+ 400,
135
+ );
136
+ }
137
+ }
138
+
126
139
  try {
127
140
  updateRule(id, {
128
141
  tool: body.tool,
@@ -38,7 +38,7 @@ import { syncTwilioWebhooks } from '../../daemon/handlers/config-ingress.js';
38
38
  import type { IngressConfig } from '../../inbound/public-ingress-urls.js';
39
39
  import { deleteSecureKey, getSecureKey, setSecureKey } from '../../security/secure-keys.js';
40
40
  import { deleteCredentialMetadata, upsertCredentialMetadata } from '../../tools/credentials/metadata-store.js';
41
- import { readHttpToken } from '../../util/platform.js';
41
+ import { mintDaemonDeliveryToken } from '../auth/token-service.js';
42
42
 
43
43
  // ---------------------------------------------------------------------------
44
44
  // Shared helpers
@@ -716,14 +716,14 @@ export async function handleSmsSendTest(req: Request): Promise<Response> {
716
716
  const text = body.text || 'Test SMS from your Vellum assistant';
717
717
 
718
718
  // Send via gateway's /deliver/sms endpoint
719
- const bearerToken = readHttpToken();
719
+ const bearerToken = mintDaemonDeliveryToken();
720
720
  const gatewayUrl = getGatewayInternalBaseUrl();
721
721
 
722
722
  const sendResp = await fetch(`${gatewayUrl}/deliver/sms`, {
723
723
  method: 'POST',
724
724
  headers: {
725
725
  'Content-Type': 'application/json',
726
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
726
+ Authorization: `Bearer ${bearerToken}`,
727
727
  },
728
728
  body: JSON.stringify({ to, text }),
729
729
  signal: AbortSignal.timeout(30_000),
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-conversation temporary approval overrides.
3
+ *
4
+ * When a user chooses `allow_thread` or `allow_10m`, the session records a
5
+ * temporary approval mode scoped to the conversation. Subsequent tool-use
6
+ * confirmations within the same conversation can check this state to
7
+ * auto-approve without prompting.
8
+ *
9
+ * State is in-memory only -- it does not survive daemon restarts, which is
10
+ * the desired behavior for temporary approvals. Thread-scoped overrides
11
+ * persist until the session ends or the mode is explicitly cleared. Timed
12
+ * overrides expire after their TTL (checked at read time, no background sweep).
13
+ */
14
+
15
+ export type TemporaryApprovalMode =
16
+ | { kind: "thread" }
17
+ | { kind: "timed"; expiresAt: number };
18
+
19
+ const DEFAULT_TIMED_DURATION_MS = 10 * 60 * 1000; // 10 minutes
20
+
21
+ const store = new Map<string, TemporaryApprovalMode>();
22
+
23
+ /**
24
+ * Set thread-scoped temporary approval for a conversation.
25
+ * Remains active until explicitly cleared or session ends.
26
+ * Replaces any existing mode for the conversation.
27
+ */
28
+ export function setThreadMode(conversationId: string): void {
29
+ store.set(conversationId, { kind: "thread" });
30
+ }
31
+
32
+ /**
33
+ * Set time-limited temporary approval for a conversation.
34
+ * Replaces any existing mode for the conversation.
35
+ *
36
+ * @param conversationId - The conversation to scope the override to
37
+ * @param durationMs - How long the override lasts (defaults to 10 minutes)
38
+ */
39
+ export function setTimedMode(
40
+ conversationId: string,
41
+ durationMs: number = DEFAULT_TIMED_DURATION_MS,
42
+ ): void {
43
+ store.set(conversationId, {
44
+ kind: "timed",
45
+ expiresAt: Date.now() + durationMs,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Clear any temporary approval mode for a conversation.
51
+ */
52
+ export function clearMode(conversationId: string): void {
53
+ store.delete(conversationId);
54
+ }
55
+
56
+ /**
57
+ * Get the effective temporary approval mode for a conversation.
58
+ *
59
+ * Returns undefined if no mode is set or if a timed mode has expired.
60
+ * Expired timed modes are cleaned up lazily on read.
61
+ */
62
+ export function getEffectiveMode(
63
+ conversationId: string,
64
+ ): TemporaryApprovalMode | undefined {
65
+ const mode = store.get(conversationId);
66
+ if (!mode) return undefined;
67
+
68
+ if (mode.kind === "timed" && Date.now() >= mode.expiresAt) {
69
+ store.delete(conversationId);
70
+ return undefined;
71
+ }
72
+
73
+ return mode;
74
+ }
75
+
76
+ /**
77
+ * Check whether a conversation has an active (non-expired) temporary approval.
78
+ */
79
+ export function hasActiveOverride(conversationId: string): boolean {
80
+ return getEffectiveMode(conversationId) !== undefined;
81
+ }
82
+
83
+ /** Clear all overrides. Useful for testing. */
84
+ export function clearAll(): void {
85
+ store.clear();
86
+ }
@@ -35,6 +35,7 @@ export function migrateKeychainToEncrypted(): void {
35
35
  if (encryptedStore.getKey(MIGRATION_MARKER) === "true") return;
36
36
 
37
37
  let migrated = 0;
38
+ let hadErrors = false;
38
39
  const allKeys = [...API_KEY_PROVIDERS, ...CREDENTIAL_KEYS];
39
40
 
40
41
  for (const account of allKeys) {
@@ -45,10 +46,16 @@ export function migrateKeychainToEncrypted(): void {
45
46
  migrated++;
46
47
  }
47
48
  } catch {
48
- // Keychain unavailable or locked -- skip silently
49
+ hadErrors = true;
50
+ log.warn({ account }, "Keychain read failed during migration");
49
51
  }
50
52
  }
51
53
 
54
+ if (hadErrors) {
55
+ log.warn("Skipping migration marker — will retry on next startup");
56
+ return;
57
+ }
58
+
52
59
  encryptedStore.setKey(MIGRATION_MARKER, "true");
53
60
  if (migrated > 0) {
54
61
  log.info(
@@ -34,15 +34,55 @@ export function parseFrontmatterFields(
34
34
  const frontmatter = match[1];
35
35
  const fields: Record<string, string> = {};
36
36
 
37
- for (const line of frontmatter.split(/\r?\n/)) {
37
+ const lines = frontmatter.split(/\r?\n/);
38
+ let currentKey: string | undefined;
39
+ let continuationLines: string[] = [];
40
+
41
+ function flushContinuation() {
42
+ if (currentKey !== undefined) {
43
+ if (continuationLines.length > 0) {
44
+ // Join continuation lines, then strip trailing commas before closing
45
+ // braces/brackets so that prettier-formatted JSON remains valid for JSON.parse.
46
+ fields[currentKey] = continuationLines
47
+ .map((l) => l.trim())
48
+ .join(" ")
49
+ .replace(/,\s*([}\]])/g, "$1");
50
+ } else {
51
+ fields[currentKey] = "";
52
+ }
53
+ }
54
+ currentKey = undefined;
55
+ continuationLines = [];
56
+ }
57
+
58
+ for (const line of lines) {
38
59
  const trimmed = line.trim();
39
60
  if (!trimmed || trimmed.startsWith("#")) continue;
61
+
62
+ // Continuation line: indented and no top-level key: pattern
63
+ // (i.e. starts with whitespace and either has no colon or the colon
64
+ // is inside braces/quotes — heuristic: line starts with space/tab)
65
+ if (currentKey !== undefined && /^\s/.test(line)) {
66
+ continuationLines.push(trimmed);
67
+ continue;
68
+ }
69
+
70
+ // Flush any pending multiline value
71
+ flushContinuation();
72
+
40
73
  const separatorIndex = trimmed.indexOf(":");
41
74
  if (separatorIndex === -1) continue;
42
75
 
43
76
  const key = trimmed.slice(0, separatorIndex).trim();
44
77
  let value = trimmed.slice(separatorIndex + 1).trim();
45
78
 
79
+ if (!value) {
80
+ // Value may continue on subsequent indented lines
81
+ currentKey = key;
82
+ continuationLines = [];
83
+ continue;
84
+ }
85
+
46
86
  const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
47
87
  const isSingleQuoted = value.startsWith("'") && value.endsWith("'");
48
88
  if (isDoubleQuoted || isSingleQuoted) {
@@ -61,5 +101,8 @@ export function parseFrontmatterFields(
61
101
  fields[key] = value;
62
102
  }
63
103
 
104
+ // Flush any trailing multiline value
105
+ flushContinuation();
106
+
64
107
  return { fields, body: content.slice(match[0].length) };
65
108
  }