@vellumai/assistant 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/send-endpoint-busy.test.ts +284 -0
  3. package/src/__tests__/subagent-manager-notify.test.ts +3 -3
  4. package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
  5. package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
  6. package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
  7. package/src/config/defaults.ts +1 -1
  8. package/src/config/env-registry.ts +7 -0
  9. package/src/config/memory-schema.ts +3 -3
  10. package/src/config/schema.ts +1 -1
  11. package/src/daemon/daemon-control.ts +44 -6
  12. package/src/daemon/handlers/sessions.ts +20 -0
  13. package/src/daemon/handlers/subagents.ts +10 -3
  14. package/src/daemon/ipc-contract/sessions.ts +6 -0
  15. package/src/daemon/ipc-contract-inventory.json +2 -0
  16. package/src/daemon/ipc-contract.ts +2 -1
  17. package/src/daemon/lifecycle.ts +16 -0
  18. package/src/daemon/server.ts +8 -0
  19. package/src/daemon/session-queue-manager.ts +13 -11
  20. package/src/daemon/session-surfaces.ts +8 -1
  21. package/src/memory/migrations/016-memory-segments-indexes.ts +5 -4
  22. package/src/memory/migrations/017-memory-items-indexes.ts +5 -3
  23. package/src/memory/retriever.ts +4 -1
  24. package/src/memory/schema.ts +0 -1
  25. package/src/permissions/checker.ts +14 -7
  26. package/src/runtime/assistant-event-hub.ts +3 -1
  27. package/src/runtime/http-server.ts +22 -5
  28. package/src/runtime/http-types.ts +22 -0
  29. package/src/runtime/routes/conversation-routes.ts +77 -1
  30. package/src/runtime/routes/pairing-routes.ts +2 -1
  31. package/src/subagent/manager.ts +6 -6
  32. package/src/tools/browser/browser-execution.ts +4 -1
  33. package/src/tools/executor.ts +12 -9
  34. package/src/tools/subagent/message.ts +9 -2
  35. package/src/util/logger.ts +7 -2
@@ -109,10 +109,17 @@ export function handleSubagentMessage(
109
109
  return;
110
110
  }
111
111
 
112
- const sent = manager.sendMessage(msg.subagentId, msg.content);
112
+ const result = manager.sendMessage(msg.subagentId, msg.content);
113
113
 
