vellum 0.2.0 → 0.2.1

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -11,7 +11,6 @@ import type { RunOrchestrator } from '../run-orchestrator.js';
11
11
  const log = getLogger('runtime-http');
12
12
 
13
13
  export async function handleCreateRun(
14
- assistantId: string,
15
14
  req: Request,
16
15
  runOrchestrator: RunOrchestrator,
17
16
  ): Promise<Response> {
@@ -39,7 +38,7 @@ export async function handleCreateRun(
39
38
  }
40
39
 
41
40
  if (hasAttachments) {
42
- const resolved = attachmentsStore.getAttachmentsByIds(assistantId, attachmentIds);
41
+ const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
43
42
  if (resolved.length !== attachmentIds.length) {
44
43
  const resolvedIds = new Set(resolved.map((a) => a.id));
45
44
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -50,11 +49,10 @@ export async function handleCreateRun(
50
49
  }
51
50
  }
52
51
 
53
- const mapping = getOrCreateConversation(assistantId, conversationKey);
52
+ const mapping = getOrCreateConversation("self", conversationKey);
54
53
 
55
54
  try {
56
55
  const run = await runOrchestrator.startRun(
57
- assistantId,
58
56
  mapping.conversationId,
59
57
  content ?? '',
60
58
  hasAttachments ? attachmentIds : undefined,
@@ -77,12 +75,11 @@ export async function handleCreateRun(
77
75
  }
78
76
 
79
77
  export function handleGetRun(
80
- assistantId: string,
81
78
  runId: string,
82
79
  runOrchestrator: RunOrchestrator,
83
80
  ): Response {
84
81
  const run = runOrchestrator.getRun(runId);
85
- if (!run || run.assistantId !== assistantId) {
82
+ if (!run) {
86
83
  return Response.json({ error: 'Run not found' }, { status: 404 });
87
84
  }
88
85
 
@@ -98,13 +95,12 @@ export function handleGetRun(
98
95
  }
99
96
 
100
97
  export async function handleRunDecision(
101
- assistantId: string,
102
98
  runId: string,
103
99
  req: Request,
104
100
  runOrchestrator: RunOrchestrator,
105
101
  ): Promise<Response> {
106
102
  const run = runOrchestrator.getRun(runId);
107
- if (!run || run.assistantId !== assistantId) {
103
+ if (!run) {
108
104
  return Response.json({ error: 'Run not found' }, { status: 404 });
109
105
  }
110
106
 
@@ -18,6 +18,8 @@ import type { UserDecision } from '../permissions/types.js';
18
18
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
19
19
  import { IngressBlockedError } from '../util/errors.js';
20
20
  import { getLogger } from '../util/logger.js';
21
+ import { assistantEventHub } from './assistant-event-hub.js';
22
+ import { buildAssistantEvent } from './assistant-event.js';
21
23
 
22
24
  const log = getLogger('run-orchestrator');
23
25
 
@@ -32,7 +34,7 @@ interface PendingRunState {
32
34
 
33
35
  export interface RunOrchestratorDeps {
34
36
  getOrCreateSession: (conversationId: string) => Promise<Session>;
35
- resolveAttachments: (assistantId: string, attachmentIds: string[]) => Array<{
37
+ resolveAttachments: (attachmentIds: string[]) => Array<{
36
38
  id: string;
37
39
  filename: string;
38
40
  mimeType: string;
@@ -64,7 +66,6 @@ export class RunOrchestrator {
64
66
  * and fire the agent loop in the background.
65
67
  */
66
68
  async startRun(
67
- assistantId: string,
68
69
  conversationId: string,
69
70
  content: string,
70
71
  attachmentIds?: string[],
@@ -82,15 +83,32 @@ export class RunOrchestrator {
82
83
  }
83
84
 
84
85
  const attachments = attachmentIds
85
- ? this.deps.resolveAttachments(assistantId, attachmentIds)
86
+ ? this.deps.resolveAttachments(attachmentIds)
86
87
  : [];
87
88
 
88
89
  const requestId = crypto.randomUUID();
89
90
  const messageId = session.persistUserMessage(content, attachments, requestId);
90
- const run = runsStore.createRun(assistantId, conversationId, messageId);
91
+ const run = runsStore.createRun('self', conversationId, messageId);
91
92
 
92
93
  // Set the assistant ID so attachments are scoped correctly.
93
- session.setAssistantId(assistantId);
94
+ session.setAssistantId('self');
95
+
96
+ // Serialized publish chain so hub subscribers observe events in order.
97
+ let hubChain: Promise<void> = Promise.resolve();
98
+ const publishToHub = (msg: ServerMessage): void => {
99
+ const msgRecord = msg as unknown as Record<string, unknown>;
100
+ const msgSessionId =
101
+ 'sessionId' in msg && typeof msgRecord.sessionId === 'string'
102
+ ? (msgRecord.sessionId as string)
103
+ : undefined;
104
+ const resolvedSessionId = msgSessionId ?? conversationId;
105
+ const event = buildAssistantEvent('self', msg, resolvedSessionId);
106
+ hubChain = hubChain
107
+ .then(() => assistantEventHub.publish(event))
108
+ .catch((err: unknown) => {
109
+ log.warn({ err }, 'assistant-events hub subscriber threw during HTTP run');
110
+ });
111
+ };
94
112
 
95
113
  // Hook into session to intercept confirmation_request events.
96
114
  // When the prompter sends a confirmation_request, we record it in the
@@ -118,6 +136,9 @@ export class RunOrchestrator {
118
136
  session,
119
137
  });
120
138
  }
139
+ // Mirror every outbound message to the assistant-events hub so SSE
140
+ // subscribers receive the same payload parity as IPC clients.
141
+ publishToHub(msg);
121
142
  });
122
143
 
123
144
  // Fire-and-forget the agent loop
@@ -136,6 +157,12 @@ export class RunOrchestrator {
136
157
  } else if (msg.type === 'session_error') {
137
158
  lastError = msg.userMessage;
138
159
  }
160
+ // Mirror agent-loop events (assistant_text_delta, message_complete,
161
+ // tool_use_start, tool_result, etc.) to the hub. These travel through
162
+ // the onEvent path, distinct from the updateClient path used by the
163
+ // prompter (confirmation_request). Both paths must publish so SSE
164
+ // consumers receive the full response stream.
165
+ publishToHub(msg);
139
166
  }).then(() => {
140
167
  if (lastError) {
141
168
  log.error({ runId: run.id, error: lastError }, 'Run failed (error event from agent loop)');
@@ -0,0 +1,117 @@
1
+ import { RiskLevel } from '../../permissions/types.js';
2
+ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
3
+ import type { ToolDefinition } from '../../providers/types.js';
4
+ import { registerTool } from '../registry.js';
5
+ import { getCallSession, updateCallSession } from '../../calls/call-store.js';
6
+ import { getCallOrchestrator, unregisterCallOrchestrator } from '../../calls/call-state.js';
7
+ import { activeRelayConnections } from '../../calls/relay-server.js';
8
+ import { TwilioConversationRelayProvider } from '../../calls/twilio-provider.js';
9
+ import { getLogger } from '../../util/logger.js';
10
+
11
+ const log = getLogger('call-end');
12
+
13
+ const definition: ToolDefinition = {
14
+ name: 'call_end',
15
+ description: 'End an active phone call',
16
+ input_schema: {
17
+ type: 'object',
18
+ properties: {
19
+ call_session_id: {
20
+ type: 'string',
21
+ description: 'The call session ID to end',
22
+ },
23
+ reason: {
24
+ type: 'string',
25
+ description: 'Reason for ending the call',
26
+ },
27
+ },
28
+ required: ['call_session_id'],
29
+ },
30
+ };
31
+
32
+ class CallEndTool implements Tool {
33
+ name = 'call_end';
34
+ description = definition.description;
35
+ category = 'communication';
36
+ defaultRiskLevel = RiskLevel.Medium;
37
+
38
+ getDefinition(): ToolDefinition {
39
+ return definition;
40
+ }
41
+
42
+ async execute(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
43
+ const callSessionId = input.call_session_id as string | undefined;
44
+ if (!callSessionId || typeof callSessionId !== 'string') {
45
+ return { content: 'Error: call_session_id is required and must be a string', isError: true };
46
+ }
47
+
48
+ const reason = input.reason as string | undefined;
49
+
50
+ try {
51
+ const session = getCallSession(callSessionId);
52
+ if (!session) {
53
+ return { content: `Error: no call session found with ID ${callSessionId}`, isError: true };
54
+ }
55
+
56
+ if (session.status === 'completed' || session.status === 'failed') {
57
+ return {
58
+ content: `Call session ${callSessionId} has already ended with status: ${session.status}`,
59
+ isError: false,
60
+ };
61
+ }
62
+
63
+ log.info({ callSessionId, reason }, 'Ending call');
64
+
65
+ // Terminate the call via the provider API so Twilio hangs up,
66
+ // even if the relay WebSocket is not connected.
67
+ if (session.providerCallSid) {
68
+ try {
69
+ const provider = new TwilioConversationRelayProvider();
70
+ await provider.endCall(session.providerCallSid);
71
+ } catch (endErr) {
72
+ log.warn({ err: endErr, callSessionId, callSid: session.providerCallSid }, 'Failed to terminate call via provider API — proceeding with cleanup');
73
+ }
74
+ }
75
+
76
+ // End the relay connection if active
77
+ const relayConnection = activeRelayConnections.get(callSessionId);
78
+ if (relayConnection) {
79
+ relayConnection.endSession(reason);
80
+ relayConnection.destroy();
81
+ activeRelayConnections.delete(callSessionId);
82
+ }
83
+
84
+ // Clean up orchestrator
85
+ const orchestrator = getCallOrchestrator(callSessionId);
86
+ if (orchestrator) {
87
+ orchestrator.destroy();
88
+ unregisterCallOrchestrator(callSessionId);
89
+ }
90
+
91
+ // Update session status
92
+ updateCallSession(callSessionId, {
93
+ status: 'completed',
94
+ endedAt: Date.now(),
95
+ });
96
+
97
+ log.info({ callSessionId }, 'Call ended successfully');
98
+
99
+ const lines = [
100
+ 'Call ended successfully.',
101
+ ` Call Session ID: ${callSessionId}`,
102
+ ` Status: completed`,
103
+ ];
104
+ if (reason) {
105
+ lines.push(` Reason: ${reason}`);
106
+ }
107
+
108
+ return { content: lines.join('\n'), isError: false };
109
+ } catch (err) {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ log.error({ err, callSessionId }, 'Failed to end call');
112
+ return { content: `Error ending call: ${msg}`, isError: true };
113
+ }
114
+ }
115
+ }
116
+
117
+ registerTool(new CallEndTool());
@@ -0,0 +1,134 @@
1
+ import { RiskLevel } from '../../permissions/types.js';
2
+ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
3
+ import type { ToolDefinition } from '../../providers/types.js';
4
+ import { registerTool } from '../registry.js';
5
+ import { DENIED_NUMBERS } from '../../calls/call-constants.js';
6
+ import { createCallSession, updateCallSession } from '../../calls/call-store.js';
7
+ import { TwilioConversationRelayProvider } from '../../calls/twilio-provider.js';
8
+ import { getTwilioConfig } from '../../calls/twilio-config.js';
9
+ import { getLogger } from '../../util/logger.js';
10
+
11
+ const log = getLogger('call-start');
12
+
13
+ const E164_REGEX = /^\+\d+$/;
14
+
15
+ const definition: ToolDefinition = {
16
+ name: 'call_start',
17
+ description:
18
+ 'Place an outbound phone call via AI voice. The assistant will converse with the callee on behalf of the user.',
19
+ input_schema: {
20
+ type: 'object',
21
+ properties: {
22
+ phone_number: {
23
+ type: 'string',
24
+ description: 'E.164 formatted phone number (e.g. +14155551234)',
25
+ },
26
+ task: {
27
+ type: 'string',
28
+ description: 'What the call should accomplish',
29
+ },
30
+ context: {
31
+ type: 'string',
32
+ description: 'Additional context for the conversation',
33
+ },
34
+ },
35
+ required: ['phone_number', 'task'],
36
+ },
37
+ };
38
+
39
+ class CallStartTool implements Tool {
40
+ name = 'call_start';
41
+ description = definition.description;
42
+ category = 'communication';
43
+ defaultRiskLevel = RiskLevel.High;
44
+
45
+ getDefinition(): ToolDefinition {
46
+ return definition;
47
+ }
48
+
49
+ async execute(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
50
+ const phoneNumber = input.phone_number as string | undefined;
51
+ if (!phoneNumber || typeof phoneNumber !== 'string') {
52
+ return { content: 'Error: phone_number is required and must be a string', isError: true };
53
+ }
54
+
55
+ if (!E164_REGEX.test(phoneNumber)) {
56
+ return {
57
+ content: 'Error: phone_number must be in E.164 format (starts with + followed by digits, e.g. +14155551234)',
58
+ isError: true,
59
+ };
60
+ }
61
+
62
+ const task = input.task as string | undefined;
63
+ if (!task || typeof task !== 'string' || task.trim().length === 0) {
64
+ return { content: 'Error: task is required and must be a non-empty string', isError: true };
65
+ }
66
+
67
+ if (DENIED_NUMBERS.has(phoneNumber)) {
68
+ return { content: 'Error: this phone number is not allowed to be called', isError: true };
69
+ }
70
+
71
+ const callContext = input.context as string | undefined;
72
+
73
+ // Create session outside the try block so it's available in the catch block
74
+ // for marking as failed if the provider call fails.
75
+ let sessionId: string | null = null;
76
+
77
+ try {
78
+ const config = getTwilioConfig();
79
+ const provider = new TwilioConversationRelayProvider();
80
+
81
+ const session = createCallSession({
82
+ conversationId: context.conversationId,
83
+ provider: 'twilio',
84
+ fromNumber: config.phoneNumber,
85
+ toNumber: phoneNumber,
86
+ task: callContext ? `${task}\n\nContext: ${callContext}` : task,
87
+ });
88
+ sessionId = session.id;
89
+
90
+ log.info({ callSessionId: session.id, to: phoneNumber, task }, 'Initiating outbound call');
91
+
92
+ const baseUrl = config.webhookBaseUrl.replace(/\/$/, '');
93
+ const { callSid } = await provider.initiateCall({
94
+ from: config.phoneNumber,
95
+ to: phoneNumber,
96
+ webhookUrl: `${baseUrl}/v1/calls/twilio/voice-webhook?callSessionId=${session.id}`,
97
+ statusCallbackUrl: `${baseUrl}/v1/calls/twilio/status`,
98
+ });
99
+
100
+ updateCallSession(session.id, { providerCallSid: callSid });
101
+
102
+ log.info({ callSessionId: session.id, callSid }, 'Call initiated successfully');
103
+
104
+ return {
105
+ content: [
106
+ 'Call initiated successfully.',
107
+ ` Call Session ID: ${session.id}`,
108
+ ` Call SID: ${callSid}`,
109
+ ` To: ${phoneNumber}`,
110
+ ` Status: initiated`,
111
+ '',
112
+ 'The AI voice assistant is now placing the call. Use call_status to check progress.',
113
+ ].join('\n'),
114
+ isError: false,
115
+ };
116
+ } catch (err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ log.error({ err, phoneNumber }, 'Failed to initiate call');
119
+
120
+ // Mark the session as failed so it doesn't stay in 'initiated' state
121
+ if (sessionId) {
122
+ updateCallSession(sessionId, {
123
+ status: 'failed',
124
+ endedAt: Date.now(),
125
+ lastError: msg,
126
+ });
127
+ }
128
+
129
+ return { content: `Error initiating call: ${msg}`, isError: true };
130
+ }
131
+ }
132
+ }
133
+
134
+ registerTool(new CallStartTool());
@@ -0,0 +1,97 @@
1
+ import { RiskLevel } from '../../permissions/types.js';
2
+ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
3
+ import type { ToolDefinition } from '../../providers/types.js';
4
+ import { registerTool } from '../registry.js';
5
+ import { getCallSession, getActiveCallSessionForConversation, getPendingQuestion } from '../../calls/call-store.js';
6
+ import { getLogger } from '../../util/logger.js';
7
+
8
+ const log = getLogger('call-status');
9
+
10
+ const definition: ToolDefinition = {
11
+ name: 'call_status',
12
+ description: 'Check the status of an active or recent phone call',
13
+ input_schema: {
14
+ type: 'object',
15
+ properties: {
16
+ call_session_id: {
17
+ type: 'string',
18
+ description: 'Specific call session ID to check. If omitted, checks for an active call in the current conversation.',
19
+ },
20
+ },
21
+ required: [],
22
+ },
23
+ };
24
+
25
+ class CallStatusTool implements Tool {
26
+ name = 'call_status';
27
+ description = definition.description;
28
+ category = 'communication';
29
+ defaultRiskLevel = RiskLevel.Low;
30
+
31
+ getDefinition(): ToolDefinition {
32
+ return definition;
33
+ }
34
+
35
+ async execute(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
36
+ const callSessionId = input.call_session_id as string | undefined;
37
+
38
+ try {
39
+ let session;
40
+
41
+ if (callSessionId) {
42
+ session = getCallSession(callSessionId);
43
+ if (!session) {
44
+ return { content: `Error: no call session found with ID ${callSessionId}`, isError: true };
45
+ }
46
+ } else {
47
+ session = getActiveCallSessionForConversation(context.conversationId);
48
+ if (!session) {
49
+ return { content: 'No active call found in the current conversation.', isError: false };
50
+ }
51
+ }
52
+
53
+ log.info({ callSessionId: session.id, status: session.status }, 'Checking call status');
54
+
55
+ const lines = [
56
+ `Call Session: ${session.id}`,
57
+ ` Status: ${session.status}`,
58
+ ` To: ${session.toNumber}`,
59
+ ` From: ${session.fromNumber}`,
60
+ ];
61
+
62
+ if (session.providerCallSid) {
63
+ lines.push(` Call SID: ${session.providerCallSid}`);
64
+ }
65
+
66
+ if (session.task) {
67
+ lines.push(` Task: ${session.task}`);
68
+ }
69
+
70
+ if (session.startedAt) {
71
+ const durationMs = (session.endedAt ?? Date.now()) - session.startedAt;
72
+ const durationSec = Math.round(durationMs / 1000);
73
+ lines.push(` Duration: ${durationSec}s`);
74
+ }
75
+
76
+ if (session.lastError) {
77
+ lines.push(` Last Error: ${session.lastError}`);
78
+ }
79
+
80
+ // Check for pending questions from the call
81
+ const pendingQuestion = getPendingQuestion(session.id);
82
+ if (pendingQuestion) {
83
+ lines.push('');
84
+ lines.push(` Pending Question: ${pendingQuestion.questionText}`);
85
+ lines.push(` Question ID: ${pendingQuestion.id}`);
86
+ }
87
+
88
+ return { content: lines.join('\n'), isError: false };
89
+ } catch (err) {
90
+ const msg = err instanceof Error ? err.message : String(err);
91
+ log.error({ err, callSessionId }, 'Failed to check call status');
92
+ return { content: `Error checking call status: ${msg}`, isError: true };
93
+ }
94
+ }
95
+ }
96
+
97
+ registerTool(new CallStatusTool());
@@ -113,7 +113,7 @@ class CredentialStoreTool implements Tool {
113
113
  name = 'credential_store';
114
114
  description = 'Store, list, delete, or prompt for credentials in the secure vault';
115
115
  category = 'credentials';
116
- defaultRiskLevel = RiskLevel.Medium;
116
+ defaultRiskLevel = RiskLevel.Low;
117
117
 
118
118
  getDefinition(): ToolDefinition {
119
119
  return {
@@ -198,7 +198,7 @@ export function getAllToolDefinitions(): ToolDefinition[] {
198
198
  }
199
199
 
200
200
  export async function initializeTools(): Promise<void> {
201
- const { eagerModules, eagerModuleToolNames, explicitTools, lazyTools } = await import('./tool-manifest.js');
201
+ const { loadEagerModules, eagerModuleToolNames, explicitTools, lazyTools } = await import('./tool-manifest.js');
202
202
 
203
203
  // Capture tool names already in the registry before any manifest
204
204
  // registrations. In production this is empty; in tests a non-skill tool
@@ -206,9 +206,7 @@ export async function initializeTools(): Promise<void> {
206
206
  const preExisting = new Set(tools.keys());
207
207
 
208
208
  // Import tool modules to trigger registration side effects.
209
- for (const modulePath of eagerModules) {
210
- await import(modulePath);
211
- }
209
+ await loadEagerModules();
212
210
 
213
211
  // Explicit tool instances — no side-effect import required.
214
212
  for (const tool of explicitTools) {
@@ -23,3 +23,5 @@ export { taskListTool } from './task-list.js';
23
23
  export { taskDeleteTool } from './task-delete.js';
24
24
  export { taskListShowTool } from './work-item-list.js';
25
25
  export { taskListAddTool } from './work-item-enqueue.js';
26
+ export { taskListUpdateTool } from './work-item-update.js';
27
+ export { taskListRemoveTool } from './work-item-remove.js';
@@ -2,6 +2,10 @@ import { RiskLevel } from '../../permissions/types.js';
2
2
  import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
3
3
  import type { ToolDefinition } from '../../providers/types.js';
4
4
  import { deleteTask, deleteTasks, getTask } from '../../tasks/task-store.js';
5
+ import { removeWorkItemFromQueue } from '../../work-items/work-item-store.js';
6
+ import { getLogger } from '../../util/logger.js';
7
+
8
+ const log = getLogger('task-delete');
5
9
 
6
10
  const definition: ToolDefinition = {
7
11
  name: 'task_delete',
@@ -44,23 +48,60 @@ class TaskDeleteTool implements Tool {
44
48
  const task = getTask(ids[0]);
45
49
  const deleted = deleteTask(ids[0]);
46
50
  if (!deleted) {
47
- return { content: `No task found with ID ${ids[0]}`, isError: true };
51
+ // The LLM may pass a work item ID instead of a task template ID.
52
+ // Fall back to removing from the task queue so the user's intent succeeds.
53
+ const result = removeWorkItemFromQueue(ids[0]);
54
+ if (result.success) {
55
+ log.info({ inputId: ids[0], fallback: true, deletedCount: 1 }, 'deleted via work item fallback');
56
+ return { content: result.message, isError: false };
57
+ }
58
+ log.warn({ inputId: ids[0] }, 'no task or work item found for deletion');
59
+ return { content: `No task template or work item found with ID "${ids[0]}". Use task_list to see task templates or task_list_show to see work items in the queue.`, isError: true };
48
60
  }
61
+ log.info({ taskId: ids[0], title: task?.title, deletedCount: 1 }, 'task deleted');
49
62
  return { content: `Deleted task: ${task?.title ?? ids[0]}`, isError: false };
50
63
  }
51
64
 
52
- const titles = ids.map((id) => {
53
- const t = getTask(id);
54
- return t ? t.title : id;
55
- });
56
- const count = deleteTasks(ids);
57
- if (count === 0) {
65
+ const taskIds: string[] = [];
66
+ const taskTitles: string[] = [];
67
+ const workItemTitles: string[] = [];
68
+
69
+ for (const id of ids) {
70
+ const task = getTask(id);
71
+ if (task) {
72
+ taskIds.push(id);
73
+ taskTitles.push(task.title);
74
+ } else {
75
+ const result = removeWorkItemFromQueue(id);
76
+ if (result.success) {
77
+ log.info({ inputId: id, fallback: true }, 'deleted work item in batch (fallback)');
78
+ workItemTitles.push(result.title);
79
+ } else {
80
+ log.warn({ inputId: id }, 'batch delete: no task or work item found');
81
+ }
82
+ }
83
+ }
84
+
85
+ const taskCount = taskIds.length > 0 ? deleteTasks(taskIds) : 0;
86
+
87
+ if (taskCount === 0 && workItemTitles.length === 0) {
88
+ log.warn({ inputIds: ids }, 'no matching tasks found to delete');
58
89
  return { content: 'No matching tasks found to delete.', isError: true };
59
90
  }
60
- const lines = [`Deleted ${count} task(s):`, ...titles.map((t) => `- ${t}`)];
91
+
92
+ log.info({ deletedTasks: taskCount, deletedWorkItems: workItemTitles.length, totalInput: ids.length }, 'batch delete completed');
93
+
94
+ const lines: string[] = [];
95
+ if (taskCount > 0) {
96
+ lines.push(`Deleted ${taskCount} task(s):`, ...taskTitles.map((t) => `- ${t}`));
97
+ }
98
+ if (workItemTitles.length > 0) {
99
+ lines.push(`Removed ${workItemTitles.length} item(s) from the task queue:`, ...workItemTitles.map((t) => `- ${t}`));
100
+ }
61
101
  return { content: lines.join('\n'), isError: false };
62
102
  } catch (err) {
63
103
  const msg = err instanceof Error ? err.message : String(err);
104
+ log.error({ inputIds: ids, error: msg }, 'delete failed');
64
105
  return { content: `Error: ${msg}`, isError: true };
65
106
  }
66
107
  }
@@ -3,6 +3,7 @@ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
3
3
  import type { ToolDefinition } from '../../providers/types.js';
4
4
  import { getTask, listTasks } from '../../tasks/task-store.js';
5
5
  import { renderTemplate } from '../../tasks/task-runner.js';
6
+ import { identifyEntityById, buildWorkItemMismatchError } from '../../work-items/work-item-store.js';
6
7
 
7
8
  const definition: ToolDefinition = {
8
9
  name: 'task_run',
@@ -57,7 +58,14 @@ class TaskRunTool implements Tool {
57
58
  if (taskId) {
58
59
  task = getTask(taskId);
59
60
  if (!task) {
60
- return { content: `Error: No task found with ID "${taskId}"`, isError: true };
61
+ const entity = identifyEntityById(taskId);
62
+ if (entity.type === 'work_item') {
63
+ return {
64
+ content: `Error: ${buildWorkItemMismatchError(taskId, entity.title!, 'task_list_show to view work items, or task_list_update to modify them')}`,
65
+ isError: true,
66
+ };
67
+ }
68
+ return { content: `Error: No task template found with ID "${taskId}". Use task_list to see available templates.`, isError: true };
61
69
  }
62
70
  } else if (taskName) {
63
71
  const allTasks = listTasks();