@vellumai/assistant 0.3.6 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -47,7 +47,7 @@ export const DEFAULT_CONFIG: AssistantConfig = {
47
47
  injectionFormat: 'markdown' as const,
48
48
  injectionStrategy: 'prepend_user_block' as const,
49
49
  reranking: {
50
- enabled: true,
50
+ enabled: false,
51
51
  model: 'claude-haiku-4-5-20251001',
52
52
  topK: 20,
53
53
  },
@@ -136,11 +136,18 @@ const KNOWN_VELLUM_VARS = new Set([
136
136
  'VELLUM_DAEMON_TCP_ENABLED',
137
137
  'VELLUM_DAEMON_TCP_HOST',
138
138
  'VELLUM_DAEMON_IOS_PAIRING',
139
+ 'VELLUM_DAEMON_NOAUTH',
140
+ 'VELLUM_DAEMON_AUTOSTART',
139
141
  'VELLUM_DEBUG',
140
142
  'VELLUM_LOG_STDERR',
141
143
  'VELLUM_ENABLE_MONITORING',
142
144
  'VELLUM_HOOK_EVENT',
143
145
  'VELLUM_HOOK_NAME',
146
+ 'VELLUM_HOOK_SETTINGS',
147
+ 'VELLUM_ROOT_DIR',
148
+ 'VELLUM_WORKSPACE_DIR',
149
+ 'VELLUM_CLAUDE_CODE_DEPTH',
150
+ 'VELLUM_ASSISTANT_PLATFORM_URL',
144
151
  ]);
145
152
 
146
153
  /**
@@ -60,7 +60,7 @@ export const QdrantConfigSchema = z.object({
60
60
  export const MemoryRerankingConfigSchema = z.object({
61
61
  enabled: z
62
62
  .boolean({ error: 'memory.retrieval.reranking.enabled must be a boolean' })
63
- .default(true),
63
+ .default(false),
64
64
  model: z
65
65
  .string({ error: 'memory.retrieval.reranking.model must be a string' })
66
66
  .default('claude-haiku-4-5-20251001'),
@@ -186,7 +186,7 @@ export const MemoryRetrievalConfigSchema = z.object({
186
186
  })
187
187
  .default('prepend_user_block'),
188
188
  reranking: MemoryRerankingConfigSchema.default({
189
- enabled: true,
189
+ enabled: false,
190
190
  model: 'claude-haiku-4-5-20251001',
191
191
  topK: 20,
192
192
  }),
@@ -430,7 +430,7 @@ export const MemoryConfigSchema = z.object({
430
430
  injectionFormat: 'markdown',
431
431
  injectionStrategy: 'prepend_user_block',
432
432
  reranking: {
433
- enabled: true,
433
+ enabled: false,
434
434
  model: 'claude-haiku-4-5-20251001',
435
435
  topK: 20,
436
436
  },
@@ -215,7 +215,7 @@ export const AssistantConfigSchema = z.object({
215
215
  injectionFormat: 'markdown',
216
216
  injectionStrategy: 'prepend_user_block',
217
217
  reranking: {
218
- enabled: true,
218
+ enabled: false,
219
219
  model: 'claude-haiku-4-5-20251001',
220
220
  topK: 20,
221
221
  },
@@ -5,14 +5,51 @@ import {
5
5
  getSocketPath,
6
6
  getPidPath,
7
7
  getRootDir,
8
+ getWorkspaceConfigPath,
8
9
  removeSocketFile,
9
10
  } from '../util/platform.js';
10
11
  import { getLogger } from '../util/logger.js';
11
12
  import { DaemonError } from '../util/errors.js';
12
- import { getConfig } from '../config/loader.js';
13
13
 
14
14
  const log = getLogger('lifecycle');
15
15
 
16
+ const DAEMON_TIMEOUT_DEFAULTS = {
17
+ startupSocketWaitMs: 5000,
18
+ stopTimeoutMs: 5000,
19
+ sigkillGracePeriodMs: 2000,
20
+ };
21
+
22
+ /**
23
+ * Read daemon timeout values directly from the config JSON file, bypassing
24
+ * loadConfig() and its ensureMigratedDataDir()/ensureDataDir() side effects.
25
+ * Falls back to hardcoded defaults on any error (missing file, malformed JSON,
26
+ * unexpected shape) so daemon stop/start never fails due to config issues.
27
+ */
28
+ function readDaemonTimeouts(): typeof DAEMON_TIMEOUT_DEFAULTS {
29
+ try {
30
+ const raw = JSON.parse(readFileSync(getWorkspaceConfigPath(), 'utf-8'));
31
+ if (raw.daemon && typeof raw.daemon === 'object') {
32
+ return {
33
+ startupSocketWaitMs:
34
+ typeof raw.daemon.startupSocketWaitMs === 'number'
35
+ ? raw.daemon.startupSocketWaitMs
36
+ : DAEMON_TIMEOUT_DEFAULTS.startupSocketWaitMs,
37
+ stopTimeoutMs:
38
+ typeof raw.daemon.stopTimeoutMs === 'number'
39
+ ? raw.daemon.stopTimeoutMs
40
+ : DAEMON_TIMEOUT_DEFAULTS.stopTimeoutMs,
41
+ sigkillGracePeriodMs:
42
+ typeof raw.daemon.sigkillGracePeriodMs === 'number'
43
+ ? raw.daemon.sigkillGracePeriodMs
44
+ : DAEMON_TIMEOUT_DEFAULTS.sigkillGracePeriodMs,
45
+ };
46
+ }
47
+ } catch {
48
+ // Missing file, malformed JSON, etc. — use defaults.
49
+ }
50
+ return { ...DAEMON_TIMEOUT_DEFAULTS };
51
+ }
52
+
16
53
  function isProcessRunning(pid: number): boolean {
17
54
  try {
18
55
  process.kill(pid, 0);
@@ -125,8 +162,8 @@ export async function startDaemon(): Promise<{
125
162
  writePid(pid);
126
163
 
127
164
  // Wait for socket to appear
128
- const config = getConfig();
129
- const maxWait = config.daemon.startupSocketWaitMs;
165
+ const timeouts = readDaemonTimeouts();
166
+ const maxWait = timeouts.startupSocketWaitMs;
130
167
  const interval = 100;
131
168
  let waited = 0;
132
169
  while (waited < maxWait) {
@@ -165,10 +202,10 @@ export async function stopDaemon(): Promise<StopResult> {
165
202
 
166
203
  process.kill(pid, 'SIGTERM');
167
204
 
168
- const config = getConfig();
205
+ const timeouts = readDaemonTimeouts();
169
206
 
170
207
  // Wait for process to exit
171
- const maxWait = config.daemon.stopTimeoutMs;
208
+ const maxWait = timeouts.stopTimeoutMs;
172
209
  const interval = 100;
173
210
  let waited = 0;
174
211
  while (waited < maxWait) {
@@ -190,7 +227,7 @@ export async function stopDaemon(): Promise<StopResult> {
190
227
  // Wait for the process to actually die after SIGKILL. Without this,
191
228
  // startDaemon() can race with the dying process's shutdown handler,
192
229
  // which removes the socket file and bricks the new daemon.
193
- const killMaxWait = config.daemon.sigkillGracePeriodMs;
230
+ const killMaxWait = timeouts.sigkillGracePeriodMs;
194
231
  let killWaited = 0;
195
232
  while (killWaited < killMaxWait && isProcessRunning(pid)) {
196
233
  await new Promise((r) => setTimeout(r, 100));
@@ -16,6 +16,7 @@ import type {
16
16
  SecretResponse,
17
17
  SessionCreateRequest,
18
18
  SessionSwitchRequest,
19
+ SessionRenameRequest,
19
20
  CancelRequest,
20
21
  DeleteQueuedMessage,
21
22
  HistoryRequest,
@@ -352,6 +353,24 @@ export async function handleSessionSwitch(
352
353
  });
353
354
  }
354
355
 
356
+ export function handleSessionRename(
357
+ msg: SessionRenameRequest,
358
+ socket: net.Socket,
359
+ ctx: HandlerContext,
360
+ ): void {
361
+ const conversation = conversationStore.getConversation(msg.sessionId);
362
+ if (!conversation) {
363
+ ctx.send(socket, { type: 'error', message: `Session ${msg.sessionId} not found` });
364
+ return;
365
+ }
366
+ conversationStore.updateConversationTitle(msg.sessionId, msg.title);
367
+ ctx.send(socket, {
368
+ type: 'session_title_updated',
369
+ sessionId: msg.sessionId,
370
+ title: msg.title,
371
+ });
372
+ }
373
+
355
374
  export function handleCancel(msg: CancelRequest, socket: net.Socket, ctx: HandlerContext): void {
356
375
  const sessionId = msg.sessionId || ctx.socketToSession.get(socket);
357
376
  if (sessionId) {
@@ -597,6 +616,7 @@ export const sessionHandlers = defineHandlers({
597
616
  session_create: handleSessionCreate,
598
617
  sessions_clear: (_msg, socket, ctx) => handleSessionsClear(socket, ctx),
599
618
  session_switch: handleSessionSwitch,
619
+ session_rename: handleSessionRename,
600
620
  cancel: handleCancel,
601
621
  delete_queued_message: handleDeleteQueuedMessage,
602
622
  history_request: handleHistoryRequest,
@@ -43,6 +43,12 @@ export interface SessionSwitchRequest {
43
43
  sessionId: string;
44
44
  }
45
45
 
46
+ export interface SessionRenameRequest {
47
+ type: 'session_rename';
48
+ sessionId: string;
49
+ title: string;
50
+ }
51
+
46
52
  export interface AuthMessage {
47
53
  type: 'auth';
48
54
  token: string;
@@ -79,6 +79,7 @@
79
79
  "SecretResponse",
80
80
  "SessionCreateRequest",
81
81
  "SessionListRequest",
82
+ "SessionRenameRequest",
82
83
  "SessionSwitchRequest",
83
84
  "SessionsClearRequest",
84
85
  "ShareAppCloudRequest",
@@ -363,6 +364,7 @@
363
364
  "secret_response",
364
365
  "session_create",
365
366
  "session_list",
367
+ "session_rename",
366
368
  "session_switch",
367
369
  "sessions_clear",
368
370
  "share_app_cloud",
@@ -29,7 +29,7 @@ export * from './ipc-contract/inbox.js';
29
29
  export * from './ipc-contract/pairing.js';
30
30
 
31
31
  // Import types needed for aggregate unions and SubagentEvent
32
- import type { AuthMessage, PingMessage, CancelRequest, DeleteQueuedMessage, ModelGetRequest, ModelSetRequest, ImageGenModelSetRequest, HistoryRequest, UndoRequest, RegenerateRequest, UsageRequest, SandboxSetRequest, SessionListRequest, SessionCreateRequest, SessionSwitchRequest, SessionsClearRequest, ConversationSearchRequest } from './ipc-contract/sessions.js';
32
+ import type { AuthMessage, PingMessage, CancelRequest, DeleteQueuedMessage, ModelGetRequest, ModelSetRequest, ImageGenModelSetRequest, HistoryRequest, UndoRequest, RegenerateRequest, UsageRequest, SandboxSetRequest, SessionListRequest, SessionCreateRequest, SessionSwitchRequest, SessionRenameRequest, SessionsClearRequest, ConversationSearchRequest } from './ipc-contract/sessions.js';
33
33
  import type { UserMessage, ConfirmationResponse, SecretResponse, SuggestionRequest } from './ipc-contract/messages.js';
34
34
  import type { UiSurfaceAction, UiSurfaceUndoRequest } from './ipc-contract/surfaces.js';
35
35
  import type { SkillsListRequest, SkillDetailRequest, SkillsEnableRequest, SkillsDisableRequest, SkillsConfigureRequest, SkillsInstallRequest, SkillsUninstallRequest, SkillsUpdateRequest, SkillsCheckUpdatesRequest, SkillsSearchRequest, SkillsInspectRequest } from './ipc-contract/skills.js';
@@ -84,6 +84,7 @@ export type ClientMessage =
84
84
  | SessionListRequest
85
85
  | SessionCreateRequest
86
86
  | SessionSwitchRequest
87
+ | SessionRenameRequest
87
88
  | PingMessage
88
89
  | CancelRequest
89
90
  | DeleteQueuedMessage
@@ -78,11 +78,6 @@ export class MessageQueue {
78
78
 
79
79
  if (this.items.length >= MAX_QUEUE_DEPTH) {
80
80
  this.droppedCount++;
81
- item.onEvent({
82
- type: 'error',
83
- message: 'Message queue is full. Please wait for current messages to be processed.',
84
- category: 'queue_full',
85
- });
86
81
  return false;
87
82
  }
88
83
 
@@ -149,21 +144,28 @@ export class MessageQueue {
149
144
  private expireStale(): void {
150
145
  const now = Date.now();
151
146
  const cutoff = now - this.maxWaitMs;
152
- const before = this.items.length;
147
+ const expired: QueuedMessage[] = [];
153
148
  this.items = this.items.filter((item) => {
154
149
  if (item.queuedAt < cutoff) {
155
150
  this.expiredCount++;
156
- log.warn({ requestId: item.requestId, waitMs: now - item.queuedAt }, 'Expiring stale queued message');
151
+ expired.push(item);
152
+ return false;
153
+ }
154
+ return true;
155
+ });
156
+ for (const item of expired) {
157
+ log.warn({ requestId: item.requestId, waitMs: now - item.queuedAt }, 'Expiring stale queued message');
158
+ try {
157
159
  item.onEvent({
158
160
  type: 'error',
159
161
  message: 'Your queued message was dropped because it waited too long in the queue.',
160
162
  category: 'queue_expired',
161
163
  });
162
- return false;
164
+ } catch (e) {
165
+ log.debug({ err: e, requestId: item.requestId }, 'Failed to notify client of expired message');
163
166
  }
164
- return true;
165
- });
166
- if (this.items.length < before && this.items.length / MAX_QUEUE_DEPTH < CAPACITY_WARNING_THRESHOLD) {
167
+ }
168
+ if (expired.length > 0 && this.items.length / MAX_QUEUE_DEPTH < CAPACITY_WARNING_THRESHOLD) {
167
169
  this.capacityWarned = false;
168
170
  }
169
171
  }
@@ -140,7 +140,14 @@ export function createSurfaceMutex(): <T>(surfaceId: string, fn: () => T | Promi
140
140
  const prev = chains.get(surfaceId) ?? Promise.resolve();
141
141
  const next = prev.then(fn, fn);
142
142
  // Keep the chain alive but swallow errors so one failure doesn't block subsequent ops
143
- chains.set(surfaceId, next.then(() => {}, () => {}));
143
+ const tail = next.then(() => {}, () => {});
144
+ chains.set(surfaceId, tail);
145
+ // Clean up the map entry once the queue settles to prevent unbounded growth
146
+ tail.then(() => {
147
+ if (chains.get(surfaceId) === tail) {
148
+ chains.delete(surfaceId);
149
+ }
150
+ });
144
151
  return next;
145
152
  };
146
153
  }
@@ -1,11 +1,11 @@
1
1
  import type { DrizzleDb } from '../db-connection.js';
2
2
 
3
3
  /**
4
- * Idempotent migration to ensure memory_segments has indexes on scope_id and
5
- * conversation_id for faster lookups. scope_id was already covered by
6
- * db-init, but we include both here for completeness.
4
+ * Idempotent migration to add a scope_id index on memory_segments.
5
+ * conversation_id is already covered by the composite index
6
+ * idx_memory_segments_conversation_created(conversation_id, created_at DESC)
7
+ * in db-init, so a standalone index is unnecessary.
7
8
  */
8
9
  export function migrateMemorySegmentsIndexes(database: DrizzleDb): void {
9
10
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_segments_scope_id ON memory_segments(scope_id)`);
10
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_segments_conversation_id ON memory_segments(conversation_id)`);
11
11
  }
@@ -1,10 +1,12 @@
1
1
  import type { DrizzleDb } from '../db-connection.js';
2
2
 
3
3
  /**
4
- * Idempotent migration to add indexes on memory_items for scope_id and
5
- * fingerprint critical for duplicate detection and scope-filtered queries.
4
+ * Idempotent migration to add an index on memory_items.scope_id for
5
+ * scope-filtered queries. The standalone fingerprint index is intentionally
6
+ * omitted — it's superseded by the compound unique index
7
+ * idx_memory_items_fingerprint_scope(fingerprint, scope_id), which already
8
+ * covers single-column lookups on fingerprint as its leading column.
6
9
  */
7
10
  export function migrateMemoryItemsIndexes(database: DrizzleDb): void {
8
11
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_items_scope_id ON memory_items(scope_id)`);
9
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_items_fingerprint ON memory_items(fingerprint)`);
10
12
  }
@@ -779,7 +779,10 @@ function isAbortError(err: unknown): boolean {
779
779
  * in the message. This avoids false positives from dimension numbers like 512.
780
780
  */
781
781
  function getErrorStatusCode(err: Error): unknown {
782
- if ('status' in err) return (err as { status: unknown }).status;
782
+ if ('status' in err) {
783
+ const status = (err as { status: unknown }).status;
784
+ if (status != null) return status;
785
+ }
783
786
  if ('statusCode' in err) return (err as { statusCode: unknown }).statusCode;
784
787
  return undefined;
785
788
  }
@@ -16,16 +16,23 @@ import type { ManifestOverride } from '../tools/execution-target.js';
16
16
 
17
17
  // ── Risk classification cache ────────────────────────────────────────────────
18
18
  // classifyRisk() is called on every permission check and can invoke WASM
19
- // parsing for shell commands. Cache results keyed on (toolName, inputHash).
19
+ // parsing for shell commands. Cache results keyed on
20
+ // (toolName, inputHash, workingDir, manifestOverride).
20
21
  // Invalidated when trust rules change since risk classification for file tools
21
22
  // depends on skill source path checks which reference config, but the core
22
23
  // risk logic is input-deterministic.
23
24
  const RISK_CACHE_MAX = 256;
24
25
  const riskCache = new Map<string, RiskLevel>();
25
26
 
26
- function riskCacheKey(toolName: string, input: Record<string, unknown>): string {
27
+ function riskCacheKey(toolName: string, input: Record<string, unknown>, workingDir?: string, manifestOverride?: ManifestOverride): string {
27
28
  const inputJson = JSON.stringify(input);
28
- const hash = createHash('sha256').update(inputJson).digest('hex');
29
+ const hash = createHash('sha256')
30
+ .update(inputJson)
31
+ .update('\0')
32
+ .update(workingDir ?? '')
33
+ .update('\0')
34
+ .update(manifestOverride ? JSON.stringify(manifestOverride) : '')
35
+ .digest('hex');
29
36
  return `${toolName}\0${hash}`;
30
37
  }
31
38
 
@@ -305,11 +312,11 @@ async function buildCommandCandidates(toolName: string, input: Record<string, un
305
312
  }
306
313
 
307
314
  export async function classifyRisk(toolName: string, input: Record<string, unknown>, workingDir?: string, preParsed?: ParsedCommand, manifestOverride?: ManifestOverride, signal?: AbortSignal): Promise<RiskLevel> {
308
- if (signal?.aborted) throw new Error('Cancelled');
315
+ signal?.throwIfAborted();
309
316
 
310
317
  // Check cache first (skip when preParsed is provided since caller already
311
318
  // parsed and we'd just be duplicating the key computation cost).
312
- const cacheKey = preParsed ? null : riskCacheKey(toolName, input);
319
+ const cacheKey = preParsed ? null : riskCacheKey(toolName, input, workingDir, manifestOverride);
313
320
  if (cacheKey) {
314
321
  const cached = riskCache.get(cacheKey);
315
322
  if (cached !== undefined) {
@@ -466,7 +473,7 @@ export async function check(
466
473
  manifestOverride?: ManifestOverride,
467
474
  signal?: AbortSignal,
468
475
  ): Promise<PermissionCheckResult> {
469
- if (signal?.aborted) throw new Error('Cancelled');
476
+ signal?.throwIfAborted();
470
477
 
471
478
  // For shell tools, parse once and share the result to avoid duplicate tree-sitter work.
472
479
  let shellParsed: ParsedCommand | undefined;
@@ -608,7 +615,7 @@ function friendlyHostname(url: URL): string {
608
615
  }
609
616
 
610
617
  export async function generateAllowlistOptions(toolName: string, input: Record<string, unknown>, signal?: AbortSignal): Promise<AllowlistOption[]> {
611
- if (signal?.aborted) throw new Error('Cancelled');
618
+ signal?.throwIfAborted();
612
619
  if (toolName === 'bash' || toolName === 'host_bash') {
613
620
  const command = ((input.command as string) ?? '').trim();
614
621
  return buildShellAllowlistOptions(command);
@@ -123,7 +123,9 @@ export class AssistantEventHub {
123
123
  for (const entry of snapshot) {
124
124
  if (!entry.active) continue;
125
125
  if (entry.filter.assistantId !== event.assistantId) continue;
126
- if (entry.filter.sessionId != null && entry.filter.sessionId !== event.sessionId) continue;
126
+ // System events (no sessionId) match all subscribers; scoped events
127
+ // must match the subscriber's sessionId filter when present.
128
+ if (event.sessionId != null && entry.filter.sessionId != null && entry.filter.sessionId !== event.sessionId) continue;
127
129
  try {
128
130
  await entry.callback(event);
129
131
  } catch (err) {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Optional HTTP server that exposes the canonical runtime API.
3
3
  *
4
- * Runs in the same process as the daemon. Started only when
5
- * `RUNTIME_HTTP_PORT` is set (default: disabled).
4
+ * Runs in the same process as the daemon. Always started on the
5
+ * configured port (default: 7821).
6
6
  */
7
7
 
8
8
  import { existsSync, readFileSync } from 'node:fs';
@@ -77,6 +77,9 @@ import type { BrowserRelayWebSocketData } from '../browser-extension-relay/serve
77
77
  import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
78
78
  import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
79
79
  import { PairingStore } from '../daemon/pairing-store.js';
80
+ import type { ServerMessage } from '../daemon/ipc-contract.js';
81
+ import { assistantEventHub } from './assistant-event-hub.js';
82
+ import { buildAssistantEvent } from './assistant-event.js';
80
83
 
81
84
  // Middleware
82
85
  import {
@@ -152,7 +155,7 @@ export class RuntimeHttpServer {
152
155
  private retrySweepTimer: ReturnType<typeof setInterval> | null = null;
153
156
  private sweepInProgress = false;
154
157
  private pairingStore = new PairingStore();
155
- private pairingBroadcast?: (msg: { type: string; [key: string]: unknown }) => void;
158
+ private pairingBroadcast?: (msg: ServerMessage) => void;
156
159
 
157
160
  constructor(options: RuntimeHttpServerOptions = {}) {
158
161
  this.port = options.port ?? DEFAULT_PORT;
@@ -177,15 +180,24 @@ export class RuntimeHttpServer {
177
180
  }
178
181
 
179
182
  /** Set a callback for broadcasting IPC messages (wired by daemon server). */
180
- setPairingBroadcast(fn: (msg: { type: string; [key: string]: unknown }) => void): void {
183
+ setPairingBroadcast(fn: (msg: ServerMessage) => void): void {
181
184
  this.pairingBroadcast = fn;
182
185
  }
183
186
 
184
187
  private get pairingContext(): PairingHandlerContext {
188
+ const ipcBroadcast = this.pairingBroadcast;
185
189
  return {
186
190
  pairingStore: this.pairingStore,
187
191
  bearerToken: this.bearerToken,
188
- pairingBroadcast: this.pairingBroadcast,
192
+ pairingBroadcast: ipcBroadcast
193
+ ? (msg) => {
194
+ // Broadcast to IPC socket clients (local Unix socket)
195
+ ipcBroadcast(msg);
196
+ // Also publish to the event hub so HTTP/SSE clients (e.g. macOS
197
+ // app with localHttpEnabled) receive pairing approval requests.
198
+ void assistantEventHub.publish(buildAssistantEvent('self', msg));
199
+ }
200
+ : undefined,
189
201
  };
190
202
  }
191
203
 
@@ -9,13 +9,14 @@ import {
9
9
  refreshDevice,
10
10
  hashDeviceId,
11
11
  } from '../../daemon/approved-devices-store.js';
12
+ import type { ServerMessage } from '../../daemon/ipc-contract.js';
12
13
 
13
14
  const log = getLogger('runtime-http');
14
15
 
15
16
  export interface PairingHandlerContext {
16
17
  pairingStore: PairingStore;
17
18
  bearerToken: string | undefined;
18
- pairingBroadcast?: (msg: { type: string; [key: string]: unknown }) => void;
19
+ pairingBroadcast?: (msg: ServerMessage) => void;
19
20
  }
20
21
 
21
22
  /**
@@ -320,7 +320,10 @@ export async function executeBrowserNavigate(
320
320
  if (challenge?.type === 'captcha') {
321
321
  log.info('CAPTCHA detected, waiting up to 5s for auto-resolve');
322
322
  for (let i = 0; i < 5; i++) {
323
- if (context.signal?.aborted) break;
323
+ if (context.signal?.aborted) {
324
+ if (sender) updateBrowserStatus(context.sessionId, sender, 'idle');
325
+ return { content: 'Navigation cancelled.', isError: true };
326
+ }
324
327
  await new Promise((r) => setTimeout(r, 1000));
325
328
  const still = await detectCaptchaChallenge(page);
326
329
  if (!still) {
@@ -699,15 +699,18 @@ export class ToolExecutor {
699
699
  } catch (err) {
700
700
  const durationMs = Date.now() - startTime;
701
701
  const msg = err instanceof Error ? err.message : String(err);
702
- const isExpected = err instanceof PermissionDeniedError || err instanceof ToolError || err instanceof TokenExpiredError;
703
-
704
- const errorCategory = err instanceof PermissionDeniedError
705
- ? 'permission_denied' as const
706
- : err instanceof TokenExpiredError
707
- ? 'auth' as const
708
- : err instanceof ToolError
709
- ? 'tool_failure' as const
710
- : 'unexpected' as const;
702
+ const isAbort = err instanceof Error && err.name === 'AbortError';
703
+ const isExpected = isAbort || err instanceof PermissionDeniedError || err instanceof ToolError || err instanceof TokenExpiredError;
704
+
705
+ const errorCategory = isAbort
706
+ ? 'tool_failure' as const
707
+ : err instanceof PermissionDeniedError
708
+ ? 'permission_denied' as const
709
+ : err instanceof TokenExpiredError
710
+ ? 'auth' as const
711
+ : err instanceof ToolError
712
+ ? 'tool_failure' as const
713
+ : 'unexpected' as const;
711
714
 
712
715
  emitLifecycleEvent(context, {
713
716
  type: 'error',
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { Writable } from 'node:stream';
4
4
  import pino from 'pino';
@@ -67,6 +67,8 @@ function buildRotatingLogger(config: LogFileConfig): pino.Logger {
67
67
  const today = formatDate(new Date());
68
68
  const filePath = logFilePathForDate(config.dir, new Date());
69
69
  const fileStream = pino.destination({ dest: filePath, sync: false, mkdir: true, mode: 0o600 });
70
+ // Tighten permissions on pre-existing log files that may have been created with looser modes
71
+ try { chmodSync(filePath, 0o600); } catch { /* best-effort */ }
70
72
 
71
73
  activeLogDate = today;
72
74
  activeLogFileConfig = config;
@@ -130,7 +132,10 @@ function getRootLogger(): pino.Logger {
130
132
  }
131
133
 
132
134
  try {
133
- const fileStream = pino.destination({ dest: getLogPath(), sync: false, mkdir: true, mode: 0o600 });
135
+ const logPath = getLogPath();
136
+ const fileStream = pino.destination({ dest: logPath, sync: false, mkdir: true, mode: 0o600 });
137
+ // Tighten permissions on pre-existing log files that may have been created with looser modes
138
+ try { chmodSync(logPath, 0o600); } catch { /* best-effort */ }
134
139
 
135
140
  if (getDebugMode()) {
136
141
  const prettyStream = pinoPretty({ destination: 2 });