@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.
- package/package.json +1 -1
- package/src/__tests__/send-endpoint-busy.test.ts +284 -0
- package/src/__tests__/subagent-manager-notify.test.ts +3 -3
- package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
- package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
- package/src/config/defaults.ts +1 -1
- package/src/config/env-registry.ts +7 -0
- package/src/config/memory-schema.ts +3 -3
- package/src/config/schema.ts +1 -1
- package/src/daemon/daemon-control.ts +44 -6
- package/src/daemon/handlers/sessions.ts +20 -0
- package/src/daemon/handlers/subagents.ts +10 -3
- package/src/daemon/ipc-contract/sessions.ts +6 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +2 -1
- package/src/daemon/lifecycle.ts +16 -0
- package/src/daemon/server.ts +8 -0
- package/src/daemon/session-queue-manager.ts +13 -11
- package/src/daemon/session-surfaces.ts +8 -1
- package/src/memory/migrations/016-memory-segments-indexes.ts +5 -4
- package/src/memory/migrations/017-memory-items-indexes.ts +5 -3
- package/src/memory/retriever.ts +4 -1
- package/src/memory/schema.ts +0 -1
- package/src/permissions/checker.ts +14 -7
- package/src/runtime/assistant-event-hub.ts +3 -1
- package/src/runtime/http-server.ts +22 -5
- package/src/runtime/http-types.ts +22 -0
- package/src/runtime/routes/conversation-routes.ts +77 -1
- package/src/runtime/routes/pairing-routes.ts +2 -1
- package/src/subagent/manager.ts +6 -6
- package/src/tools/browser/browser-execution.ts +4 -1
- package/src/tools/executor.ts +12 -9
- package/src/tools/subagent/message.ts +9 -2
- package/src/util/logger.ts +7 -2
|
@@ -109,10 +109,17 @@ export function handleSubagentMessage(
|
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
const
|
|
112
|
+
const result = manager.sendMessage(msg.subagentId, msg.content);
|
|
113
113
|
|
|
114
|
-
if (
|
|
115
|
-
log.warn({ subagentId: msg.subagentId }, '
|
|
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
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -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) {
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
|
147
|
+
const expired: QueuedMessage[] = [];
|
|
153
148
|
this.items = this.items.filter((item) => {
|
|
154
149
|
if (item.queuedAt < cutoff) {
|
|
155
150
|
this.expiredCount++;
|
|
156
|
-
|
|
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
|
-
|
|
164
|
+
} catch (e) {
|
|
165
|
+
log.debug({ err: e, requestId: item.requestId }, 'Failed to notify client of expired message');
|
|
163
166
|
}
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
* conversation_id
|
|
6
|
-
*
|
|
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*/ `
|
|
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
|
|
5
|
-
*
|
|
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
|
}
|
package/src/memory/retriever.ts
CHANGED
|
@@ -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)
|
|
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
|
}
|
package/src/memory/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5
|
-
*
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
19
|
+
pairingBroadcast?: (msg: ServerMessage) => void;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
package/src/subagent/manager.ts
CHANGED
|
@@ -343,20 +343,20 @@ export class SubagentManager {
|
|
|
343
343
|
|
|
344
344
|
// ── Send message to subagent ──────────────────────────────────────────
|
|
345
345
|
|
|
346
|
-
sendMessage(subagentId: string, content: string):
|
|
346
|
+
sendMessage(subagentId: string, content: string): 'sent' | 'empty' | 'not_found' | 'terminal' | 'queue_full' {
|
|
347
347
|
const trimmed = content?.trim();
|
|
348
|
-
if (!trimmed) return
|
|
348
|
+
if (!trimmed) return 'empty';
|
|
349
349
|
|
|
350
350
|
const managed = this.subagents.get(subagentId);
|
|
351
|
-
if (!managed) return
|
|
352
|
-
if (TERMINAL_STATUSES.has(managed.state.status)) return
|
|
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
|
|
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
|
|
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)
|
|
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) {
|