114
- if (!sent) {
115
- log.warn({ subagentId: msg.subagentId }, 'Client sent message to terminal subagent');
114
+ if (result === 'queue_full') {
115
+ log.warn({ subagentId: msg.subagentId }, 'Subagent message rejected queue full');
116
+ ctx.send(socket, {
117
+ type: 'error',
118
+ message: `Subagent "${msg.subagentId}" message queue is full. Please wait for current messages to be processed.`,
119
+ category: 'queue_full',
120
+ });
121
+ } else if (result !== 'sent') {
122
+ log.warn({ subagentId: msg.subagentId, reason: result }, 'Client sent message to terminal subagent');
116
123
  ctx.send(socket, {
117
124
  type: 'error',
118
125
  message: `Subagent "${msg.subagentId}" not found or in terminal state.`,
@@ -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
@@ -35,6 +35,8 @@ import { QdrantManager } from '../memory/qdrant-manager.js';
35
35
  import { initQdrantClient } from '../memory/qdrant-client.js';
36
36
  import { startScheduler } from '../schedule/scheduler.js';
37
37
  import { RuntimeHttpServer } from '../runtime/http-server.js';
38
+ import { assistantEventHub } from '../runtime/assistant-event-hub.js';
39
+ import * as attachmentsStore from '../memory/attachments-store.js';
38
40
  import { getHookManager } from '../hooks/manager.js';
39
41
  import { installTemplates } from '../hooks/templates.js';
40
42
  import { installCliLaunchers } from './install-cli-launchers.js';
@@ -46,6 +48,7 @@ import { createApprovalCopyGenerator, createApprovalConversationGenerator } from
46
48
  import { initializeProvidersAndTools, registerWatcherProviders, registerMessagingProviders } from './providers-setup.js';
47
49
  import { installShutdownHandlers } from './shutdown-handlers.js';
48
50
  import { writePid, cleanupPidFile } from './daemon-control.js';
51
+ import { initPairingHandlers } from './handlers/pairing.js';
49
52
 
50
53
  // Re-export public API so existing consumers don't need to change imports
51
54
  export {
@@ -259,11 +262,24 @@ export async function runDaemon(): Promise<void> {
259
262
  interfacesDir: getInterfacesDir(),
260
263
  approvalCopyGenerator: createApprovalCopyGenerator(),
261
264
  approvalConversationGenerator: createApprovalConversationGenerator(),
265
+ sendMessageDeps: {
266
+ getOrCreateSession: (conversationId) =>
267
+ server.getSessionForMessages(conversationId),
268
+ assistantEventHub,
269
+ resolveAttachments: (attachmentIds) =>
270
+ attachmentsStore.getAttachmentsByIds(attachmentIds).map((a) => ({
271
+ id: a.id,
272
+ filename: a.originalFilename,
273
+ mimeType: a.mimeType,
274
+ data: a.dataBase64,
275
+ })),
276
+ },
262
277
  });
263
278
  try {
264
279
  await runtimeHttp.start();
265
280
  setRelayBroadcast((msg) => server.broadcast(msg));
266
281
  runtimeHttp.setPairingBroadcast((msg) => server.broadcast(msg));
282
+ initPairingHandlers(runtimeHttp.getPairingStore(), bearerToken);
267
283
  server.setHttpPort(httpPort);
268
284
  log.info({ port: httpPort, hostname }, 'Daemon startup: runtime HTTP server listening');
269
285
  } catch (err) {
@@ -819,6 +819,14 @@ export class DaemonServer {
819
819
  return { messageId };
820
820
  }
821
821
 
822
+ /**
823
+ * Expose session lookup for the POST /v1/messages handler.
824
+ * The handler manages busy-state checking and queueing itself.
825
+ */
826
+ async getSessionForMessages(conversationId: string): Promise<Session> {
827
+ return this.getOrCreateSession(conversationId, undefined, true);
828
+ }
829
+
822
830
  createRunOrchestrator(): RunOrchestrator {
823
831
  return new RunOrchestrator({
824
832
  getOrCreateSession: (conversationId, transport) =>
@@ -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,12 @@
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
+ database.run(/*sql*/ `DROP INDEX IF EXISTS idx_memory_segments_conversation_id`);
11
12
  }
@@ -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
  }
@@ -64,7 +64,6 @@ export const memorySegments = sqliteTable('memory_segments', {
64
64
  updatedAt: integer('updated_at').notNull(),
65
65
  }, (table) => [
66
66
  index('idx_memory_segments_scope_id').on(table.scopeId),
67
- index('idx_memory_segments_conversation_id').on(table.conversationId),
68
67
  ]);
69
68
 
70
69
  export const memoryItems = sqliteTable('memory_items', {
@@ -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 {
@@ -118,6 +121,7 @@ export type {
118
121
  RuntimeAttachmentMetadata,
119
122
  ApprovalCopyGenerator,
120
123
  ApprovalConversationGenerator,
124
+ SendMessageDeps,
121
125
  } from './http-types.js';
122
126
 
123
127
  import type {
@@ -126,6 +130,7 @@ import type {
126
130
  RuntimeHttpServerOptions,
127
131
  ApprovalCopyGenerator,
128
132
  ApprovalConversationGenerator,
133
+ SendMessageDeps,
129
134
  } from './http-types.js';
130
135
 
131
136
  const log = getLogger('runtime-http');
@@ -152,7 +157,8 @@ export class RuntimeHttpServer {
152
157
  private retrySweepTimer: ReturnType<typeof setInterval> | null = null;
153
158
  private sweepInProgress = false;
154
159
  private pairingStore = new PairingStore();
155
- private pairingBroadcast?: (msg: { type: string; [key: string]: unknown }) => void;
160
+ private pairingBroadcast?: (msg: ServerMessage) => void;
161
+ private sendMessageDeps?: SendMessageDeps;
156
162
 
157
163
  constructor(options: RuntimeHttpServerOptions = {}) {
158
164
  this.port = options.port ?? DEFAULT_PORT;
@@ -164,6 +170,7 @@ export class RuntimeHttpServer {
164
170
  this.approvalCopyGenerator = options.approvalCopyGenerator;
165
171
  this.approvalConversationGenerator = options.approvalConversationGenerator;
166
172
  this.interfacesDir = options.interfacesDir ?? null;
173
+ this.sendMessageDeps = options.sendMessageDeps;
167
174
  }
168
175
 
169
176
  /** The port the server is actually listening on (resolved after start). */
@@ -177,15 +184,24 @@ export class RuntimeHttpServer {
177
184
  }
178
185
 
179
186
  /** Set a callback for broadcasting IPC messages (wired by daemon server). */
180
- setPairingBroadcast(fn: (msg: { type: string; [key: string]: unknown }) => void): void {
187
+ setPairingBroadcast(fn: (msg: ServerMessage) => void): void {
181
188
  this.pairingBroadcast = fn;
182
189
  }
183
190
 
184
191
  private get pairingContext(): PairingHandlerContext {
192
+ const ipcBroadcast = this.pairingBroadcast;
185
193
  return {
186
194
  pairingStore: this.pairingStore,
187
195
  bearerToken: this.bearerToken,
188
- pairingBroadcast: this.pairingBroadcast,
196
+ pairingBroadcast: ipcBroadcast
197
+ ? (msg) => {
198
+ // Broadcast to IPC socket clients (local Unix socket)
199
+ ipcBroadcast(msg);
200
+ // Also publish to the event hub so HTTP/SSE clients (e.g. macOS
201
+ // app with localHttpEnabled) receive pairing approval requests.
202
+ void assistantEventHub.publish(buildAssistantEvent('self', msg));
203
+ }
204
+ : undefined,
189
205
  };
190
206
  }
191
207
 
@@ -546,6 +562,7 @@ export class RuntimeHttpServer {
546
562
  return await handleSendMessage(req, {
547
563
  processMessage: this.processMessage,
548
564
  persistAndProcessMessage: this.persistAndProcessMessage,
565
+ sendMessageDeps: this.sendMessageDeps,
549
566
  });
550
567
  }
551
568
 
@@ -5,6 +5,8 @@ import type { ChannelId } from '../channels/types.js';
5
5
  import type { RunOrchestrator } from './run-orchestrator.js';
6
6
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
7
7
  import type { ApprovalMessageContext, ComposeApprovalMessageGenerativeOptions } from './approval-message-composer.js';
8
+ import type { Session } from '../daemon/session.js';
9
+ import type { AssistantEventHub } from './assistant-event-hub.js';
8
10
 
9
11
  /**
10
12
  * Daemon-injected function that generates approval copy using a provider.
@@ -84,6 +86,24 @@ export type NonBlockingMessageProcessor = (
84
86
  sourceChannel?: ChannelId,
85
87
  ) => Promise<{ messageId: string }>;
86
88
 
89
+ /**
90
+ * Dependencies for the POST /v1/messages handler.
91
+ *
92
+ * The handler needs direct access to the session so it can check busy state,
93
+ * persist user messages, fire the agent loop, or queue messages when busy.
94
+ * Hub publishing wires outbound events to the SSE stream.
95
+ */
96
+ export interface SendMessageDeps {
97
+ getOrCreateSession: (conversationId: string) => Promise<Session>;
98
+ assistantEventHub: AssistantEventHub;
99
+ resolveAttachments: (attachmentIds: string[]) => Array<{
100
+ id: string;
101
+ filename: string;
102
+ mimeType: string;
103
+ data: string;
104
+ }>;
105
+ }
106
+
87
107
  export interface RuntimeHttpServerOptions {
88
108
  port?: number;
89
109
  /** Hostname / IP to bind to. Defaults to '127.0.0.1' (loopback-only). */
@@ -101,6 +121,8 @@ export interface RuntimeHttpServerOptions {
101
121
  approvalCopyGenerator?: ApprovalCopyGenerator;
102
122
  /** Daemon-injected generator for conversational approval flow (provider-backed). */
103
123
  approvalConversationGenerator?: ApprovalConversationGenerator;
124
+ /** Dependencies for the POST /v1/messages queue-if-busy handler. */
125
+ sendMessageDeps?: SendMessageDeps;
104
126
  }
105
127
 
106
128
  export interface RuntimeAttachmentMetadata {
@@ -18,7 +18,13 @@ import type {
18
18
  NonBlockingMessageProcessor,
19
19
  RuntimeAttachmentMetadata,
20
20
  RuntimeMessagePayload,
21
+ SendMessageDeps,
21
22
  } from '../http-types.js';
23
+ import type { ServerMessage } from '../../daemon/ipc-protocol.js';
24
+ import { buildAssistantEvent } from '../assistant-event.js';
25
+ import { getLogger } from '../../util/logger.js';
26
+
27
+ const log = getLogger('conversation-routes');
22
28
 
23
29
  const SUGGESTION_CACHE_MAX = 100;
24
30
 
@@ -134,11 +140,40 @@ export function handleListMessages(
134
140
  return Response.json({ messages });
135
141
  }
136
142
 
143
+ /**
144
+ * Build an `onEvent` callback that publishes every outbound event to the
145
+ * assistant event hub, maintaining ordered delivery through a serial chain.
146
+ */
147
+ function makeHubPublisher(
148
+ deps: SendMessageDeps,
149
+ conversationId: string,
150
+ ): (msg: ServerMessage) => void {
151
+ let hubChain: Promise<void> = Promise.resolve();
152
+ return (msg: ServerMessage) => {
153
+ const msgRecord = msg as unknown as Record<string, unknown>;
154
+ const msgSessionId =
155
+ 'sessionId' in msg && typeof msgRecord.sessionId === 'string'
156
+ ? (msgRecord.sessionId as string)
157
+ : undefined;
158
+ const resolvedSessionId = msgSessionId ?? conversationId;
159
+ const event = buildAssistantEvent('self', msg, resolvedSessionId);
160
+ hubChain = (async () => {
161
+ await hubChain;
162
+ try {
163
+ await deps.assistantEventHub.publish(event);
164
+ } catch (err) {
165
+ log.warn({ err }, 'assistant-events hub subscriber threw during POST /messages');
166
+ }
167
+ })();
168
+ };
169
+ }
170
+
137
171
  export async function handleSendMessage(
138
172
  req: Request,
139
173
  deps: {
140
174
  processMessage?: MessageProcessor;
141
175
  persistAndProcessMessage?: NonBlockingMessageProcessor;
176
+ sendMessageDeps?: SendMessageDeps;
142
177
  },
143
178
  ): Promise<Response> {
144
179
  const body = await req.json() as {
@@ -204,6 +239,47 @@ export async function handleSendMessage(
204
239
 
205
240
  const mapping = getOrCreateConversation(conversationKey);
206
241
 
242
+ // ── Queue-if-busy path (preferred when sendMessageDeps is wired) ────
243
+ if (deps.sendMessageDeps) {
244
+ const smDeps = deps.sendMessageDeps;
245
+ const session = await smDeps.getOrCreateSession(mapping.conversationId);
246
+ const onEvent = makeHubPublisher(smDeps, mapping.conversationId);
247
+
248
+ const attachments = hasAttachments
249
+ ? smDeps.resolveAttachments(attachmentIds)
250
+ : [];
251
+
252
+ if (session.isProcessing()) {
253
+ // Queue the message so it's processed when the current turn completes
254
+ const requestId = crypto.randomUUID();
255
+ const result = session.enqueueMessage(
256
+ content ?? '',
257
+ attachments,
258
+ onEvent,
259
+ requestId,
260
+ );
261
+ if (result.rejected) {
262
+ return Response.json(
263
+ { error: 'Message queue is full. Please retry later.' },
264
+ { status: 429 },
265
+ );
266
+ }
267
+ return Response.json({ accepted: true, queued: true }, { status: 202 });
268
+ }
269
+
270
+ // Session is idle — persist and fire agent loop immediately
271
+ const requestId = crypto.randomUUID();
272
+ const messageId = session.persistUserMessage(content ?? '', attachments, requestId);
273
+
274
+ // Fire-and-forget the agent loop; events flow to the hub via onEvent
275
+ session.runAgentLoop(content ?? '', messageId, onEvent).catch((err) => {
276
+ log.error({ err, conversationId: mapping.conversationId }, 'Agent loop failed (POST /messages)');
277
+ });
278
+
279
+ return Response.json({ accepted: true, messageId }, { status: 202 });
280
+ }
281
+
282
+ // ── Legacy path (fallback when sendMessageDeps not wired) ───────────
207
283
  const processor = deps.persistAndProcessMessage ?? deps.processMessage;
208
284
  if (!processor) {
209
285
  return Response.json({ error: 'Message processing not configured' }, { status: 503 });
@@ -217,7 +293,7 @@ export async function handleSendMessage(
217
293
  undefined,
218
294
  sourceChannel,
219
295
  );
220
- return Response.json({ accepted: true, messageId: result.messageId });
296
+ return Response.json({ accepted: true, messageId: result.messageId }, { status: 202 });
221
297
  } catch (err) {
222
298
  if (err instanceof Error && err.message === 'Session is already processing a message') {
223
299
  return Response.json(
@@ -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
  /**
@@ -343,20 +343,20 @@ export class SubagentManager {
343
343
 
344
344
  // ── Send message to subagent ──────────────────────────────────────────
345
345
 
346
- sendMessage(subagentId: string, content: string): boolean {
346
+ sendMessage(subagentId: string, content: string): 'sent' | 'empty' | 'not_found' | 'terminal' | 'queue_full' {
347
347
  const trimmed = content?.trim();
348
- if (!trimmed) return false;
348
+ if (!trimmed) return 'empty';
349
349
 
350
350
  const managed = this.subagents.get(subagentId);
351
- if (!managed) return false;
352
- if (TERMINAL_STATUSES.has(managed.state.status)) return false;
351
+ if (!managed) return 'not_found';
352
+ if (TERMINAL_STATUSES.has(managed.state.status)) return 'terminal';
353
353
 
354
354
  const onEvent = managed.session.sendToClient;
355
355
  const requestId = uuid();
356
356
 
357
357
  // If the session is busy, queue the message; otherwise process immediately.
358
358
  const result = managed.session.enqueueMessage(trimmed, [], onEvent, requestId);
359
- if (result.rejected) return false;
359
+ if (result.rejected) return 'queue_full';
360
360
  if (!result.queued) {
361
361
  // Session is idle — send directly. Fire-and-forget so we don't block.
362
362
  const messageId = managed.session.persistUserMessage(trimmed, []);
@@ -364,7 +364,7 @@ export class SubagentManager {
364
364
  log.error({ subagentId, err }, 'Subagent message processing failed');
365
365
  });
366
366
  }
367
- return true;
367
+ return 'sent';
368
368
  }
369
369
 
370
370
  // ── Queries ───────────────────────────────────────────────────────────
@@ -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) {