vellum 0.2.12 → 0.2.14
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.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +171 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +402 -5
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +271 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +28 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +96 -8
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +97 -0
- package/src/calls/elevenlabs-config.ts +31 -0
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +50 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +114 -0
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +26 -2
- package/src/config/schema.ts +178 -9
- package/src/config/types.ts +3 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/defaults.ts +11 -0
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/conversation-routes.ts +12 -5
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -5,31 +5,12 @@ import { registerTool } from '../registry.js';
|
|
|
5
5
|
import { getConfig } from '../../config/loader.js';
|
|
6
6
|
import { getSecureKey } from '../../security/secure-keys.js';
|
|
7
7
|
import { getLogger } from '../../util/logger.js';
|
|
8
|
+
import { getHttpRetryDelay, sleep, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY_MS } from '../../util/retry.js';
|
|
8
9
|
|
|
9
10
|
const log = getLogger('web-search');
|
|
10
11
|
|
|
11
12
|
const BRAVE_API_URL = 'https://api.search.brave.com/res/v1/web/search';
|
|
12
13
|
const PERPLEXITY_API_URL = 'https://api.perplexity.ai/chat/completions';
|
|
13
|
-
const RATE_LIMIT_MAX_RETRIES = 3;
|
|
14
|
-
const RATE_LIMIT_BASE_DELAY_MS = 1000;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Parse a Retry-After header value into milliseconds.
|
|
18
|
-
* RFC 7231 allows either delta-seconds (e.g. "120") or an HTTP-date
|
|
19
|
-
* (e.g. "Tue, 17 Feb 2026 12:00:00 GMT"). Returns undefined if unparseable.
|
|
20
|
-
*/
|
|
21
|
-
function parseRetryAfterMs(value: string): number | undefined {
|
|
22
|
-
const seconds = Number(value);
|
|
23
|
-
if (!isNaN(seconds)) {
|
|
24
|
-
return seconds * 1000;
|
|
25
|
-
}
|
|
26
|
-
// Try HTTP-date format — Date.parse handles RFC 2822 / IMF-fixdate
|
|
27
|
-
const dateMs = Date.parse(value);
|
|
28
|
-
if (!isNaN(dateMs)) {
|
|
29
|
-
return Math.max(0, dateMs - Date.now());
|
|
30
|
-
}
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
14
|
|
|
34
15
|
type WebSearchProvider = 'perplexity' | 'brave';
|
|
35
16
|
|
|
@@ -148,7 +129,7 @@ async function executeBraveSearch(
|
|
|
148
129
|
|
|
149
130
|
const url = `${BRAVE_API_URL}?${params.toString()}`;
|
|
150
131
|
|
|
151
|
-
for (let attempt = 0; attempt <=
|
|
132
|
+
for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
|
|
152
133
|
const response = await fetch(url, {
|
|
153
134
|
headers: {
|
|
154
135
|
'Accept': 'application/json',
|
|
@@ -169,12 +150,10 @@ async function executeBraveSearch(
|
|
|
169
150
|
return { content: 'Error: Invalid or expired Brave Search API key', isError: true };
|
|
170
151
|
}
|
|
171
152
|
|
|
172
|
-
if (response.status === 429 && attempt <
|
|
173
|
-
const
|
|
174
|
-
const parsed = retryAfter ? parseRetryAfterMs(retryAfter) : undefined;
|
|
175
|
-
const delayMs = parsed ?? RATE_LIMIT_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
153
|
+
if (response.status === 429 && attempt < DEFAULT_MAX_RETRIES) {
|
|
154
|
+
const delayMs = getHttpRetryDelay(response, attempt, DEFAULT_BASE_DELAY_MS);
|
|
176
155
|
log.warn({ attempt: attempt + 1, delayMs }, 'Brave Search rate limited, retrying');
|
|
177
|
-
await
|
|
156
|
+
await sleep(delayMs);
|
|
178
157
|
continue;
|
|
179
158
|
}
|
|
180
159
|
|
|
@@ -192,7 +171,7 @@ async function executePerplexitySearch(
|
|
|
192
171
|
query: string,
|
|
193
172
|
apiKey: string,
|
|
194
173
|
): Promise<ToolExecutionResult> {
|
|
195
|
-
for (let attempt = 0; attempt <=
|
|
174
|
+
for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
|
|
196
175
|
const response = await fetch(PERPLEXITY_API_URL, {
|
|
197
176
|
method: 'POST',
|
|
198
177
|
headers: {
|
|
@@ -218,12 +197,10 @@ async function executePerplexitySearch(
|
|
|
218
197
|
return { content: 'Error: Invalid or expired Perplexity API key', isError: true };
|
|
219
198
|
}
|
|
220
199
|
|
|
221
|
-
if (response.status === 429 && attempt <
|
|
222
|
-
const
|
|
223
|
-
const parsed = retryAfter ? parseRetryAfterMs(retryAfter) : undefined;
|
|
224
|
-
const delayMs = parsed ?? RATE_LIMIT_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
200
|
+
if (response.status === 429 && attempt < DEFAULT_MAX_RETRIES) {
|
|
201
|
+
const delayMs = getHttpRetryDelay(response, attempt, DEFAULT_BASE_DELAY_MS);
|
|
225
202
|
log.warn({ attempt: attempt + 1, delayMs }, 'Perplexity rate limited, retrying');
|
|
226
|
-
await
|
|
203
|
+
await sleep(delayMs);
|
|
227
204
|
continue;
|
|
228
205
|
}
|
|
229
206
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { PolicyContext } from '../permissions/types.js';
|
|
2
|
+
import type { Tool, ToolContext } from './types.js';
|
|
3
|
+
import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a PolicyContext from tool metadata and execution context.
|
|
7
|
+
* When executing within a task run, ephemeral permission rules are
|
|
8
|
+
* included so pre-approved tools are auto-allowed without prompting.
|
|
9
|
+
*/
|
|
10
|
+
export function buildPolicyContext(tool: Tool, context?: ToolContext): PolicyContext | undefined {
|
|
11
|
+
const ephemeralRules = context?.taskRunId
|
|
12
|
+
? getTaskRunRules(context.taskRunId)
|
|
13
|
+
: undefined;
|
|
14
|
+
|
|
15
|
+
if (tool.origin === 'skill') {
|
|
16
|
+
return {
|
|
17
|
+
executionTarget: tool.executionTarget,
|
|
18
|
+
ephemeralRules: ephemeralRules?.length ? ephemeralRules : undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (context?.taskRunId && ephemeralRules?.length) {
|
|
23
|
+
return {
|
|
24
|
+
ephemeralRules,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ToolContext, ToolExecutionResult } from '../types.js';
|
|
2
2
|
import { updateSchedule, formatLocalDate, describeCronExpression } from '../../schedule/schedule-store.js';
|
|
3
|
-
import { normalizeScheduleSyntax, type ScheduleSyntax } from '../../schedule/recurrence-types.js';
|
|
3
|
+
import { normalizeScheduleSyntax, detectScheduleSyntax, type ScheduleSyntax } from '../../schedule/recurrence-types.js';
|
|
4
4
|
import { validateRruleSetLines } from '../../schedule/recurrence-engine.js';
|
|
5
5
|
|
|
6
6
|
export async function executeScheduleUpdate(
|
|
@@ -31,9 +31,16 @@ export async function executeScheduleUpdate(
|
|
|
31
31
|
updates.expression = resolved.expression;
|
|
32
32
|
} else if (input.expression !== undefined) {
|
|
33
33
|
updates.expression = input.expression;
|
|
34
|
+
const detected = detectScheduleSyntax(input.expression as string);
|
|
35
|
+
if (detected) updates.syntax = detected;
|
|
34
36
|
} else if (input.cron_expression !== undefined) {
|
|
35
37
|
updates.cronExpression = input.cron_expression;
|
|
36
38
|
}
|
|
39
|
+
// When only syntax is provided (no expression), normalizeScheduleSyntax returns null
|
|
40
|
+
// but we still need to persist the explicit syntax value.
|
|
41
|
+
if (input.syntax !== undefined && updates.syntax === undefined) {
|
|
42
|
+
updates.syntax = input.syntax;
|
|
43
|
+
}
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
if (Object.keys(updates).length === 0) {
|
|
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { getLogger } from '../../util/logger.js';
|
|
5
5
|
import { IntegrityError } from '../../util/errors.js';
|
|
6
|
+
import { PromiseGuard } from '../../util/promise-guard.js';
|
|
6
7
|
import { Parser, Language, type Node as TSNode } from 'web-tree-sitter';
|
|
7
8
|
|
|
8
9
|
const log = getLogger('shell-parser');
|
|
@@ -73,7 +74,7 @@ function verifyWasmChecksum(filePath: string, label: string): void {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
let parserInstance: Parser | null = null;
|
|
76
|
-
|
|
77
|
+
const initGuard = new PromiseGuard<void>();
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
80
|
* Locate a WASM file from a dependency package.
|
|
@@ -105,27 +106,24 @@ function findWasmPath(pkg: string, file: string): string {
|
|
|
105
106
|
async function ensureParser(): Promise<Parser> {
|
|
106
107
|
if (parserInstance) return parserInstance;
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const bashWasmPath = findWasmPath('tree-sitter-bash', 'tree-sitter-bash.wasm');
|
|
109
|
+
await initGuard.run(async () => {
|
|
110
|
+
const treeSitterWasm = findWasmPath('web-tree-sitter', 'web-tree-sitter.wasm');
|
|
111
|
+
const bashWasmPath = findWasmPath('tree-sitter-bash', 'tree-sitter-bash.wasm');
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
verifyWasmChecksum(treeSitterWasm, 'web-tree-sitter.wasm');
|
|
114
|
+
verifyWasmChecksum(bashWasmPath, 'tree-sitter-bash.wasm');
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
await Parser.init({
|
|
117
|
+
locateFile: () => treeSitterWasm,
|
|
118
|
+
});
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
120
|
+
const Bash = await Language.load(bashWasmPath);
|
|
121
|
+
const parser = new Parser();
|
|
122
|
+
parser.setLanguage(Bash);
|
|
123
|
+
parserInstance = parser;
|
|
124
|
+
log.info('Shell parser initialized (web-tree-sitter + bash, checksums verified)');
|
|
125
|
+
});
|
|
127
126
|
|
|
128
|
-
await initPromise;
|
|
129
127
|
return parserInstance!;
|
|
130
128
|
}
|
|
131
129
|
|
package/src/tools/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RiskLevel, AllowlistOption, ScopeOption
|
|
1
|
+
import type { RiskLevel, AllowlistOption, ScopeOption } from '../permissions/types.js';
|
|
2
2
|
import type { ToolDefinition, ContentBlock } from '../providers/types.js';
|
|
3
3
|
import type { SecretPromptResult } from '../permissions/secret-prompter.js';
|
|
4
4
|
|
|
@@ -12,12 +12,6 @@ interface ToolLifecycleEventBase {
|
|
|
12
12
|
conversationId: string;
|
|
13
13
|
requestId?: string;
|
|
14
14
|
executionTarget?: ExecutionTarget;
|
|
15
|
-
/** Security principal kind (e.g. 'core', 'skill', or 'task'). */
|
|
16
|
-
principalKind?: ToolPrincipalKind;
|
|
17
|
-
/** Security principal ID (skill ID when principalKind is 'skill'; task ID when 'task'). */
|
|
18
|
-
principalId?: string;
|
|
19
|
-
/** Content-hash of the principal's source at invocation time. */
|
|
20
|
-
principalVersion?: string;
|
|
21
15
|
}
|
|
22
16
|
|
|
23
17
|
export interface ToolExecutionStartEvent extends ToolLifecycleEventBase {
|
|
@@ -109,16 +103,13 @@ export interface ToolContext {
|
|
|
109
103
|
proxyToolResolver?: ProxyToolResolver;
|
|
110
104
|
/** When set, only tools in this set may execute. Tools outside the set are blocked with an error. */
|
|
111
105
|
allowedToolNames?: Set<string>;
|
|
112
|
-
/** Principal that owns this tool invocation (set by executor from tool metadata). */
|
|
113
|
-
principal?: { kind?: string; id?: string; version?: string };
|
|
114
106
|
/** Request user confirmation for a sub-tool operation (used by claude_code tool). */
|
|
115
107
|
requestConfirmation?: (req: {
|
|
116
108
|
toolName: string;
|
|
117
109
|
input: Record<string, unknown>;
|
|
118
110
|
riskLevel: string;
|
|
119
111
|
executionTarget?: ExecutionTarget;
|
|
120
|
-
|
|
121
|
-
principal?: { kind?: string; id?: string; version?: string };
|
|
112
|
+
principal?: string;
|
|
122
113
|
}) => Promise<{ decision: 'allow' | 'deny' }>;
|
|
123
114
|
/** Prompt the user for a secret value via native SecureField UI. */
|
|
124
115
|
requestSecret?: (params: {
|
|
@@ -141,6 +132,8 @@ export interface ToolContext {
|
|
|
141
132
|
forcePromptSideEffects?: boolean;
|
|
142
133
|
/** Approval callback for proxy policy decisions that require user confirmation. */
|
|
143
134
|
proxyApprovalCallback?: import('./network/script-proxy/types.js').ProxyApprovalCallback;
|
|
135
|
+
/** Optional principal identifier propagated to sub-tool confirmation flows. */
|
|
136
|
+
principal?: string;
|
|
144
137
|
}
|
|
145
138
|
|
|
146
139
|
export interface DiffInfo {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth-backed Twitter API client.
|
|
3
|
+
*
|
|
4
|
+
* Uses stored OAuth2 Bearer tokens (via the token manager) to execute
|
|
5
|
+
* Twitter API v2 operations directly, without requiring a browser session.
|
|
6
|
+
* Currently supports post and reply; all other operations fall back to the
|
|
7
|
+
* browser-based CDP client.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { withValidToken } from '../security/token-manager.js';
|
|
11
|
+
import { getSecureKey } from '../security/secure-keys.js';
|
|
12
|
+
|
|
13
|
+
const TWITTER_API_BASE = 'https://api.x.com/2';
|
|
14
|
+
const SERVICE = 'integration:twitter';
|
|
15
|
+
|
|
16
|
+
/** Operations that the OAuth client can handle natively. */
|
|
17
|
+
const SUPPORTED_OPERATIONS = new Set(['post', 'reply']);
|
|
18
|
+
|
|
19
|
+
export interface OAuthPostResult {
|
|
20
|
+
tweetId: string;
|
|
21
|
+
text: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OAuthOperationError {
|
|
26
|
+
message: string;
|
|
27
|
+
suggestFallback: boolean;
|
|
28
|
+
fallbackPath: 'browser';
|
|
29
|
+
operation: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class UnsupportedOAuthOperationError extends Error {
|
|
33
|
+
public readonly suggestFallback = true;
|
|
34
|
+
public readonly fallbackPath = 'browser' as const;
|
|
35
|
+
public readonly operation: string;
|
|
36
|
+
constructor(operation: string) {
|
|
37
|
+
super(`The "${operation}" operation is not available via the OAuth API. Use the browser path instead.`);
|
|
38
|
+
this.name = 'UnsupportedOAuthOperationError';
|
|
39
|
+
this.operation = operation;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Post a tweet (or reply) using OAuth2 Bearer token authentication.
|
|
45
|
+
*
|
|
46
|
+
* The token manager handles refresh transparently — if the stored token
|
|
47
|
+
* is expired it will be refreshed before (or after a 401) calling the API.
|
|
48
|
+
*/
|
|
49
|
+
export async function oauthPostTweet(
|
|
50
|
+
text: string,
|
|
51
|
+
opts?: { inReplyToTweetId?: string },
|
|
52
|
+
): Promise<OAuthPostResult> {
|
|
53
|
+
return withValidToken(SERVICE, async (token) => {
|
|
54
|
+
const body: Record<string, unknown> = { text };
|
|
55
|
+
if (opts?.inReplyToTweetId) {
|
|
56
|
+
body.reply = { in_reply_to_tweet_id: opts.inReplyToTweetId };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const res = await fetch(`${TWITTER_API_BASE}/tweets`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${token}`,
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const errorBody = await res.text().catch(() => '');
|
|
70
|
+
const err = new Error(
|
|
71
|
+
`Twitter API error (${res.status}): ${errorBody.slice(0, 500)}`,
|
|
72
|
+
);
|
|
73
|
+
// Attach status so the token manager's 401-retry logic can detect it.
|
|
74
|
+
(err as Error & { status: number }).status = res.status;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const json = (await res.json()) as { data: { id: string; text: string } };
|
|
79
|
+
return {
|
|
80
|
+
tweetId: json.data.id,
|
|
81
|
+
text: json.data.text,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check whether OAuth credentials are available for the Twitter integration.
|
|
88
|
+
* Returns true if an access token has been stored (the token manager will
|
|
89
|
+
* handle refresh if it's expired).
|
|
90
|
+
*/
|
|
91
|
+
export function oauthIsAvailable(): boolean {
|
|
92
|
+
return getSecureKey('credential:integration:twitter:access_token') !== undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check whether a given operation is supported via the OAuth API path.
|
|
97
|
+
* Only `post` and `reply` are currently supported; everything else
|
|
98
|
+
* (timeline, search, bookmarks, etc.) requires the browser path.
|
|
99
|
+
*/
|
|
100
|
+
export function oauthSupportsOperation(operation: string): boolean {
|
|
101
|
+
return SUPPORTED_OPERATIONS.has(operation);
|
|
102
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy router for Twitter operations.
|
|
3
|
+
* Selects OAuth or browser path based on persisted preference and capability.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadRawConfig } from '../config/loader.js';
|
|
7
|
+
import { oauthPostTweet, oauthIsAvailable, oauthSupportsOperation } from './oauth-client.js';
|
|
8
|
+
import { postTweet as browserPostTweet, SessionExpiredError } from './client.js';
|
|
9
|
+
import type { PostTweetResult } from './client.js';
|
|
10
|
+
|
|
11
|
+
export type TwitterStrategy = 'oauth' | 'browser' | 'auto';
|
|
12
|
+
|
|
13
|
+
export interface RoutedResult<T> {
|
|
14
|
+
result: T;
|
|
15
|
+
pathUsed: TwitterStrategy;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RoutedError {
|
|
19
|
+
message: string;
|
|
20
|
+
pathUsed: TwitterStrategy;
|
|
21
|
+
suggestAlternative?: TwitterStrategy;
|
|
22
|
+
alternativeSetupHint?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPreferredStrategy(): TwitterStrategy {
|
|
26
|
+
try {
|
|
27
|
+
const raw = loadRawConfig();
|
|
28
|
+
const strategy = raw.twitterOperationStrategy as string | undefined;
|
|
29
|
+
if (strategy === 'oauth' || strategy === 'browser') return strategy;
|
|
30
|
+
} catch { /* fall through */ }
|
|
31
|
+
return 'auto';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function routedPostTweet(
|
|
35
|
+
text: string,
|
|
36
|
+
opts?: { inReplyToTweetId?: string },
|
|
37
|
+
): Promise<RoutedResult<PostTweetResult>> {
|
|
38
|
+
const strategy = getPreferredStrategy();
|
|
39
|
+
const operation = opts?.inReplyToTweetId ? 'reply' : 'post';
|
|
40
|
+
|
|
41
|
+
if (strategy === 'oauth') {
|
|
42
|
+
// User explicitly wants OAuth
|
|
43
|
+
if (!oauthIsAvailable()) {
|
|
44
|
+
throw Object.assign(new Error('OAuth is not configured. Set up OAuth credentials in Settings, or switch to browser strategy: `vellum x strategy set browser`.'), {
|
|
45
|
+
pathUsed: 'oauth' as const,
|
|
46
|
+
suggestAlternative: 'browser' as const,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const result = await oauthPostTweet(text, opts);
|
|
50
|
+
return { result: { tweetId: result.tweetId, text: result.text, url: result.url ?? `https://x.com/i/status/${result.tweetId}` }, pathUsed: 'oauth' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (strategy === 'browser') {
|
|
54
|
+
// User explicitly wants browser
|
|
55
|
+
try {
|
|
56
|
+
const result = await browserPostTweet(text, opts);
|
|
57
|
+
return { result, pathUsed: 'browser' };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof SessionExpiredError) {
|
|
60
|
+
throw Object.assign(err, {
|
|
61
|
+
pathUsed: 'browser' as const,
|
|
62
|
+
suggestAlternative: 'oauth' as const,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// auto strategy: try OAuth first if available and supported, fallback to browser
|
|
70
|
+
let oauthError: Error | undefined;
|
|
71
|
+
if (oauthIsAvailable() && oauthSupportsOperation(operation)) {
|
|
72
|
+
try {
|
|
73
|
+
const result = await oauthPostTweet(text, opts);
|
|
74
|
+
return { result: { tweetId: result.tweetId, text: result.text, url: result.url ?? `https://x.com/i/status/${result.tweetId}` }, pathUsed: 'oauth' };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
oauthError = err instanceof Error ? err : new Error(String(err));
|
|
77
|
+
// Fall through to browser
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback to browser
|
|
82
|
+
try {
|
|
83
|
+
const result = await browserPostTweet(text, opts);
|
|
84
|
+
return { result, pathUsed: 'browser' };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err instanceof SessionExpiredError) {
|
|
87
|
+
throw Object.assign(err, {
|
|
88
|
+
pathUsed: 'auto' as const,
|
|
89
|
+
oauthError: oauthError?.message,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (oauthError) {
|
|
93
|
+
const browserError = err instanceof Error ? err : new Error(String(err));
|
|
94
|
+
throw Object.assign(browserError, {
|
|
95
|
+
pathUsed: 'auto' as const,
|
|
96
|
+
oauthError: oauthError.message,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-key debouncer. Delays execution until no new calls arrive
|
|
3
|
+
* within the specified delay period.
|
|
4
|
+
*/
|
|
5
|
+
export class Debouncer {
|
|
6
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
7
|
+
|
|
8
|
+
constructor(private readonly delayMs: number) {}
|
|
9
|
+
|
|
10
|
+
schedule(fn: () => void): void {
|
|
11
|
+
if (this.timer) clearTimeout(this.timer);
|
|
12
|
+
this.timer = setTimeout(() => {
|
|
13
|
+
this.timer = null;
|
|
14
|
+
fn();
|
|
15
|
+
}, this.delayMs);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
cancel(): void {
|
|
19
|
+
if (this.timer) {
|
|
20
|
+
clearTimeout(this.timer);
|
|
21
|
+
this.timer = null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Multi-key debouncer. Each key gets its own independent timer.
|
|
28
|
+
* Includes an optional entry limit with eviction of oldest non-protected entries.
|
|
29
|
+
*/
|
|
30
|
+
export class DebouncerMap {
|
|
31
|
+
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
32
|
+
private readonly defaultDelayMs: number;
|
|
33
|
+
private readonly maxEntries: number;
|
|
34
|
+
private readonly protectedKeyPrefix: string;
|
|
35
|
+
|
|
36
|
+
constructor(options: {
|
|
37
|
+
defaultDelayMs: number;
|
|
38
|
+
maxEntries?: number;
|
|
39
|
+
protectedKeyPrefix?: string;
|
|
40
|
+
}) {
|
|
41
|
+
this.defaultDelayMs = options.defaultDelayMs;
|
|
42
|
+
this.maxEntries = options.maxEntries ?? Infinity;
|
|
43
|
+
this.protectedKeyPrefix = options.protectedKeyPrefix ?? '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
schedule(key: string, fn: () => void, delayMs?: number): void {
|
|
47
|
+
const existing = this.timers.get(key);
|
|
48
|
+
if (existing) clearTimeout(existing);
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
this.timers.delete(key);
|
|
51
|
+
fn();
|
|
52
|
+
}, delayMs ?? this.defaultDelayMs);
|
|
53
|
+
this.timers.set(key, timer);
|
|
54
|
+
this.enforceLimit();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cancel(key: string): void {
|
|
58
|
+
const timer = this.timers.get(key);
|
|
59
|
+
if (timer) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
this.timers.delete(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cancelAll(): void {
|
|
66
|
+
for (const timer of this.timers.values()) {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
this.timers.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get size(): number {
|
|
73
|
+
return this.timers.size;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private enforceLimit(): void {
|
|
77
|
+
if (this.timers.size <= this.maxEntries) return;
|
|
78
|
+
const excess = this.timers.size - this.maxEntries;
|
|
79
|
+
let removed = 0;
|
|
80
|
+
for (const [key, timer] of this.timers) {
|
|
81
|
+
if (removed >= excess) break;
|
|
82
|
+
if (this.protectedKeyPrefix && key.startsWith(this.protectedKeyPrefix)) continue;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
this.timers.delete(key);
|
|
85
|
+
removed++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the local IPv4 address most likely to be reachable from other
|
|
5
|
+
* devices on the same LAN.
|
|
6
|
+
*
|
|
7
|
+
* Priority order:
|
|
8
|
+
* 1. en0 (Wi-Fi on macOS)
|
|
9
|
+
* 2. en1 (secondary network on macOS)
|
|
10
|
+
* 3. First non-loopback IPv4 on any interface
|
|
11
|
+
*
|
|
12
|
+
* Skips link-local addresses (169.254.x.x) and IPv6.
|
|
13
|
+
* Returns null if no suitable address is found (e.g. no network).
|
|
14
|
+
*/
|
|
15
|
+
export function getLocalIPv4(): string | null {
|
|
16
|
+
const ifaces = networkInterfaces();
|
|
17
|
+
|
|
18
|
+
// Priority interfaces in order
|
|
19
|
+
const priorityInterfaces = ['en0', 'en1'];
|
|
20
|
+
|
|
21
|
+
for (const ifName of priorityInterfaces) {
|
|
22
|
+
const addrs = ifaces[ifName];
|
|
23
|
+
if (!addrs) continue;
|
|
24
|
+
for (const addr of addrs) {
|
|
25
|
+
if (addr.family === 'IPv4' && !addr.internal && !isLinkLocal(addr.address)) {
|
|
26
|
+
return addr.address;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fallback: first non-loopback, non-link-local IPv4 on any interface
|
|
32
|
+
for (const [, addrs] of Object.entries(ifaces)) {
|
|
33
|
+
if (!addrs) continue;
|
|
34
|
+
for (const addr of addrs) {
|
|
35
|
+
if (addr.family === 'IPv4' && !addr.internal && !isLinkLocal(addr.address)) {
|
|
36
|
+
return addr.address;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns true for IPv4 link-local addresses (169.254.x.x). */
|
|
45
|
+
function isLinkLocal(address: string): boolean {
|
|
46
|
+
return address.startsWith('169.254.');
|
|
47
|
+
}
|
package/src/util/platform.ts
CHANGED
|
@@ -135,12 +135,37 @@ export function isTCPEnabled(): boolean {
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* Returns the hostname/address for the TCP listener.
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* Resolution order (first match wins):
|
|
139
|
+
* 1. VELLUM_DAEMON_TCP_HOST env var (explicit override)
|
|
140
|
+
* 2. If iOS pairing is enabled: '0.0.0.0' (LAN-accessible)
|
|
141
|
+
* 3. Default: '127.0.0.1' (localhost only)
|
|
141
142
|
*/
|
|
142
143
|
export function getTCPHost(): string {
|
|
143
|
-
|
|
144
|
+
const override = process.env.VELLUM_DAEMON_TCP_HOST?.trim();
|
|
145
|
+
if (override) return override;
|
|
146
|
+
if (isIOSPairingEnabled()) return '0.0.0.0';
|
|
147
|
+
return '127.0.0.1';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns whether iOS pairing mode is enabled.
|
|
152
|
+
* When enabled, the TCP listener binds to 0.0.0.0 (all interfaces)
|
|
153
|
+
* instead of 127.0.0.1 (localhost only), making the daemon reachable
|
|
154
|
+
* from iOS devices on the same local network.
|
|
155
|
+
*
|
|
156
|
+
* Resolution order (first match wins):
|
|
157
|
+
* 1. VELLUM_DAEMON_IOS_PAIRING env var ('true'/'1' → on, 'false'/'0' → off)
|
|
158
|
+
* 2. Presence of the flag file ~/.vellum/ios-pairing-enabled (exists → on)
|
|
159
|
+
* 3. Default: false
|
|
160
|
+
*
|
|
161
|
+
* This is separate from isTCPEnabled() — TCP can be enabled for localhost-only
|
|
162
|
+
* access without exposing the daemon to the LAN.
|
|
163
|
+
*/
|
|
164
|
+
export function isIOSPairingEnabled(): boolean {
|
|
165
|
+
const override = process.env.VELLUM_DAEMON_IOS_PAIRING?.trim();
|
|
166
|
+
if (override === 'true' || override === '1') return true;
|
|
167
|
+
if (override === 'false' || override === '0') return false;
|
|
168
|
+
return existsSync(join(getRootDir(), 'ios-pairing-enabled'));
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
export function getHttpTokenPath(): string {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards against concurrent execution of an async factory.
|
|
3
|
+
* Multiple concurrent callers share the same in-flight promise.
|
|
4
|
+
* On failure, the guard resets so subsequent calls can retry.
|
|
5
|
+
*/
|
|
6
|
+
export class PromiseGuard<T> {
|
|
7
|
+
private promise: Promise<T> | null = null;
|
|
8
|
+
|
|
9
|
+
/** Whether a promise is currently in-flight. */
|
|
10
|
+
get active(): boolean {
|
|
11
|
+
return this.promise !== null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Execute the factory, deduplicating concurrent calls.
|
|
16
|
+
* If a call is already in-flight, returns the same promise.
|
|
17
|
+
* On failure, clears the cached promise to allow retry.
|
|
18
|
+
*
|
|
19
|
+
* @param factory - Creates the promise on first call.
|
|
20
|
+
* @param onError - Optional callback invoked when the factory rejects (before re-throwing).
|
|
21
|
+
*/
|
|
22
|
+
run(factory: () => Promise<T>, onError?: (err: unknown) => void): Promise<T> {
|
|
23
|
+
if (this.promise) return this.promise;
|
|
24
|
+
|
|
25
|
+
this.promise = factory();
|
|
26
|
+
this.promise.catch((err) => {
|
|
27
|
+
this.promise = null;
|
|
28
|
+
onError?.(err);
|
|
29
|
+
});
|
|
30
|
+
return this.promise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Reset the guard, allowing the next call to create a new promise. */
|
|
34
|
+
reset(): void {
|
|
35
|
+
this.promise = null;
|
|
36
|
+
}
|
|
37
|
+
}
|