@vellumai/assistant 0.3.16 → 0.3.19
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/ARCHITECTURE.md +74 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +139 -48
- package/src/__tests__/config-watcher.test.ts +11 -13
- package/src/__tests__/conversation-pairing.test.ts +103 -3
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +180 -0
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +22 -0
- package/src/__tests__/non-member-access-request.test.ts +1 -2
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +2 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +23 -21
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +9 -9
- package/src/__tests__/update-bulletin-state.test.ts +1 -1
- package/src/__tests__/update-bulletin.test.ts +66 -3
- package/src/__tests__/update-template-contract.test.ts +6 -11
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +150 -8
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +16 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +46 -5
- package/src/cli/core-commands.ts +41 -1
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/config/templates/UPDATES.md +5 -6
- package/src/config/update-bulletin-format.ts +2 -0
- package/src/config/update-bulletin-state.ts +1 -1
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +21 -6
- package/src/daemon/config-watcher.ts +3 -2
- package/src/daemon/daemon-control.ts +64 -10
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/config-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/server.ts +25 -3
- package/src/daemon/session-process.ts +450 -184
- package/src/daemon/tls-certs.ts +17 -12
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/memory/channel-delivery-store.ts +18 -20
- package/src/memory/channel-guardian-store.ts +39 -42
- package/src/memory/conversation-crud.ts +2 -2
- package/src/memory/conversation-queries.ts +2 -2
- package/src/memory/conversation-store.ts +24 -25
- package/src/memory/db-init.ts +17 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +65 -7
- package/src/memory/guardian-verification.ts +1 -0
- package/src/memory/jobs-worker.ts +2 -2
- package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +6 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +36 -1
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/memory/search/semantic.ts +3 -3
- package/src/notifications/README.md +158 -17
- package/src/notifications/broadcaster.ts +68 -50
- package/src/notifications/conversation-pairing.ts +96 -18
- package/src/notifications/decision-engine.ts +6 -3
- package/src/notifications/deliveries-store.ts +12 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/thread-candidates.ts +60 -25
- package/src/notifications/types.ts +2 -1
- package/src/permissions/checker.ts +28 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/http-server.ts +11 -11
- package/src/runtime/routes/access-request-decision.ts +1 -1
- package/src/runtime/routes/debug-routes.ts +4 -4
- package/src/runtime/routes/guardian-approval-interception.ts +120 -4
- package/src/runtime/routes/inbound-message-handler.ts +100 -33
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/permission-checker.ts +1 -2
- package/src/tools/secret-detection-handler.ts +1 -1
- package/src/tools/system/voice-config.ts +1 -1
- package/src/version.ts +29 -2
|
@@ -8,6 +8,18 @@ import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
|
8
8
|
import type { Session } from '../daemon/session.js';
|
|
9
9
|
|
|
10
10
|
const testDir = mkdtempSync(join(tmpdir(), 'voice-bridge-test-'));
|
|
11
|
+
let mockedConfig: {
|
|
12
|
+
secretDetection: { enabled: boolean };
|
|
13
|
+
calls: { disclosure: { enabled: boolean; text: string } };
|
|
14
|
+
} = {
|
|
15
|
+
secretDetection: { enabled: false },
|
|
16
|
+
calls: {
|
|
17
|
+
disclosure: {
|
|
18
|
+
enabled: false,
|
|
19
|
+
text: '',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
11
23
|
|
|
12
24
|
mock.module('../util/platform.js', () => ({
|
|
13
25
|
getRootDir: () => testDir,
|
|
@@ -29,15 +41,7 @@ mock.module('../util/logger.js', () => ({
|
|
|
29
41
|
}));
|
|
30
42
|
|
|
31
43
|
mock.module('../config/loader.js', () => ({
|
|
32
|
-
getConfig: () =>
|
|
33
|
-
secretDetection: { enabled: false },
|
|
34
|
-
calls: {
|
|
35
|
-
disclosure: {
|
|
36
|
-
enabled: false,
|
|
37
|
-
text: '',
|
|
38
|
-
},
|
|
39
|
-
},
|
|
40
|
-
}),
|
|
44
|
+
getConfig: () => mockedConfig,
|
|
41
45
|
}));
|
|
42
46
|
|
|
43
47
|
import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
|
|
@@ -85,6 +89,15 @@ function injectDeps(sessionFactory: () => Session): void {
|
|
|
85
89
|
|
|
86
90
|
describe('voice-session-bridge', () => {
|
|
87
91
|
beforeEach(() => {
|
|
92
|
+
mockedConfig = {
|
|
93
|
+
secretDetection: { enabled: false },
|
|
94
|
+
calls: {
|
|
95
|
+
disclosure: {
|
|
96
|
+
enabled: false,
|
|
97
|
+
text: '',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
88
101
|
const db = getDb();
|
|
89
102
|
db.run('DELETE FROM messages');
|
|
90
103
|
db.run('DELETE FROM conversations');
|
|
@@ -415,6 +428,93 @@ describe('voice-session-bridge', () => {
|
|
|
415
428
|
expect(capturedGuardianContext).toEqual(guardianCtx);
|
|
416
429
|
});
|
|
417
430
|
|
|
431
|
+
test('inbound non-guardian opener prompt uses pickup framing instead of outbound phrasing', async () => {
|
|
432
|
+
const conversation = createConversation('voice bridge inbound opener framing test');
|
|
433
|
+
const events: ServerMessage[] = [
|
|
434
|
+
{ type: 'message_complete', sessionId: conversation.id },
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
let capturedPrompt: string | null = null;
|
|
438
|
+
const session = {
|
|
439
|
+
...makeStreamingSession(events),
|
|
440
|
+
setVoiceCallControlPrompt: (prompt: string | null) => {
|
|
441
|
+
if (prompt != null) capturedPrompt = prompt;
|
|
442
|
+
},
|
|
443
|
+
} as unknown as Session;
|
|
444
|
+
|
|
445
|
+
injectDeps(() => session);
|
|
446
|
+
|
|
447
|
+
await startVoiceTurn({
|
|
448
|
+
conversationId: conversation.id,
|
|
449
|
+
content: 'Hello there',
|
|
450
|
+
isInbound: true,
|
|
451
|
+
guardianContext: {
|
|
452
|
+
sourceChannel: 'voice',
|
|
453
|
+
actorRole: 'non-guardian',
|
|
454
|
+
},
|
|
455
|
+
onTextDelta: () => {},
|
|
456
|
+
onComplete: () => {},
|
|
457
|
+
onError: () => {},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
461
|
+
if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
|
|
462
|
+
const prompt: string = capturedPrompt;
|
|
463
|
+
|
|
464
|
+
expect(prompt).toContain('this is an inbound call you are answering (not a call you initiated)');
|
|
465
|
+
expect(prompt).toContain('Introduce yourself once at the start using your assistant name if you know it');
|
|
466
|
+
expect(prompt).toContain('If your assistant name is not known, skip the name and just identify yourself as the guardian\'s assistant.');
|
|
467
|
+
expect(prompt).toContain('Do NOT say "I\'m calling" or "I\'m calling on behalf of".');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('inbound disclosure guidance is rewritten for pickup context', async () => {
|
|
471
|
+
mockedConfig = {
|
|
472
|
+
secretDetection: { enabled: false },
|
|
473
|
+
calls: {
|
|
474
|
+
disclosure: {
|
|
475
|
+
enabled: true,
|
|
476
|
+
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.',
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const conversation = createConversation('voice bridge inbound disclosure rewrite test');
|
|
482
|
+
const events: ServerMessage[] = [
|
|
483
|
+
{ type: 'message_complete', sessionId: conversation.id },
|
|
484
|
+
];
|
|
485
|
+
|
|
486
|
+
let capturedPrompt: string | null = null;
|
|
487
|
+
const session = {
|
|
488
|
+
...makeStreamingSession(events),
|
|
489
|
+
setVoiceCallControlPrompt: (prompt: string | null) => {
|
|
490
|
+
if (prompt != null) capturedPrompt = prompt;
|
|
491
|
+
},
|
|
492
|
+
} as unknown as Session;
|
|
493
|
+
|
|
494
|
+
injectDeps(() => session);
|
|
495
|
+
|
|
496
|
+
await startVoiceTurn({
|
|
497
|
+
conversationId: conversation.id,
|
|
498
|
+
content: 'Hi',
|
|
499
|
+
isInbound: true,
|
|
500
|
+
guardianContext: {
|
|
501
|
+
sourceChannel: 'voice',
|
|
502
|
+
actorRole: 'non-guardian',
|
|
503
|
+
},
|
|
504
|
+
onTextDelta: () => {},
|
|
505
|
+
onComplete: () => {},
|
|
506
|
+
onError: () => {},
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
510
|
+
if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
|
|
511
|
+
const prompt: string = capturedPrompt;
|
|
512
|
+
|
|
513
|
+
expect(prompt).toContain('At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.');
|
|
514
|
+
expect(prompt).toContain('rewrite any disclosure naturally for pickup context');
|
|
515
|
+
expect(prompt).toContain('Do NOT say "I\'m calling", "I called you", or "I\'m calling on behalf of".');
|
|
516
|
+
});
|
|
517
|
+
|
|
418
518
|
test('auto-denies confirmation requests for non-guardian voice turns', async () => {
|
|
419
519
|
const conversation = createConversation('voice bridge auto-deny non-guardian test');
|
|
420
520
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* barge-in, state machine, guardian verification).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { getGatewayInternalBaseUrl } from '../config/env.js';
|
|
11
12
|
import type { ServerMessage } from '../daemon/ipc-contract.js';
|
|
12
13
|
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
13
14
|
import {
|
|
@@ -15,7 +16,9 @@ import {
|
|
|
15
16
|
getPendingRequestByCallSessionId,
|
|
16
17
|
markTimedOutWithReason,
|
|
17
18
|
} from '../memory/guardian-action-store.js';
|
|
19
|
+
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
18
20
|
import { getLogger } from '../util/logger.js';
|
|
21
|
+
import { readHttpToken } from '../util/platform.js';
|
|
19
22
|
import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
|
|
20
23
|
import { persistCallCompletionMessage } from './call-conversation-messages.js';
|
|
21
24
|
import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
|
|
@@ -27,10 +30,9 @@ import {
|
|
|
27
30
|
recordCallEvent,
|
|
28
31
|
updateCallSession,
|
|
29
32
|
} from './call-store.js';
|
|
30
|
-
import {
|
|
31
|
-
import { readHttpToken } from '../util/platform.js';
|
|
32
|
-
import { dispatchGuardianQuestion } from './guardian-dispatch.js';
|
|
33
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
33
34
|
import { sendGuardianExpiryNotices } from './guardian-action-sweep.js';
|
|
35
|
+
import { dispatchGuardianQuestion } from './guardian-dispatch.js';
|
|
34
36
|
import type { RelayConnection } from './relay-server.js';
|
|
35
37
|
import type { PromptSpeakerContext } from './speaker-identification.js';
|
|
36
38
|
import { startVoiceTurn, type VoiceTurnHandle } from './voice-session-bridge.js';
|
|
@@ -41,6 +43,104 @@ type ControllerState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
|
|
|
41
43
|
|
|
42
44
|
const ASK_GUARDIAN_CAPTURE_REGEX = /\[ASK_GUARDIAN:\s*(.+?)\]/;
|
|
43
45
|
const ASK_GUARDIAN_MARKER_REGEX = /\[ASK_GUARDIAN:\s*.+?\]/g;
|
|
46
|
+
|
|
47
|
+
// Flexible prefix for ASK_GUARDIAN_APPROVAL — tolerates variable whitespace
|
|
48
|
+
// after the colon so the marker is recognized even if the model omits the
|
|
49
|
+
// space or inserts a newline.
|
|
50
|
+
const ASK_GUARDIAN_APPROVAL_PREFIX_RE = /\[ASK_GUARDIAN_APPROVAL:\s*/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract a balanced JSON object from text that starts with an
|
|
54
|
+
* ASK_GUARDIAN_APPROVAL prefix. Uses brace counting with string-literal
|
|
55
|
+
* awareness so that `}` or `}]` inside JSON string values does not
|
|
56
|
+
* terminate the match prematurely.
|
|
57
|
+
*
|
|
58
|
+
* Returns the extracted JSON string, the full marker text
|
|
59
|
+
* (prefix + JSON + "]"), and the start index — or null when:
|
|
60
|
+
* - no prefix is found,
|
|
61
|
+
* - braces are unbalanced (still streaming), or
|
|
62
|
+
* - the closing `]` has not yet arrived (prevents stripping
|
|
63
|
+
* the marker body while the bracket leaks into TTS in a later delta).
|
|
64
|
+
*/
|
|
65
|
+
function extractBalancedJson(
|
|
66
|
+
text: string,
|
|
67
|
+
): { json: string; fullMatch: string; startIndex: number } | null {
|
|
68
|
+
const prefixMatch = ASK_GUARDIAN_APPROVAL_PREFIX_RE.exec(text);
|
|
69
|
+
if (!prefixMatch) return null;
|
|
70
|
+
|
|
71
|
+
const prefixIdx = prefixMatch.index;
|
|
72
|
+
const jsonStart = prefixIdx + prefixMatch[0].length;
|
|
73
|
+
if (jsonStart >= text.length || text[jsonStart] !== '{') return null;
|
|
74
|
+
|
|
75
|
+
let depth = 0;
|
|
76
|
+
let inString = false;
|
|
77
|
+
let escape = false;
|
|
78
|
+
|
|
79
|
+
for (let i = jsonStart; i < text.length; i++) {
|
|
80
|
+
const ch = text[i];
|
|
81
|
+
|
|
82
|
+
if (escape) {
|
|
83
|
+
escape = false;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (ch === '\\' && inString) {
|
|
88
|
+
escape = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ch === '"') {
|
|
93
|
+
inString = !inString;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (inString) continue;
|
|
98
|
+
|
|
99
|
+
if (ch === '{') {
|
|
100
|
+
depth++;
|
|
101
|
+
} else if (ch === '}') {
|
|
102
|
+
depth--;
|
|
103
|
+
if (depth === 0) {
|
|
104
|
+
const jsonEnd = i + 1;
|
|
105
|
+
const json = text.slice(jsonStart, jsonEnd);
|
|
106
|
+
// Skip any whitespace between the closing '}' and the expected ']'.
|
|
107
|
+
// Models sometimes emit formatted markers with spaces or newlines
|
|
108
|
+
// before the bracket (e.g. `{ ... }\n]` or `{ ... } ]`).
|
|
109
|
+
let bracketIdx = jsonEnd;
|
|
110
|
+
while (bracketIdx < text.length && /\s/.test(text[bracketIdx])) {
|
|
111
|
+
bracketIdx++;
|
|
112
|
+
}
|
|
113
|
+
// Require the closing ']' to be present before considering this
|
|
114
|
+
// a complete match. If it hasn't arrived yet (streaming), return
|
|
115
|
+
// null so the caller keeps buffering.
|
|
116
|
+
if (bracketIdx >= text.length || text[bracketIdx] !== ']') {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const fullMatchEnd = bracketIdx + 1;
|
|
120
|
+
const fullMatch = text.slice(prefixIdx, fullMatchEnd);
|
|
121
|
+
return { json, fullMatch, startIndex: prefixIdx };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null; // Unbalanced braces — still streaming
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Strip all balanced ASK_GUARDIAN_APPROVAL markers from text, handling
|
|
131
|
+
* nested braces, string literals, and flexible whitespace correctly.
|
|
132
|
+
* Only strips complete markers (prefix + balanced JSON + closing `]`).
|
|
133
|
+
*/
|
|
134
|
+
function stripGuardianApprovalMarkers(text: string): string {
|
|
135
|
+
let result = text;
|
|
136
|
+
for (;;) {
|
|
137
|
+
const match = extractBalancedJson(result);
|
|
138
|
+
if (!match) break;
|
|
139
|
+
result = result.slice(0, match.startIndex) + result.slice(match.startIndex + match.fullMatch.length);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
44
144
|
const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
|
|
45
145
|
const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
|
|
46
146
|
const CALL_OPENING_MARKER_REGEX = /\[CALL_OPENING\]/g;
|
|
@@ -53,7 +153,8 @@ const CALL_OPENING_ACK_MARKER = '[CALL_OPENING_ACK]';
|
|
|
53
153
|
const END_CALL_MARKER = '[END_CALL]';
|
|
54
154
|
|
|
55
155
|
function stripInternalSpeechMarkers(text: string): string {
|
|
56
|
-
|
|
156
|
+
let result = stripGuardianApprovalMarkers(text);
|
|
157
|
+
result = result
|
|
57
158
|
.replace(ASK_GUARDIAN_MARKER_REGEX, '')
|
|
58
159
|
.replace(USER_ANSWERED_MARKER_REGEX, '')
|
|
59
160
|
.replace(USER_INSTRUCTION_MARKER_REGEX, '')
|
|
@@ -62,6 +163,7 @@ function stripInternalSpeechMarkers(text: string): string {
|
|
|
62
163
|
.replace(END_CALL_MARKER_REGEX, '')
|
|
63
164
|
.replace(GUARDIAN_TIMEOUT_MARKER_REGEX, '')
|
|
64
165
|
.replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, '');
|
|
166
|
+
return result;
|
|
65
167
|
}
|
|
66
168
|
|
|
67
169
|
export class CallController {
|
|
@@ -336,6 +438,21 @@ export class CallController {
|
|
|
336
438
|
this.abortCurrentTurn();
|
|
337
439
|
this.currentTurnPromise = null;
|
|
338
440
|
unregisterCallController(this.callSessionId);
|
|
441
|
+
|
|
442
|
+
// Revoke any scoped approval grants bound to this call session.
|
|
443
|
+
// Revoke by both callSessionId and conversationId because the
|
|
444
|
+
// guardian-approval-interception minting path sets callSessionId: null
|
|
445
|
+
// but always sets conversationId.
|
|
446
|
+
try {
|
|
447
|
+
let revoked = revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
|
|
448
|
+
revoked += revokeScopedApprovalGrantsForContext({ conversationId: this.conversationId });
|
|
449
|
+
if (revoked > 0) {
|
|
450
|
+
log.info({ callSessionId: this.callSessionId, conversationId: this.conversationId, revokedCount: revoked }, 'Revoked scoped grants on call end');
|
|
451
|
+
}
|
|
452
|
+
} catch (err) {
|
|
453
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on call end');
|
|
454
|
+
}
|
|
455
|
+
|
|
339
456
|
log.info({ callSessionId: this.callSessionId }, 'CallController destroyed');
|
|
340
457
|
}
|
|
341
458
|
|
|
@@ -414,6 +531,7 @@ export class CallController {
|
|
|
414
531
|
// bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
|
|
415
532
|
const afterBracket = ttsBuffer;
|
|
416
533
|
const couldBeControl =
|
|
534
|
+
'[ASK_GUARDIAN_APPROVAL:'.startsWith(afterBracket) ||
|
|
417
535
|
'[ASK_GUARDIAN:'.startsWith(afterBracket) ||
|
|
418
536
|
'[USER_ANSWERED:'.startsWith(afterBracket) ||
|
|
419
537
|
'[USER_INSTRUCTION:'.startsWith(afterBracket) ||
|
|
@@ -422,6 +540,7 @@ export class CallController {
|
|
|
422
540
|
'[END_CALL]'.startsWith(afterBracket) ||
|
|
423
541
|
'[GUARDIAN_TIMEOUT]'.startsWith(afterBracket) ||
|
|
424
542
|
'[GUARDIAN_UNAVAILABLE]'.startsWith(afterBracket) ||
|
|
543
|
+
afterBracket.startsWith('[ASK_GUARDIAN_APPROVAL:') ||
|
|
425
544
|
afterBracket.startsWith('[ASK_GUARDIAN:') ||
|
|
426
545
|
afterBracket.startsWith('[USER_ANSWERED:') ||
|
|
427
546
|
afterBracket.startsWith('[USER_INSTRUCTION:') ||
|
|
@@ -472,6 +591,7 @@ export class CallController {
|
|
|
472
591
|
// Start the voice turn through the session bridge
|
|
473
592
|
startVoiceTurn({
|
|
474
593
|
conversationId: this.conversationId,
|
|
594
|
+
callSessionId: this.callSessionId,
|
|
475
595
|
content,
|
|
476
596
|
assistantId: this.assistantId,
|
|
477
597
|
guardianContext: this.guardianContext ?? undefined,
|
|
@@ -528,11 +648,31 @@ export class CallController {
|
|
|
528
648
|
}
|
|
529
649
|
}
|
|
530
650
|
|
|
531
|
-
// Check for
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
651
|
+
// Check for structured tool-approval ASK_GUARDIAN_APPROVAL first,
|
|
652
|
+
// then informational ASK_GUARDIAN. Uses brace-balanced extraction so
|
|
653
|
+
// `}]` inside JSON string values does not truncate the payload or
|
|
654
|
+
// leak partial JSON into TTS output.
|
|
655
|
+
const approvalMatch = extractBalancedJson(responseText);
|
|
656
|
+
let toolApprovalMeta: { question: string; toolName: string; inputDigest: string } | null = null;
|
|
657
|
+
if (approvalMatch) {
|
|
658
|
+
try {
|
|
659
|
+
const parsed = JSON.parse(approvalMatch.json) as { question?: string; toolName?: string; input?: Record<string, unknown> };
|
|
660
|
+
if (parsed.question && parsed.toolName && parsed.input) {
|
|
661
|
+
const digest = computeToolApprovalDigest(parsed.toolName, parsed.input);
|
|
662
|
+
toolApprovalMeta = { question: parsed.question, toolName: parsed.toolName, inputDigest: digest };
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
log.warn({ callSessionId: this.callSessionId }, 'Failed to parse ASK_GUARDIAN_APPROVAL JSON payload');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const askMatch = toolApprovalMeta
|
|
670
|
+
? null // structured approval takes precedence
|
|
671
|
+
: responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
|
|
672
|
+
|
|
673
|
+
const questionText = toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
|
|
535
674
|
|
|
675
|
+
if (questionText) {
|
|
536
676
|
if (this.isCallerGuardian()) {
|
|
537
677
|
// Caller IS the guardian — don't dispatch cross-channel.
|
|
538
678
|
// Queue an instruction so the next turn asks them directly.
|
|
@@ -569,6 +709,8 @@ export class CallController {
|
|
|
569
709
|
conversationId: session.conversationId,
|
|
570
710
|
assistantId: this.assistantId,
|
|
571
711
|
pendingQuestion,
|
|
712
|
+
toolName: toolApprovalMeta?.toolName,
|
|
713
|
+
inputDigest: toolApprovalMeta?.inputDigest,
|
|
572
714
|
});
|
|
573
715
|
}
|
|
574
716
|
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getTwilioStatusCallbackUrl,getTwilioVoiceWebhookUrl } from '../inbound/
|
|
|
13
13
|
import { getOrCreateConversation } from '../memory/conversation-key-store.js';
|
|
14
14
|
import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
|
|
15
15
|
import { upsertBinding } from '../memory/external-conversation-store.js';
|
|
16
|
+
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
16
17
|
import { isGuardian } from '../runtime/channel-guardian-service.js';
|
|
17
18
|
import { getSecureKey } from '../security/secure-keys.js';
|
|
18
19
|
import { getLogger } from '../util/logger.js';
|
|
@@ -489,6 +490,17 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
|
|
|
489
490
|
// Expire any pending questions so they don't linger
|
|
490
491
|
expirePendingQuestions(callSessionId);
|
|
491
492
|
|
|
493
|
+
// Revoke any scoped approval grants bound to this call session.
|
|
494
|
+
// Revoke by both callSessionId and conversationId because the
|
|
495
|
+
// guardian-approval-interception minting path sets callSessionId: null
|
|
496
|
+
// but always sets conversationId.
|
|
497
|
+
try {
|
|
498
|
+
revokeScopedApprovalGrantsForContext({ callSessionId });
|
|
499
|
+
revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
|
|
500
|
+
} catch (err) {
|
|
501
|
+
log.warn({ err, callSessionId }, 'Failed to revoke scoped grants on call cancel');
|
|
502
|
+
}
|
|
503
|
+
|
|
492
504
|
// Re-check final status: a concurrent transition (e.g. Twilio callback) may have
|
|
493
505
|
// moved the session to a terminal state before our update, causing it to be skipped.
|
|
494
506
|
const updated = getCallSession(callSessionId);
|
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
getExpiredGuardianActionRequests,
|
|
18
18
|
} from '../memory/guardian-action-store.js';
|
|
19
19
|
import { deliverChannelReply } from '../runtime/gateway-client.js';
|
|
20
|
-
import type { GuardianActionCopyGenerator } from '../runtime/http-types.js';
|
|
21
20
|
import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
|
|
21
|
+
import type { GuardianActionCopyGenerator } from '../runtime/http-types.js';
|
|
22
22
|
import { getLogger } from '../util/logger.js';
|
|
23
23
|
import { expirePendingQuestions } from './call-store.js';
|
|
24
24
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { getActiveBinding } from '../memory/channel-guardian-store.js';
|
|
11
11
|
import {
|
|
12
|
+
countPendingRequestsByCallSessionId,
|
|
12
13
|
createGuardianActionDelivery,
|
|
13
14
|
createGuardianActionRequest,
|
|
14
15
|
updateDeliveryStatus,
|
|
@@ -26,6 +27,10 @@ export interface GuardianDispatchParams {
|
|
|
26
27
|
conversationId: string;
|
|
27
28
|
assistantId: string;
|
|
28
29
|
pendingQuestion: CallPendingQuestion;
|
|
30
|
+
/** Tool identity for tool-approval requests (absent for informational ASK_GUARDIAN). */
|
|
31
|
+
toolName?: string;
|
|
32
|
+
/** Canonical SHA-256 digest of tool input for tool-approval requests. */
|
|
33
|
+
inputDigest?: string;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
|
|
@@ -47,6 +52,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
|
|
|
47
52
|
conversationId,
|
|
48
53
|
assistantId,
|
|
49
54
|
pendingQuestion,
|
|
55
|
+
toolName,
|
|
56
|
+
inputDigest,
|
|
50
57
|
} = params;
|
|
51
58
|
|
|
52
59
|
try {
|
|
@@ -62,6 +69,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
|
|
|
62
69
|
pendingQuestionId: pendingQuestion.id,
|
|
63
70
|
questionText: pendingQuestion.questionText,
|
|
64
71
|
expiresAt,
|
|
72
|
+
toolName,
|
|
73
|
+
inputDigest,
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
log.info(
|
|
@@ -69,6 +78,12 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
|
|
|
69
78
|
'Created guardian action request',
|
|
70
79
|
);
|
|
71
80
|
|
|
81
|
+
// Count how many guardian requests are already pending for this call.
|
|
82
|
+
// This count is a candidate-affinity hint: the decision engine uses it
|
|
83
|
+
// to prefer reusing an existing thread when multiple questions arise
|
|
84
|
+
// in the same call session.
|
|
85
|
+
const activeGuardianRequestCount = countPendingRequestsByCallSessionId(callSessionId);
|
|
86
|
+
|
|
72
87
|
// Route through the canonical notification pipeline. The paired vellum
|
|
73
88
|
// conversation from this pipeline is the canonical guardian thread.
|
|
74
89
|
let vellumDeliveryId: string | null = null;
|
|
@@ -90,6 +105,7 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
|
|
|
90
105
|
callSessionId,
|
|
91
106
|
questionText: pendingQuestion.questionText,
|
|
92
107
|
pendingQuestionId: pendingQuestion.id,
|
|
108
|
+
activeGuardianRequestCount,
|
|
93
109
|
},
|
|
94
110
|
dedupeKey: `guardian:${request.id}`,
|
|
95
111
|
onThreadCreated: (info) => {
|
|
@@ -12,6 +12,7 @@ import type { ServerWebSocket } from 'bun';
|
|
|
12
12
|
|
|
13
13
|
import { getConfig } from '../config/loader.js';
|
|
14
14
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
15
|
+
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
15
16
|
import {
|
|
16
17
|
getPendingChallenge,
|
|
17
18
|
validateAndConsumeChallenge,
|
|
@@ -351,6 +352,18 @@ export class RelayConnection {
|
|
|
351
352
|
}
|
|
352
353
|
|
|
353
354
|
expirePendingQuestions(this.callSessionId);
|
|
355
|
+
|
|
356
|
+
// Revoke any scoped approval grants bound to this call session.
|
|
357
|
+
// Revoke by both callSessionId and conversationId because the
|
|
358
|
+
// guardian-approval-interception minting path sets callSessionId: null
|
|
359
|
+
// but always sets conversationId.
|
|
360
|
+
try {
|
|
361
|
+
revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
|
|
362
|
+
revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on transport close');
|
|
365
|
+
}
|
|
366
|
+
|
|
354
367
|
persistCallCompletionMessage(session.conversationId, this.callSessionId).catch((err) => {
|
|
355
368
|
log.error({ err, conversationId: session.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
|
|
356
369
|
});
|
|
@@ -18,7 +18,9 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
|
|
|
18
18
|
import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.js';
|
|
19
19
|
import { buildAssistantEvent } from '../runtime/assistant-event.js';
|
|
20
20
|
import { assistantEventHub } from '../runtime/assistant-event-hub.js';
|
|
21
|
+
import { consumeScopedApprovalGrantByToolSignature } from '../memory/scoped-approval-grants.js';
|
|
21
22
|
import { checkIngressForSecrets } from '../security/secret-ingress.js';
|
|
23
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
22
24
|
import { IngressBlockedError } from '../util/errors.js';
|
|
23
25
|
import { getLogger } from '../util/logger.js';
|
|
24
26
|
|
|
@@ -81,6 +83,8 @@ export interface VoiceRunEventSink {
|
|
|
81
83
|
export interface VoiceTurnOptions {
|
|
82
84
|
/** The conversation ID for this voice call's session. */
|
|
83
85
|
conversationId: string;
|
|
86
|
+
/** The call session ID for scoped grant matching. */
|
|
87
|
+
callSessionId?: string;
|
|
84
88
|
/** The transcribed caller utterance or synthetic marker. */
|
|
85
89
|
content: string;
|
|
86
90
|
/** Assistant scope for multi-assistant channels. */
|
|
@@ -129,7 +133,9 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
129
133
|
const disclosureEnabled = config.calls?.disclosure?.enabled === true;
|
|
130
134
|
const disclosureText = config.calls?.disclosure?.text?.trim();
|
|
131
135
|
const disclosureRule = disclosureEnabled && disclosureText
|
|
132
|
-
?
|
|
136
|
+
? opts.isInbound
|
|
137
|
+
? `0. ${disclosureText} This is an inbound call you are answering, so rewrite any disclosure naturally for pickup context. Do NOT say "I'm calling", "I called you", or "I'm calling on behalf of".`
|
|
138
|
+
: `0. ${disclosureText}`
|
|
133
139
|
: '0. Begin the conversation naturally.';
|
|
134
140
|
|
|
135
141
|
const lines: string[] = ['<voice_call_control>'];
|
|
@@ -145,7 +151,12 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
145
151
|
'1. Be concise — keep responses to 1-3 sentences. Phone conversations should be brief and natural.',
|
|
146
152
|
...(opts.isCallerGuardian
|
|
147
153
|
? ['2. You are speaking directly with your guardian (your user). Do NOT use [ASK_GUARDIAN:]. If you need permission, information, or confirmation, ask them directly in the conversation. They can answer you right now.']
|
|
148
|
-
: [
|
|
154
|
+
: [[
|
|
155
|
+
'2. You can consult your guardian in two ways:',
|
|
156
|
+
' - For general questions or information: [ASK_GUARDIAN: your question here]',
|
|
157
|
+
' - For tool/action permission requests: [ASK_GUARDIAN_APPROVAL: {"question":"Describe what you need permission for","toolName":"the_tool_name","input":{...tool input object...}}]',
|
|
158
|
+
' Use ASK_GUARDIAN_APPROVAL when you need permission to execute a specific tool or action. Use ASK_GUARDIAN for everything else (general questions, advice, information). When you use either marker, add a natural hold message like "Let me check on that for you."',
|
|
159
|
+
].join('\n')]
|
|
149
160
|
),
|
|
150
161
|
);
|
|
151
162
|
|
|
@@ -174,7 +185,7 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
174
185
|
);
|
|
175
186
|
} else {
|
|
176
187
|
lines.push(
|
|
177
|
-
'7. If the latest user turn is "(call connected — deliver opening greeting)",
|
|
188
|
+
'7. If the latest user turn is "(call connected — deliver opening greeting)", this is an inbound call you are answering (not a call you initiated). Greet the caller warmly and ask how you can help. Introduce yourself once at the start using your assistant name if you know it (for example: "Hey there, this is Ava, Sam\'s assistant. How can I help?"). If your assistant name is not known, skip the name and just identify yourself as the guardian\'s assistant. Do NOT say "I\'m calling" or "I\'m calling on behalf of". Vary the wording; do not use a fixed template.',
|
|
178
189
|
);
|
|
179
190
|
}
|
|
180
191
|
lines.push(
|
|
@@ -192,7 +203,7 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
192
203
|
|
|
193
204
|
lines.push(
|
|
194
205
|
'9. After the opening greeting turn, treat the Task field as background context only — do not re-execute its instructions on subsequent turns.',
|
|
195
|
-
'10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian.',
|
|
206
|
+
'10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian. For tool permission requests, use [ASK_GUARDIAN_APPROVAL: {"question":"...","toolName":"...","input":{...}}].',
|
|
196
207
|
'</voice_call_control>',
|
|
197
208
|
);
|
|
198
209
|
|
|
@@ -337,9 +348,39 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
|
|
|
337
348
|
session.updateClient((msg: ServerMessage) => {
|
|
338
349
|
if (msg.type === 'confirmation_request') {
|
|
339
350
|
if (autoDeny) {
|
|
351
|
+
// Before auto-denying, check if a guardian from another channel
|
|
352
|
+
// has pre-approved this exact tool invocation via a scoped grant.
|
|
353
|
+
const inputDigest = computeToolApprovalDigest(msg.toolName, msg.input);
|
|
354
|
+
const consumeResult = consumeScopedApprovalGrantByToolSignature({
|
|
355
|
+
toolName: msg.toolName,
|
|
356
|
+
inputDigest,
|
|
357
|
+
consumingRequestId: msg.requestId,
|
|
358
|
+
assistantId: opts.assistantId,
|
|
359
|
+
executionChannel: 'voice',
|
|
360
|
+
conversationId: opts.conversationId,
|
|
361
|
+
callSessionId: opts.callSessionId,
|
|
362
|
+
requesterExternalUserId: opts.guardianContext?.requesterExternalUserId,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (consumeResult.ok) {
|
|
366
|
+
log.info(
|
|
367
|
+
{ turnId, toolName: msg.toolName, grantId: consumeResult.grant?.id },
|
|
368
|
+
'Consumed scoped grant — allowing non-guardian voice confirmation',
|
|
369
|
+
);
|
|
370
|
+
session.handleConfirmationResponse(
|
|
371
|
+
msg.requestId,
|
|
372
|
+
'allow',
|
|
373
|
+
undefined,
|
|
374
|
+
undefined,
|
|
375
|
+
`Permission approved for "${msg.toolName}": guardian pre-approved via scoped grant.`,
|
|
376
|
+
);
|
|
377
|
+
publishToHub(msg);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
340
381
|
log.info(
|
|
341
382
|
{ turnId, toolName: msg.toolName },
|
|
342
|
-
'Auto-denying confirmation request for voice turn (
|
|
383
|
+
'Auto-denying confirmation request for voice turn (no matching scoped grant)',
|
|
343
384
|
);
|
|
344
385
|
session.handleConfirmationResponse(
|
|
345
386
|
msg.requestId,
|
package/src/cli/core-commands.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function registerDevCommand(program: Command): void {
|
|
|
109
109
|
.command('dev')
|
|
110
110
|
.description('Run the daemon in dev mode with auto-restart on file changes')
|
|
111
111
|
.action(async () => {
|
|
112
|
-
|
|
112
|
+
let status = await getDaemonStatus();
|
|
113
113
|
if (status.running) {
|
|
114
114
|
log.info('Stopping existing daemon...');
|
|
115
115
|
const stopResult = await stopDaemon();
|
|
@@ -117,6 +117,46 @@ export function registerDevCommand(program: Command): void {
|
|
|
117
117
|
log.error('Failed to stop existing daemon — process survived SIGKILL');
|
|
118
118
|
process.exit(1);
|
|
119
119
|
}
|
|
120
|
+
} else if (status.pid) {
|
|
121
|
+
// PID file references a live process but the socket is unresponsive.
|
|
122
|
+
// This can happen during the daemon startup window before the socket
|
|
123
|
+
// is bound. Wait briefly for it to come up before replacing.
|
|
124
|
+
log.info('Daemon process alive but socket unresponsive — waiting for startup...');
|
|
125
|
+
const maxWait = 5000;
|
|
126
|
+
const interval = 500;
|
|
127
|
+
let waited = 0;
|
|
128
|
+
let resolved = false;
|
|
129
|
+
while (waited < maxWait) {
|
|
130
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
131
|
+
waited += interval;
|
|
132
|
+
status = await getDaemonStatus();
|
|
133
|
+
if (status.running) {
|
|
134
|
+
// Socket came up — stop the daemon normally.
|
|
135
|
+
log.info('Daemon became responsive, stopping it...');
|
|
136
|
+
const stopResult = await stopDaemon();
|
|
137
|
+
if (!stopResult.stopped && stopResult.reason === 'stop_failed') {
|
|
138
|
+
log.error('Failed to stop existing daemon — process survived SIGKILL');
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
resolved = true;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (!status.pid) {
|
|
145
|
+
// Process exited on its own — PID file already cleaned up.
|
|
146
|
+
resolved = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!resolved) {
|
|
151
|
+
// Still alive but unresponsive after waiting — stop it via stopDaemon()
|
|
152
|
+
// which handles SIGTERM → SIGKILL escalation and PID file cleanup.
|
|
153
|
+
log.info('Daemon still unresponsive after wait — stopping it...');
|
|
154
|
+
const stopResult = await stopDaemon();
|
|
155
|
+
if (!stopResult.stopped && stopResult.reason === 'stop_failed') {
|
|
156
|
+
log.error('Failed to stop existing daemon — process survived SIGKILL');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
120
160
|
}
|
|
121
161
|
|
|
122
162
|
const mainPath = `${import.meta.dirname}/../daemon/main.ts`;
|