@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
|
@@ -12,6 +12,24 @@ Use `send_notification` for user-facing alerts and notifications. This tool rout
|
|
|
12
12
|
- `preferred_channels` are **routing hints**, not hard channel forcing. The notification router makes the final delivery decision based on user preferences, channel availability, and urgency.
|
|
13
13
|
- Channel selection and delivery are handled entirely by the notification router -- do not attempt to control delivery manually.
|
|
14
14
|
|
|
15
|
+
## Deduplication (`dedupe_key`)
|
|
16
|
+
|
|
17
|
+
- `dedupe_key` suppresses duplicate signals. A second notification with the same key is **dropped entirely** within a **1-hour window**. After the window expires, the same key is accepted again.
|
|
18
|
+
- Never reuse a `dedupe_key` across logically distinct notifications, even if they are related. The key means "this exact event already fired," not "these events are in the same category."
|
|
19
|
+
- If you omit `dedupe_key`, the LLM decision engine may generate one automatically based on signal context. This means even keyless signals can be deduplicated if the engine considers them duplicates of a recent event.
|
|
20
|
+
|
|
21
|
+
## Threading
|
|
22
|
+
|
|
23
|
+
Thread grouping is handled by the LLM-powered decision engine, not by any parameter you pass. There is no explicit "post to thread X" parameter — thread reuse is inferred, not commanded.
|
|
24
|
+
|
|
25
|
+
**How it works:** The engine evaluates recent notification thread candidates and decides whether a new signal is a continuation of an existing thread based on `source_event_name`, provenance metadata, and message content. Use natural, descriptive titles and bodies — the engine groups by semantic relatedness, not string matching.
|
|
26
|
+
|
|
27
|
+
**`source_event_name` is the primary grouping signal.** Use a stable event name for notifications that belong to the same logical stream (e.g. `dog.news.thread.reply` for all replies in a thread). Use a distinct event name when the notification represents a genuinely different kind of event.
|
|
28
|
+
|
|
29
|
+
**Practical constraints:**
|
|
30
|
+
- Thread candidates are scoped to the **last 24 hours** (max 5 per channel). You cannot reuse an old thread from days ago.
|
|
31
|
+
- The engine will only reuse conversations originally created by the notification system (`source === 'notification'`). It will never append to a user-initiated conversation, even if it looks related.
|
|
32
|
+
|
|
15
33
|
## Important
|
|
16
34
|
|
|
17
35
|
- Do **NOT** use AppleScript `display notification` or other OS-level notification commands for assistant-managed alerts. Always use `send_notification`.
|
package/src/config/schema.ts
CHANGED
|
@@ -117,12 +117,18 @@ export {
|
|
|
117
117
|
SandboxConfigSchema,
|
|
118
118
|
} from './sandbox-schema.js';
|
|
119
119
|
export type {
|
|
120
|
+
RemotePolicyConfig,
|
|
121
|
+
RemoteProviderConfig,
|
|
122
|
+
RemoteProvidersConfig,
|
|
120
123
|
SkillEntryConfig,
|
|
121
124
|
SkillsConfig,
|
|
122
125
|
SkillsInstallConfig,
|
|
123
126
|
SkillsLoadConfig,
|
|
124
127
|
} from './skills-schema.js';
|
|
125
128
|
export {
|
|
129
|
+
RemotePolicyConfigSchema,
|
|
130
|
+
RemoteProviderConfigSchema,
|
|
131
|
+
RemoteProvidersConfigSchema,
|
|
126
132
|
SkillEntryConfigSchema,
|
|
127
133
|
SkillsConfigSchema,
|
|
128
134
|
SkillsInstallConfigSchema,
|
|
@@ -19,14 +19,41 @@ export const SkillsInstallConfigSchema = z.object({
|
|
|
19
19
|
}).default('npm'),
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
export const RemoteProviderConfigSchema = z.object({
|
|
23
|
+
enabled: z.boolean({ error: 'skills.remoteProviders.<provider>.enabled must be a boolean' }).default(true),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const RemoteProvidersConfigSchema = z.object({
|
|
27
|
+
skillssh: RemoteProviderConfigSchema.default({} as any),
|
|
28
|
+
clawhub: RemoteProviderConfigSchema.default({} as any),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const VALID_SKILLS_SH_RISK_LEVELS = ['safe', 'low', 'medium', 'high', 'critical', 'unknown'] as const;
|
|
32
|
+
// 'unknown' is valid as a risk label on a skill but not as a threshold — setting the threshold
|
|
33
|
+
// to 'unknown' would silently disable fail-closed behavior since nothing can exceed it.
|
|
34
|
+
const VALID_MAX_RISK_LEVELS = ['safe', 'low', 'medium', 'high', 'critical'] as const;
|
|
35
|
+
|
|
36
|
+
export const RemotePolicyConfigSchema = z.object({
|
|
37
|
+
blockSuspicious: z.boolean({ error: 'skills.remotePolicy.blockSuspicious must be a boolean' }).default(true),
|
|
38
|
+
blockMalware: z.boolean({ error: 'skills.remotePolicy.blockMalware must be a boolean' }).default(true),
|
|
39
|
+
maxSkillsShRisk: z.enum(VALID_MAX_RISK_LEVELS, {
|
|
40
|
+
error: `skills.remotePolicy.maxSkillsShRisk must be one of: ${VALID_MAX_RISK_LEVELS.join(', ')}`,
|
|
41
|
+
}).default('medium'),
|
|
42
|
+
});
|
|
43
|
+
|
|
22
44
|
export const SkillsConfigSchema = z.object({
|
|
23
45
|
entries: z.record(z.string(), SkillEntryConfigSchema).default({} as any),
|
|
24
46
|
load: SkillsLoadConfigSchema.default({} as any),
|
|
25
47
|
install: SkillsInstallConfigSchema.default({} as any),
|
|
26
48
|
allowBundled: z.array(z.string()).nullable().default(null),
|
|
49
|
+
remoteProviders: RemoteProvidersConfigSchema.default({} as any),
|
|
50
|
+
remotePolicy: RemotePolicyConfigSchema.default({} as any),
|
|
27
51
|
});
|
|
28
52
|
|
|
29
53
|
export type SkillEntryConfig = z.infer<typeof SkillEntryConfigSchema>;
|
|
30
54
|
export type SkillsLoadConfig = z.infer<typeof SkillsLoadConfigSchema>;
|
|
31
55
|
export type SkillsInstallConfig = z.infer<typeof SkillsInstallConfigSchema>;
|
|
56
|
+
export type RemoteProviderConfig = z.infer<typeof RemoteProviderConfigSchema>;
|
|
57
|
+
export type RemoteProvidersConfig = z.infer<typeof RemoteProvidersConfigSchema>;
|
|
58
|
+
export type RemotePolicyConfig = z.infer<typeof RemotePolicyConfigSchema>;
|
|
32
59
|
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
_ Lines starting with _ are comments — they won't appear in the system prompt
|
|
2
|
-
|
|
3
|
-
# UPDATES.md
|
|
4
|
-
|
|
2
|
+
_
|
|
5
3
|
_ This file contains release update notes for the assistant.
|
|
6
4
|
_ Each release block is wrapped with HTML comment markers:
|
|
7
5
|
_ <!-- vellum-update-release:<version> -->
|
|
@@ -11,6 +9,7 @@ _
|
|
|
11
9
|
_ Format is freeform markdown. Write notes that help the assistant
|
|
12
10
|
_ understand what changed and how it affects behavior, capabilities,
|
|
13
11
|
_ or available tools. Focus on what matters to the user experience.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
_
|
|
13
|
+
_ To add release notes, replace this content with real markdown
|
|
14
|
+
_ describing what changed. The sync will only materialize a bulletin
|
|
15
|
+
_ when non-comment content is present.
|
|
@@ -44,7 +44,9 @@ export function appendReleaseBlock(
|
|
|
44
44
|
/** Extracts all version strings from release markers found in `content`. */
|
|
45
45
|
export function extractReleaseIds(content: string): string[] {
|
|
46
46
|
const ids: string[] = [];
|
|
47
|
+
MARKER_REGEX.lastIndex = 0;
|
|
47
48
|
let match: RegExpExecArray | null;
|
|
49
|
+
// eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
|
|
48
50
|
while ((match = MARKER_REGEX.exec(content)) !== null) {
|
|
49
51
|
ids.push(match[1]);
|
|
50
52
|
}
|
|
@@ -4,7 +4,7 @@ const ACTIVE_RELEASES_KEY = 'updates:active_releases';
|
|
|
4
4
|
const COMPLETED_RELEASES_KEY = 'updates:completed_releases';
|
|
5
5
|
|
|
6
6
|
function parseReleaseArray(raw: string | null): string[] {
|
|
7
|
-
if (raw
|
|
7
|
+
if (!raw) return [];
|
|
8
8
|
try {
|
|
9
9
|
const parsed = JSON.parse(raw);
|
|
10
10
|
if (!Array.isArray(parsed)) return [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
2
|
|
|
3
|
+
import { getWorkspacePromptPath } from '../util/platform.js';
|
|
4
|
+
import { APP_VERSION } from '../version.js';
|
|
4
5
|
import { stripCommentLines } from './system-prompt.js';
|
|
5
6
|
import { appendReleaseBlock, hasReleaseBlock } from './update-bulletin-format.js';
|
|
6
7
|
import {
|
|
@@ -10,8 +11,7 @@ import {
|
|
|
10
11
|
markReleasesCompleted,
|
|
11
12
|
setActiveReleases,
|
|
12
13
|
} from './update-bulletin-state.js';
|
|
13
|
-
import {
|
|
14
|
-
import { getWorkspacePromptPath } from '../util/platform.js';
|
|
14
|
+
import { getTemplatePath } from './update-bulletin-template-path.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Writes content to a file via a temp-file + rename to prevent partial/truncated
|
|
@@ -21,7 +21,22 @@ function atomicWriteFileSync(filePath: string, content: string): void {
|
|
|
21
21
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
22
22
|
try {
|
|
23
23
|
writeFileSync(tmpPath, content, 'utf-8');
|
|
24
|
-
|
|
24
|
+
// Resolve symlinks so we rename to the real target, preserving the link.
|
|
25
|
+
// If the symlink is dangling (target doesn't exist), fall back to writing
|
|
26
|
+
// through the symlink path directly — realpathSync throws ENOENT for dangling links.
|
|
27
|
+
let targetPath = filePath;
|
|
28
|
+
try {
|
|
29
|
+
if (lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
30
|
+
targetPath = realpathSync(filePath);
|
|
31
|
+
}
|
|
32
|
+
} catch (err: unknown) {
|
|
33
|
+
// Dangling symlink — fall back to writing through the symlink path.
|
|
34
|
+
// Only swallow ENOENT (dangling target); re-throw ELOOP, EACCES, I/O faults, etc.
|
|
35
|
+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
renameSync(tmpPath, targetPath);
|
|
25
40
|
} catch (err) {
|
|
26
41
|
try {
|
|
27
42
|
unlinkSync(tmpPath);
|
|
@@ -57,7 +72,7 @@ export function syncUpdateBulletinOnStartup(): void {
|
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
// --- Template materialization ---
|
|
60
|
-
const templatePath =
|
|
75
|
+
const templatePath = getTemplatePath();
|
|
61
76
|
if (!existsSync(templatePath)) return;
|
|
62
77
|
|
|
63
78
|
const rawTemplate = readFileSync(templatePath, 'utf-8');
|
|
@@ -90,8 +90,9 @@ export class ConfigWatcher {
|
|
|
90
90
|
/**
|
|
91
91
|
* Start all file watchers. `onSessionEvict` is called when watched
|
|
92
92
|
* files change and sessions need to be evicted for reload.
|
|
93
|
+
* `onIdentityChanged` is called when IDENTITY.md changes on disk.
|
|
93
94
|
*/
|
|
94
|
-
start(onSessionEvict: () => void): void {
|
|
95
|
+
start(onSessionEvict: () => void, onIdentityChanged?: () => void): void {
|
|
95
96
|
const workspaceDir = getWorkspaceDir();
|
|
96
97
|
const protectedDir = join(getRootDir(), 'protected');
|
|
97
98
|
|
|
@@ -106,7 +107,7 @@ export class ConfigWatcher {
|
|
|
106
107
|
}
|
|
107
108
|
},
|
|
108
109
|
'SOUL.md': () => onSessionEvict(),
|
|
109
|
-
'IDENTITY.md': () => onSessionEvict(),
|
|
110
|
+
'IDENTITY.md': () => { onSessionEvict(); onIdentityChanged?.(); },
|
|
110
111
|
'USER.md': () => onSessionEvict(),
|
|
111
112
|
'LOOKS.md': () => onSessionEvict(),
|
|
112
113
|
'UPDATES.md': () => onSessionEvict(),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { execSync, spawn } from 'node:child_process';
|
|
2
2
|
import { closeSync,existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createConnection } from 'node:net';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
@@ -70,9 +70,17 @@ function killStaleDaemon(): void {
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
73
|
+
// Guard against stale PID reuse: if the PID has been recycled by the OS
|
|
74
|
+
// and now belongs to an unrelated process, we must not signal it.
|
|
75
|
+
if (!isVellumDaemonProcess(pid)) {
|
|
76
|
+
log.info({ pid }, 'PID file references a non-vellum process (stale PID reuse) — cleaning up PID file only');
|
|
77
|
+
cleanupPidFile();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The PID file references a live vellum daemon process, but getDaemonStatus()
|
|
82
|
+
// (called earlier in startDaemon) already returns early when the daemon is
|
|
83
|
+
// healthy. If we reach here, the recorded process is alive but non-responsive.
|
|
76
84
|
try {
|
|
77
85
|
log.info({ pid }, 'Killing stale daemon process from PID file');
|
|
78
86
|
process.kill(pid, 'SIGKILL');
|
|
@@ -91,6 +99,27 @@ function isProcessRunning(pid: number): boolean {
|
|
|
91
99
|
}
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Check whether a PID belongs to a vellum daemon process (a bun process
|
|
104
|
+
* running the daemon's main.ts). Prevents signaling an unrelated process
|
|
105
|
+
* that reused a stale PID.
|
|
106
|
+
*/
|
|
107
|
+
function isVellumDaemonProcess(pid: number): boolean {
|
|
108
|
+
try {
|
|
109
|
+
const cmd = execSync(`ps -p ${pid} -o command=`, {
|
|
110
|
+
encoding: 'utf-8',
|
|
111
|
+
timeout: 3000,
|
|
112
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
113
|
+
}).trim();
|
|
114
|
+
// The daemon is spawned as `bun run <path>/main.ts` — look for bun
|
|
115
|
+
// running our daemon entry point.
|
|
116
|
+
return cmd.includes('bun') && cmd.includes('daemon/main.ts');
|
|
117
|
+
} catch {
|
|
118
|
+
// Process exited or ps failed — treat as not ours.
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
94
123
|
/** Try a TCP connect to the Unix socket. Returns true if the connection
|
|
95
124
|
* handshake completes within the timeout — false on connection refused,
|
|
96
125
|
* timeout, or missing socket file. */
|
|
@@ -166,10 +195,17 @@ export async function getDaemonStatus(): Promise<{ running: boolean; pid?: numbe
|
|
|
166
195
|
cleanupPidFile();
|
|
167
196
|
return { running: false };
|
|
168
197
|
}
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
198
|
+
// Guard against stale PID reuse: if the OS recycled the PID and it now
|
|
199
|
+
// belongs to an unrelated process, discard the stale PID file.
|
|
200
|
+
if (!isVellumDaemonProcess(pid)) {
|
|
201
|
+
log.info({ pid }, 'PID file references a non-vellum process (stale PID reuse) — cleaning up');
|
|
202
|
+
cleanupPidFile();
|
|
203
|
+
return { running: false };
|
|
204
|
+
}
|
|
205
|
+
// Process is alive and is ours — verify the socket is responsive. A
|
|
206
|
+
// deadlocked or wedged daemon will pass the PID liveness check but fail
|
|
207
|
+
// to accept connections, and should be treated as not running so
|
|
208
|
+
// killStaleDaemon() can clean it up.
|
|
173
209
|
const responsive = await isSocketResponsive();
|
|
174
210
|
if (!responsive) {
|
|
175
211
|
log.warn({ pid }, 'Daemon process alive but socket unresponsive');
|
|
@@ -188,6 +224,11 @@ function getStartupLockPath(): string {
|
|
|
188
224
|
function acquireStartupLock(): boolean {
|
|
189
225
|
const lockPath = getStartupLockPath();
|
|
190
226
|
try {
|
|
227
|
+
// Ensure the root directory exists before attempting the lock file write.
|
|
228
|
+
// On a first-time run, getRootDir() may not exist yet, and writeFileSync
|
|
229
|
+
// with 'wx' would throw ENOENT — which the catch block misinterprets as
|
|
230
|
+
// "lock already held."
|
|
231
|
+
mkdirSync(getRootDir(), { recursive: true });
|
|
191
232
|
// O_CREAT | O_EXCL — fails atomically if the file already exists.
|
|
192
233
|
writeFileSync(lockPath, String(Date.now()), { flag: 'wx' });
|
|
193
234
|
return true;
|
|
@@ -226,12 +267,16 @@ export async function startDaemon(): Promise<{
|
|
|
226
267
|
const lockWaitMs = 10_000;
|
|
227
268
|
const lockInterval = 200;
|
|
228
269
|
let lockWaited = 0;
|
|
270
|
+
let lockAcquired = false;
|
|
229
271
|
while (lockWaited < lockWaitMs) {
|
|
230
272
|
await new Promise((r) => setTimeout(r, lockInterval));
|
|
231
273
|
lockWaited += lockInterval;
|
|
232
|
-
if (acquireStartupLock())
|
|
274
|
+
if (acquireStartupLock()) {
|
|
275
|
+
lockAcquired = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
233
278
|
}
|
|
234
|
-
if (
|
|
279
|
+
if (!lockAcquired) {
|
|
235
280
|
// Timed out waiting for the lock — re-check status in case the
|
|
236
281
|
// other caller succeeded.
|
|
237
282
|
const recheck = await getDaemonStatus();
|
|
@@ -358,6 +403,15 @@ export async function stopDaemon(): Promise<StopResult> {
|
|
|
358
403
|
return { stopped: false, reason: 'not_running' };
|
|
359
404
|
}
|
|
360
405
|
|
|
406
|
+
// Guard against stale PID reuse: if the PID has been recycled by the OS
|
|
407
|
+
// and now belongs to an unrelated process, clean up the PID file but
|
|
408
|
+
// never signal it.
|
|
409
|
+
if (!isVellumDaemonProcess(pid)) {
|
|
410
|
+
log.info({ pid }, 'PID file references a non-vellum process (stale PID reuse) — cleaning up PID file only');
|
|
411
|
+
cleanupPidFile();
|
|
412
|
+
return { stopped: false, reason: 'not_running' };
|
|
413
|
+
}
|
|
414
|
+
|
|
361
415
|
process.kill(pid, 'SIGTERM');
|
|
362
416
|
|
|
363
417
|
const timeouts = readDaemonTimeouts();
|
|
@@ -2,6 +2,7 @@ import * as net from 'node:net';
|
|
|
2
2
|
|
|
3
3
|
import type { ChannelId } from '../../channels/types.js';
|
|
4
4
|
import * as externalConversationStore from '../../memory/external-conversation-store.js';
|
|
5
|
+
import { findMember, revokeMember } from '../../memory/ingress-member-store.js';
|
|
5
6
|
import {
|
|
6
7
|
createVerificationChallenge,
|
|
7
8
|
findActiveSession,
|
|
@@ -173,8 +174,25 @@ export function handleGuardianVerification(
|
|
|
173
174
|
const result = getGuardianStatus(channel, assistantId);
|
|
174
175
|
ctx.send(socket, { type: 'guardian_verification_response', ...result });
|
|
175
176
|
} else if (msg.action === 'revoke') {
|
|
177
|
+
// Capture binding before revoking so we can revoke the guardian's
|
|
178
|
+
// ingress member record — without this, the guardian would still pass
|
|
179
|
+
// the ACL check after unbinding.
|
|
180
|
+
const bindingBeforeRevoke = getGuardianBinding(assistantId, channel);
|
|
176
181
|
revokeGuardianBinding(assistantId, channel);
|
|
177
182
|
revokePendingChallenges(assistantId, channel);
|
|
183
|
+
|
|
184
|
+
if (bindingBeforeRevoke) {
|
|
185
|
+
const member = findMember({
|
|
186
|
+
assistantId,
|
|
187
|
+
sourceChannel: channel,
|
|
188
|
+
externalUserId: bindingBeforeRevoke.guardianExternalUserId,
|
|
189
|
+
externalChatId: bindingBeforeRevoke.guardianDeliveryChatId,
|
|
190
|
+
});
|
|
191
|
+
if (member) {
|
|
192
|
+
revokeMember(member.id, 'guardian_binding_revoked');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
ctx.send(socket, {
|
|
179
197
|
type: 'guardian_verification_response',
|
|
180
198
|
success: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { deleteSecureKey, getSecureKey, setSecureKey } from '../../security/secure-keys.js';
|
|
2
2
|
import { deleteCredentialMetadata, getCredentialMetadata, upsertCredentialMetadata } from '../../tools/credentials/metadata-store.js';
|
|
3
|
-
import { log } from './shared.js';
|
|
3
|
+
import { log as _log } from './shared.js';
|
|
4
4
|
|
|
5
5
|
// -- Result type --
|
|
6
6
|
|
|
@@ -6,6 +6,45 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { getWorkspacePromptPath, readLockfile } from '../../util/platform.js';
|
|
7
7
|
import { defineHandlers, type HandlerContext,log } from './shared.js';
|
|
8
8
|
|
|
9
|
+
export interface IdentityFields {
|
|
10
|
+
name: string;
|
|
11
|
+
role: string;
|
|
12
|
+
personality: string;
|
|
13
|
+
emoji: string;
|
|
14
|
+
home: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parse the core identity fields from IDENTITY.md content. */
|
|
18
|
+
export function parseIdentityFields(content: string): IdentityFields {
|
|
19
|
+
const fields: Record<string, string> = {};
|
|
20
|
+
for (const line of content.split('\n')) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
const lower = trimmed.toLowerCase();
|
|
23
|
+
const extract = (prefix: string): string | null => {
|
|
24
|
+
if (!lower.startsWith(prefix)) return null;
|
|
25
|
+
return trimmed.split(':**').pop()?.trim() ?? null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const name = extract('- **name:**');
|
|
29
|
+
if (name) { fields.name = name; continue; }
|
|
30
|
+
const role = extract('- **role:**');
|
|
31
|
+
if (role) { fields.role = role; continue; }
|
|
32
|
+
const personality = extract('- **personality:**') ?? extract('- **vibe:**');
|
|
33
|
+
if (personality) { fields.personality = personality; continue; }
|
|
34
|
+
const emoji = extract('- **emoji:**');
|
|
35
|
+
if (emoji) { fields.emoji = emoji; continue; }
|
|
36
|
+
const home = extract('- **home:**');
|
|
37
|
+
if (home) { fields.home = home; continue; }
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name: fields.name ?? '',
|
|
41
|
+
role: fields.role ?? '',
|
|
42
|
+
personality: fields.personality ?? '',
|
|
43
|
+
emoji: fields.emoji ?? '',
|
|
44
|
+
home: fields.home ?? '',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
9
48
|
function handleIdentityGet(socket: net.Socket, ctx: HandlerContext): void {
|
|
10
49
|
const identityPath = getWorkspacePromptPath('IDENTITY.md');
|
|
11
50
|
|
|
@@ -24,26 +63,7 @@ function handleIdentityGet(socket: net.Socket, ctx: HandlerContext): void {
|
|
|
24
63
|
|
|
25
64
|
try {
|
|
26
65
|
const content = readFileSync(identityPath, 'utf-8');
|
|
27
|
-
const fields
|
|
28
|
-
for (const line of content.split('\n')) {
|
|
29
|
-
const trimmed = line.trim();
|
|
30
|
-
const lower = trimmed.toLowerCase();
|
|
31
|
-
const extract = (prefix: string): string | null => {
|
|
32
|
-
if (!lower.startsWith(prefix)) return null;
|
|
33
|
-
return trimmed.split(':**').pop()?.trim() ?? null;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const name = extract('- **name:**');
|
|
37
|
-
if (name) { fields.name = name; continue; }
|
|
38
|
-
const role = extract('- **role:**');
|
|
39
|
-
if (role) { fields.role = role; continue; }
|
|
40
|
-
const personality = extract('- **personality:**') ?? extract('- **vibe:**');
|
|
41
|
-
if (personality) { fields.personality = personality; continue; }
|
|
42
|
-
const emoji = extract('- **emoji:**');
|
|
43
|
-
if (emoji) { fields.emoji = emoji; continue; }
|
|
44
|
-
const home = extract('- **home:**');
|
|
45
|
-
if (home) { fields.home = home; continue; }
|
|
46
|
-
}
|
|
66
|
+
const fields = parseIdentityFields(content);
|
|
47
67
|
|
|
48
68
|
// Read version from package.json
|
|
49
69
|
let version: string | undefined;
|
|
@@ -90,11 +110,11 @@ function handleIdentityGet(socket: net.Socket, ctx: HandlerContext): void {
|
|
|
90
110
|
ctx.send(socket, {
|
|
91
111
|
type: 'identity_get_response',
|
|
92
112
|
found: true,
|
|
93
|
-
name: fields.name
|
|
94
|
-
role: fields.role
|
|
95
|
-
personality: fields.personality
|
|
96
|
-
emoji: fields.emoji
|
|
97
|
-
home: fields.home
|
|
113
|
+
name: fields.name,
|
|
114
|
+
role: fields.role,
|
|
115
|
+
personality: fields.personality,
|
|
116
|
+
emoji: fields.emoji,
|
|
117
|
+
home: fields.home,
|
|
98
118
|
version,
|
|
99
119
|
assistantId,
|
|
100
120
|
createdAt,
|
|
@@ -38,8 +38,8 @@ import { normalizeThreadType } from '../ipc-protocol.js';
|
|
|
38
38
|
import { executeRecordingIntent } from '../recording-executor.js';
|
|
39
39
|
import { resolveRecordingIntent } from '../recording-intent.js';
|
|
40
40
|
import { classifyRecordingIntentFallback, containsRecordingKeywords } from '../recording-intent-fallback.js';
|
|
41
|
-
import { resolveChannelCapabilities } from '../session-runtime-assembly.js';
|
|
42
41
|
import { buildSessionErrorMessage,classifySessionError } from '../session-error.js';
|
|
42
|
+
import { resolveChannelCapabilities } from '../session-runtime-assembly.js';
|
|
43
43
|
import { generateVideoThumbnail } from '../video-thumbnail.js';
|
|
44
44
|
import { handleRecordingPause, handleRecordingRestart, handleRecordingResume, handleRecordingStart, handleRecordingStop } from './recording.js';
|
|
45
45
|
import {
|
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { getConfig, invalidateConfigCache,loadRawConfig, saveRawConfig } from '../../config/loader.js';
|
|
6
6
|
import { resolveSkillStates } from '../../config/skill-state.js';
|
|
7
|
-
import { ensureSkillIcon,loadSkillBySelector, loadSkillCatalog } from '../../config/skills.js';
|
|
7
|
+
import { ensureSkillIcon,loadSkillBySelector, loadSkillCatalog, type SkillSummary } from '../../config/skills.js';
|
|
8
8
|
import { createTimeout,extractText, getConfiguredProvider, userMessage } from '../../providers/provider-send-message.js';
|
|
9
9
|
import { clawhubCheckUpdates, clawhubInspect, clawhubInstall, clawhubSearch, type ClawhubSearchResultItem,clawhubUpdate } from '../../skills/clawhub.js';
|
|
10
10
|
import { createManagedSkill,deleteManagedSkill, removeSkillsIndexEntry, validateManagedSkillId } from '../../skills/managed-store.js';
|
|
@@ -26,6 +26,48 @@ import type {
|
|
|
26
26
|
} from '../ipc-protocol.js';
|
|
27
27
|
import { CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, ensureSkillEntry, type HandlerContext,log } from './shared.js';
|
|
28
28
|
|
|
29
|
+
// ─── Provenance resolution ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
interface SkillProvenance {
|
|
32
|
+
kind: 'first-party' | 'third-party' | 'local';
|
|
33
|
+
provider?: string;
|
|
34
|
+
originId?: string;
|
|
35
|
+
sourceUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CLAWHUB_BASE_URL = 'https://skills.sh';
|
|
39
|
+
|
|
40
|
+
function resolveProvenance(summary: SkillSummary): SkillProvenance {
|
|
41
|
+
// Bundled skills are always first-party (shipped with Vellum)
|
|
42
|
+
if (summary.source === 'bundled') {
|
|
43
|
+
return { kind: 'first-party', provider: 'Vellum' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Managed skills could be either first-party (installed from Vellum catalog)
|
|
47
|
+
// or third-party (installed from clawhub). The homepage field serves as a
|
|
48
|
+
// heuristic: Vellum catalog skills don't typically have a clawhub homepage.
|
|
49
|
+
if (summary.source === 'managed') {
|
|
50
|
+
if (summary.homepage?.includes('skills.sh') || summary.homepage?.includes('clawhub')) {
|
|
51
|
+
return {
|
|
52
|
+
kind: 'third-party',
|
|
53
|
+
provider: 'skills.sh',
|
|
54
|
+
originId: summary.id,
|
|
55
|
+
sourceUrl: summary.homepage ?? `${CLAWHUB_BASE_URL}/skills/${encodeURIComponent(summary.id)}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// No positive evidence of origin -- could be user-authored or from Vellum catalog.
|
|
59
|
+
// Default to "local" to avoid mislabeling user-created skills as first-party.
|
|
60
|
+
return { kind: 'local' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Workspace and extra skills are user-provided
|
|
64
|
+
if (summary.source === 'workspace' || summary.source === 'extra') {
|
|
65
|
+
return { kind: 'local' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { kind: 'local' };
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
export function handleSkillsList(socket: net.Socket, ctx: HandlerContext): void {
|
|
30
72
|
const config = getConfig();
|
|
31
73
|
const catalog = loadSkillCatalog();
|
|
@@ -37,12 +79,13 @@ export function handleSkillsList(socket: net.Socket, ctx: HandlerContext): void
|
|
|
37
79
|
description: r.summary.description,
|
|
38
80
|
emoji: r.summary.emoji,
|
|
39
81
|
homepage: r.summary.homepage,
|
|
40
|
-
source: r.summary.source
|
|
82
|
+
source: r.summary.source,
|
|
41
83
|
state: (r.state === 'degraded' ? 'enabled' : r.state) as 'enabled' | 'disabled' | 'available',
|
|
42
84
|
degraded: r.degraded,
|
|
43
85
|
missingRequirements: r.missingRequirements,
|
|
44
86
|
updateAvailable: false,
|
|
45
87
|
userInvocable: r.summary.userInvocable,
|
|
88
|
+
provenance: resolveProvenance(r.summary),
|
|
46
89
|
}));
|
|
47
90
|
|
|
48
91
|
ctx.send(socket, { type: 'skills_list_response', skills });
|
|
@@ -213,7 +213,7 @@ export interface AssistantAttention {
|
|
|
213
213
|
|
|
214
214
|
export interface SessionListResponse {
|
|
215
215
|
type: 'session_list_response';
|
|
216
|
-
sessions: Array<{ id: string; title: string; createdAt
|
|
216
|
+
sessions: Array<{ id: string; title: string; createdAt?: number; updatedAt: number; threadType?: ThreadType; source?: string; channelBinding?: ChannelBinding; conversationOriginChannel?: ChannelId; conversationOriginInterface?: InterfaceId; assistantAttention?: AssistantAttention }>;
|
|
217
217
|
/** Whether more sessions exist beyond the returned page. */
|
|
218
218
|
hasMore?: boolean;
|
|
219
219
|
}
|
|
@@ -95,6 +95,7 @@ export interface SkillsListResponse {
|
|
|
95
95
|
updateAvailable: boolean;
|
|
96
96
|
userInvocable: boolean;
|
|
97
97
|
clawhub?: { author: string; stars: number; installs: number; reports: number; publishedAt: string };
|
|
98
|
+
provenance?: { kind: 'first-party' | 'third-party' | 'local'; provider?: string; originId?: string; sourceUrl?: string };
|
|
98
99
|
}>;
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -112,6 +112,16 @@ export interface ToolNamesListResponse {
|
|
|
112
112
|
schemas?: Record<string, ToolInputSchema>;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Server push — broadcast when IDENTITY.md changes on disk. */
|
|
116
|
+
export interface IdentityChanged {
|
|
117
|
+
type: 'identity_changed';
|
|
118
|
+
name: string;
|
|
119
|
+
role: string;
|
|
120
|
+
personality: string;
|
|
121
|
+
emoji: string;
|
|
122
|
+
home: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
115
125
|
// --- Domain-level union aliases (consumed by the barrel file) ---
|
|
116
126
|
|
|
117
127
|
export type _WorkspaceClientMessages =
|
|
@@ -126,4 +136,5 @@ export type _WorkspaceServerMessages =
|
|
|
126
136
|
| WorkspaceFileReadResponse
|
|
127
137
|
| IdentityGetResponse
|
|
128
138
|
| ToolPermissionSimulateResponse
|
|
129
|
-
| ToolNamesListResponse
|
|
139
|
+
| ToolNamesListResponse
|
|
140
|
+
| IdentityChanged;
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '../config/env.js';
|
|
18
18
|
import { loadConfig } from '../config/loader.js';
|
|
19
19
|
import { ensurePromptFiles } from '../config/system-prompt.js';
|
|
20
|
+
import { syncUpdateBulletinOnStartup } from '../config/update-bulletin.js';
|
|
20
21
|
import { HeartbeatService } from '../heartbeat/heartbeat-service.js';
|
|
21
22
|
import { getHookManager } from '../hooks/manager.js';
|
|
22
23
|
import { installTemplates } from '../hooks/templates.js';
|
|
@@ -63,6 +64,7 @@ import { installShutdownHandlers } from './shutdown-handlers.js';
|
|
|
63
64
|
// Re-export public API so existing consumers don't need to change imports
|
|
64
65
|
export type { StopResult } from './daemon-control.js';
|
|
65
66
|
export {
|
|
67
|
+
cleanupPidFile,
|
|
66
68
|
ensureDaemonRunning,
|
|
67
69
|
getDaemonStatus,
|
|
68
70
|
isDaemonRunning,
|
|
@@ -139,6 +141,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
139
141
|
initializeDb();
|
|
140
142
|
log.info('Daemon startup: DB initialized');
|
|
141
143
|
|
|
144
|
+
try {
|
|
145
|
+
syncUpdateBulletinOnStartup();
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log.warn({ err }, 'Bulletin sync failed — continuing startup');
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
// Recover orphaned work items that were left in 'running' state when the
|
|
143
151
|
// daemon previously crashed or was killed mid-task.
|
|
144
152
|
const orphanedRunning = listWorkItems({ status: 'running' });
|