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
@@ -10,6 +10,7 @@ import { postToSlackWebhook } from '../../slack/slack-webhook.js';
10
10
  import { getApp } from '../../memory/app-store.js';
11
11
  import type {
12
12
  ModelSetRequest,
13
+ ImageGenModelSetRequest,
13
14
  AddTrustRule,
14
15
  RemoveTrustRule,
15
16
  UpdateTrustRule,
@@ -126,6 +127,35 @@ export function handleModelSet(
126
127
  }
127
128
  }
128
129
 
130
+ export function handleImageGenModelSet(
131
+ msg: ImageGenModelSetRequest,
132
+ _socket: net.Socket,
133
+ ctx: HandlerContext,
134
+ ): void {
135
+ try {
136
+ const raw = loadRawConfig();
137
+ raw.imageGenModel = msg.model;
138
+
139
+ ctx.setSuppressConfigReload(true);
140
+ try {
141
+ saveRawConfig(raw);
142
+ } catch (err) {
143
+ ctx.setSuppressConfigReload(false);
144
+ throw err;
145
+ }
146
+ const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
147
+ if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
148
+ const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, 300);
149
+ ctx.debounceTimers.set('__suppress_reset__', resetTimer);
150
+
151
+ ctx.updateConfigFingerprint();
152
+ log.info({ model: msg.model }, 'Image generation model updated');
153
+ } catch (err) {
154
+ const message = err instanceof Error ? err.message : String(err);
155
+ log.error({ err }, `Failed to set image gen model: ${message}`);
156
+ }
157
+ }
158
+
129
159
  export function handleAddTrustRule(
130
160
  msg: AddTrustRule,
131
161
  _socket: net.Socket,
@@ -55,6 +55,7 @@ import {
55
55
  import {
56
56
  handleModelGet,
57
57
  handleModelSet,
58
+ handleImageGenModelSet,
58
59
  handleAddTrustRule,
59
60
  handleTrustRulesList,
60
61
  handleRemoveTrustRule,
@@ -103,7 +104,9 @@ import {
103
104
  handleWorkItemCreate,
104
105
  handleWorkItemUpdate,
105
106
  handleWorkItemComplete,
107
+ handleWorkItemDelete,
106
108
  handleWorkItemRunTask,
109
+ handleWorkItemOutput,
107
110
  } from './work-items.js';
108
111
 
109
112
  import {
@@ -152,6 +155,7 @@ const handlers: DispatchMap = {
152
155
  delete_queued_message: handleDeleteQueuedMessage,
153
156
  model_get: (_msg, socket, ctx) => handleModelGet(socket, ctx),
154
157
  model_set: handleModelSet,
158
+ image_gen_model_set: handleImageGenModelSet,
155
159
  history_request: handleHistoryRequest,
156
160
  undo: handleUndo,
157
161
  regenerate: handleRegenerate,
@@ -329,7 +333,9 @@ const handlers: DispatchMap = {
329
333
  work_item_create: handleWorkItemCreate,
330
334
  work_item_update: handleWorkItemUpdate,
331
335
  work_item_complete: handleWorkItemComplete,
336
+ work_item_delete: handleWorkItemDelete,
332
337
  work_item_run_task: handleWorkItemRunTask,
338
+ work_item_output: handleWorkItemOutput,
333
339
 
334
340
  subagent_abort: handleSubagentAbort,
335
341
  subagent_status: handleSubagentStatus,
@@ -5,18 +5,22 @@ import type {
5
5
  WorkItemCreateRequest,
6
6
  WorkItemUpdateRequest,
7
7
  WorkItemCompleteRequest,
8
+ WorkItemDeleteRequest,
8
9
  WorkItemRunTaskRequest,
10
+ WorkItemOutputRequest,
9
11
  } from '../ipc-protocol.js';
10
12
  import { log, type HandlerContext } from './shared.js';
11
13
  import {
12
14
  createWorkItem,
15
+ deleteWorkItem,
13
16
  getWorkItem,
14
17
  listWorkItems,
15
18
  updateWorkItem,
16
19
  type WorkItemStatus,
17
20
  } from '../../work-items/work-item-store.js';
18
- import { getTask } from '../../tasks/task-store.js';
21
+ import { getTask, getTaskRun } from '../../tasks/task-store.js';
19
22
  import { runTask } from '../../tasks/task-runner.js';
23
+ import { getMessages } from '../../memory/conversation-store.js';
20
24
 
21
25
  export function handleWorkItemsList(
22
26
  msg: WorkItemsListRequest,
@@ -57,6 +61,7 @@ export function handleWorkItemCreate(
57
61
 
58
62
  // Notify all connected clients so open Task Queue views refresh immediately
59
63
  broadcastWorkItemStatus(ctx, item.id);
64
+ ctx.broadcast({ type: 'tasks_changed' });
60
65
  }
61
66
 
62
67
  export function handleWorkItemUpdate(
@@ -78,6 +83,7 @@ export function handleWorkItemUpdate(
78
83
  // (e.g. priority/sort changes made by one client are reflected everywhere)
79
84
  if (item) {
80
85
  broadcastWorkItemStatus(ctx, item.id);
86
+ ctx.broadcast({ type: 'tasks_changed' });
81
87
  }
82
88
  }
83
89
 
@@ -86,6 +92,18 @@ export function handleWorkItemComplete(
86
92
  socket: net.Socket,
87
93
  ctx: HandlerContext,
88
94
  ): void {
95
+ // Only allow completion from the 'awaiting_review' state — this ensures
96
+ // items go through the full run lifecycle before being marked done.
97
+ const existing = getWorkItem(msg.id);
98
+ if (!existing) {
99
+ ctx.send(socket, { type: 'error', message: `Work item not found: ${msg.id}` });
100
+ return;
101
+ }
102
+ if (existing.status !== 'awaiting_review') {
103
+ ctx.send(socket, { type: 'error', message: `Cannot complete work item: status is '${existing.status}', expected 'awaiting_review'` });
104
+ return;
105
+ }
106
+
89
107
  const item = updateWorkItem(msg.id, { status: 'done' }) ?? null;
90
108
  ctx.send(socket, { type: 'work_item_update_response', item });
91
109
  if (item) {
@@ -102,9 +120,25 @@ export function handleWorkItemComplete(
102
120
  updatedAt: item.updatedAt,
103
121
  },
104
122
  });
123
+ ctx.broadcast({ type: 'tasks_changed' });
105
124
  }
106
125
  }
107
126
 
127
+ export function handleWorkItemDelete(
128
+ msg: WorkItemDeleteRequest,
129
+ socket: net.Socket,
130
+ ctx: HandlerContext,
131
+ ): void {
132
+ const existing = getWorkItem(msg.id);
133
+ if (!existing) {
134
+ ctx.send(socket, { type: 'work_item_delete_response', id: msg.id, success: false });
135
+ return;
136
+ }
137
+ deleteWorkItem(msg.id);
138
+ ctx.send(socket, { type: 'work_item_delete_response', id: msg.id, success: true });
139
+ ctx.broadcast({ type: 'tasks_changed' });
140
+ }
141
+
108
142
  function broadcastWorkItemStatus(ctx: HandlerContext, id: string): void {
109
143
  const item = getWorkItem(id);
110
144
  if (item) {
@@ -124,6 +158,92 @@ function broadcastWorkItemStatus(ctx: HandlerContext, id: string): void {
124
158
  }
125
159
  }
126
160
 
161
+ export function handleWorkItemOutput(
162
+ msg: WorkItemOutputRequest,
163
+ socket: net.Socket,
164
+ ctx: HandlerContext,
165
+ ): void {
166
+ try {
167
+ const workItem = getWorkItem(msg.id);
168
+ if (!workItem) {
169
+ ctx.send(socket, { type: 'work_item_output_response', id: msg.id, success: false, error: 'Work item not found' });
170
+ return;
171
+ }
172
+
173
+ // If the work item has never been run, return an error so the client
174
+ // can show "No output yet" instead of an empty loaded state.
175
+ if (!workItem.lastRunConversationId) {
176
+ ctx.send(socket, { type: 'work_item_output_response', id: msg.id, success: false, error: 'This task has not been run yet. No output is available.' });
177
+ return;
178
+ }
179
+
180
+ let summary = '';
181
+ const highlights: string[] = [];
182
+
183
+ const msgs = getMessages(workItem.lastRunConversationId);
184
+ // Find the last assistant message with text content (not tool calls)
185
+ for (let i = msgs.length - 1; i >= 0; i--) {
186
+ const m = msgs[i];
187
+ if (m.role !== 'assistant') continue;
188
+
189
+ let text = m.content;
190
+ // Content may be JSON array of content blocks — extract text blocks only
191
+ try {
192
+ const parsed = JSON.parse(text);
193
+ if (Array.isArray(parsed)) {
194
+ text = parsed
195
+ .filter((b: { type: string }) => b.type === 'text')
196
+ .map((b: { text: string }) => b.text)
197
+ .join('\n');
198
+ }
199
+ } catch {
200
+ // Plain text content — use as-is
201
+ }
202
+
203
+ if (!text.trim()) continue;
204
+
205
+ summary = text.length > 2000 ? text.slice(0, 2000) : text;
206
+
207
+ // Extract up to 5 notable lines (bullet points or key findings)
208
+ const lines = text.split('\n');
209
+ for (const line of lines) {
210
+ const trimmed = line.trim();
211
+ if ((trimmed.startsWith('-') || trimmed.startsWith('*')) && trimmed.length > 2) {
212
+ highlights.push(trimmed);
213
+ if (highlights.length >= 5) break;
214
+ }
215
+ }
216
+ break;
217
+ }
218
+
219
+ // Convert finishedAt from milliseconds (Date.now()) to seconds for the
220
+ // client, which uses Date(timeIntervalSince1970:) expecting seconds.
221
+ let completedAt: number | null = null;
222
+ if (workItem.lastRunId) {
223
+ const run = getTaskRun(workItem.lastRunId);
224
+ completedAt = run?.finishedAt != null ? Math.floor(run.finishedAt / 1000) : null;
225
+ }
226
+
227
+ ctx.send(socket, {
228
+ type: 'work_item_output_response',
229
+ id: msg.id,
230
+ success: true,
231
+ output: {
232
+ title: workItem.title,
233
+ status: workItem.lastRunStatus ?? workItem.status,
234
+ runId: workItem.lastRunId,
235
+ conversationId: workItem.lastRunConversationId,
236
+ completedAt,
237
+ summary,
238
+ highlights,
239
+ },
240
+ });
241
+ } catch (err) {
242
+ log.error({ err, workItemId: msg.id }, 'handleWorkItemOutput failed');
243
+ ctx.send(socket, { type: 'work_item_output_response', id: msg.id, success: false, error: 'Failed to load task output' });
244
+ }
245
+ }
246
+
127
247
  export async function handleWorkItemRunTask(
128
248
  msg: WorkItemRunTaskRequest,
129
249
  socket: net.Socket,
@@ -131,7 +251,24 @@ export async function handleWorkItemRunTask(
131
251
  ): Promise<void> {
132
252
  const workItem = getWorkItem(msg.id);
133
253
  if (!workItem) {
134
- ctx.send(socket, { type: 'work_item_run_task_response', id: msg.id, lastRunId: '', success: false, error: 'Work item not found' });
254
+ ctx.send(socket, { type: 'work_item_run_task_response', id: msg.id, lastRunId: '', success: false, error: 'Work item not found', errorCode: 'not_found' });
255
+ return;
256
+ }
257
+
258
+ if (workItem.status === 'running') {
259
+ ctx.send(socket, { type: 'work_item_run_task_response', id: msg.id, lastRunId: workItem.lastRunId ?? '', success: false, error: 'Work item is already running', errorCode: 'already_running' });
260
+ return;
261
+ }
262
+
263
+ const NON_RUNNABLE_STATUSES: readonly string[] = ['done', 'archived'];
264
+ if (NON_RUNNABLE_STATUSES.includes(workItem.status)) {
265
+ ctx.send(socket, { type: 'work_item_run_task_response', id: msg.id, lastRunId: workItem.lastRunId ?? '', success: false, error: `Work item has status '${workItem.status}' and cannot be run`, errorCode: 'invalid_status' });
266
+ return;
267
+ }
268
+
269
+ const task = getTask(workItem.taskId);
270
+ if (!task) {
271
+ ctx.send(socket, { type: 'work_item_run_task_response', id: msg.id, lastRunId: '', success: false, error: `Associated task not found: ${workItem.taskId}`, errorCode: 'no_task' });
135
272
  return;
136
273
  }
137
274
 
@@ -143,6 +280,7 @@ export async function handleWorkItemRunTask(
143
280
 
144
281
  // Broadcast the running state
145
282
  broadcastWorkItemStatus(ctx, msg.id);
283
+ ctx.broadcast({ type: 'tasks_changed' });
146
284
 
147
285
  // Execute task asynchronously — create a session and wire processMessage
148
286
  try {
@@ -165,6 +303,7 @@ export async function handleWorkItemRunTask(
165
303
  });
166
304
 
167
305
  broadcastWorkItemStatus(ctx, msg.id);
306
+ ctx.broadcast({ type: 'tasks_changed' });
168
307
  } catch (err) {
169
308
  log.error({ err, workItemId: msg.id }, 'work_item_run_task failed');
170
309
  updateWorkItem(msg.id, {
@@ -172,5 +311,6 @@ export async function handleWorkItemRunTask(
172
311
  lastRunStatus: 'failed',
173
312
  });
174
313
  broadcastWorkItemStatus(ctx, msg.id);
314
+ ctx.broadcast({ type: 'tasks_changed' });
175
315
  }
176
316
  }
@@ -31,6 +31,7 @@
31
31
  "GetSigningIdentityResponse",
32
32
  "HistoryRequest",
33
33
  "HomeBaseGetRequest",
34
+ "ImageGenModelSetRequest",
34
35
  "IntegrationConnectRequest",
35
36
  "IntegrationDisconnectRequest",
36
37
  "IntegrationListRequest",
@@ -90,7 +91,9 @@
90
91
  "WatchObservation",
91
92
  "WorkItemCompleteRequest",
92
93
  "WorkItemCreateRequest",
94
+ "WorkItemDeleteRequest",
93
95
  "WorkItemGetRequest",
96
+ "WorkItemOutputRequest",
94
97
  "WorkItemRunTaskRequest",
95
98
  "WorkItemUpdateRequest",
96
99
  "WorkItemsListRequest"
@@ -174,6 +177,7 @@
174
177
  "SubagentStatusChanged",
175
178
  "SuggestionResponse",
176
179
  "TaskRouted",
180
+ "TasksChanged",
177
181
  "ToolInputDelta",
178
182
  "ToolOutputChunk",
179
183
  "ToolResult",
@@ -196,7 +200,9 @@
196
200
  "WatcherEscalation",
197
201
  "WatcherNotification",
198
202
  "WorkItemCreateResponse",
203
+ "WorkItemDeleteResponse",
199
204
  "WorkItemGetResponse",
205
+ "WorkItemOutputResponse",
200
206
  "WorkItemRunTaskResponse",
201
207
  "WorkItemStatusChanged",
202
208
  "WorkItemUpdateResponse",
@@ -234,6 +240,7 @@
234
240
  "get_signing_identity_response",
235
241
  "history_request",
236
242
  "home_base_get",
243
+ "image_gen_model_set",
237
244
  "integration_connect",
238
245
  "integration_disconnect",
239
246
  "integration_list",
@@ -293,7 +300,9 @@
293
300
  "watch_observation",
294
301
  "work_item_complete",
295
302
  "work_item_create",
303
+ "work_item_delete",
296
304
  "work_item_get",
305
+ "work_item_output",
297
306
  "work_item_run_task",
298
307
  "work_item_update",
299
308
  "work_items_list"
@@ -377,6 +386,7 @@
377
386
  "subagent_status_changed",
378
387
  "suggestion_response",
379
388
  "task_routed",
389
+ "tasks_changed",
380
390
  "tool_input_delta",
381
391
  "tool_output_chunk",
382
392
  "tool_result",
@@ -398,7 +408,9 @@
398
408
  "watcher_escalation",
399
409
  "watcher_notification",
400
410
  "work_item_create_response",
411
+ "work_item_delete_response",
401
412
  "work_item_get_response",
413
+ "work_item_output_response",
402
414
  "work_item_run_task_response",
403
415
  "work_item_status_changed",
404
416
  "work_item_update_response",
@@ -125,6 +125,11 @@ export interface ModelSetRequest {
125
125
  model: string;
126
126
  }
127
127
 
128
+ export interface ImageGenModelSetRequest {
129
+ type: 'image_gen_model_set';
130
+ model: string;
131
+ }
132
+
128
133
  export interface HistoryRequest {
129
134
  type: 'history_request';
130
135
  sessionId: string;
@@ -793,11 +798,21 @@ export interface WorkItemCompleteRequest {
793
798
  id: string;
794
799
  }
795
800
 
801
+ export interface WorkItemDeleteRequest {
802
+ type: 'work_item_delete';
803
+ id: string;
804
+ }
805
+
796
806
  export interface WorkItemRunTaskRequest {
797
807
  type: 'work_item_run_task';
798
808
  id: string;
799
809
  }
800
810
 
811
+ export interface WorkItemOutputRequest {
812
+ type: 'work_item_output';
813
+ id: string;
814
+ }
815
+
801
816
  export type ClientMessage =
802
817
  | AuthMessage
803
818
  | UserMessage
@@ -811,6 +826,7 @@ export type ClientMessage =
811
826
  | DeleteQueuedMessage
812
827
  | ModelGetRequest
813
828
  | ModelSetRequest
829
+ | ImageGenModelSetRequest
814
830
  | HistoryRequest
815
831
  | UndoRequest
816
832
  | RegenerateRequest
@@ -889,7 +905,9 @@ export type ClientMessage =
889
905
  | WorkItemCreateRequest
890
906
  | WorkItemUpdateRequest
891
907
  | WorkItemCompleteRequest
908
+ | WorkItemDeleteRequest
892
909
  | WorkItemRunTaskRequest
910
+ | WorkItemOutputRequest
893
911
  | SubagentAbortRequest
894
912
  | SubagentStatusRequest
895
913
  | SubagentMessageRequest;
@@ -1914,12 +1932,38 @@ export interface WorkItemUpdateResponse {
1914
1932
  } | null;
1915
1933
  }
1916
1934
 
1935
+ export interface WorkItemDeleteResponse {
1936
+ type: 'work_item_delete_response';
1937
+ id: string;
1938
+ success: boolean;
1939
+ }
1940
+
1941
+ export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task';
1942
+
1917
1943
  export interface WorkItemRunTaskResponse {
1918
1944
  type: 'work_item_run_task_response';
1919
1945
  id: string;
1920
1946
  lastRunId: string;
1921
1947
  success: boolean;
1922
1948
  error?: string;
1949
+ /** Structured error code so the client can deterministically re-enable buttons or show contextual UI. */
1950
+ errorCode?: WorkItemRunTaskErrorCode;
1951
+ }
1952
+
1953
+ export interface WorkItemOutputResponse {
1954
+ type: 'work_item_output_response';
1955
+ id: string;
1956
+ success: boolean;
1957
+ error?: string;
1958
+ output?: {
1959
+ title: string;
1960
+ status: string;
1961
+ runId: string | null;
1962
+ conversationId: string | null;
1963
+ completedAt: number | null;
1964
+ summary: string;
1965
+ highlights: string[];
1966
+ };
1923
1967
  }
1924
1968
 
1925
1969
  /** Server push — tells the client to open/focus the tasks window. */
@@ -1927,6 +1971,11 @@ export interface OpenTasksWindow {
1927
1971
  type: 'open_tasks_window';
1928
1972
  }
1929
1973
 
1974
+ /** Server push — lightweight invalidation signal: the task queue has been mutated, refetch your list. */
1975
+ export interface TasksChanged {
1976
+ type: 'tasks_changed';
1977
+ }
1978
+
1930
1979
  /** Server push — broadcast when a work item status changes (e.g. running -> awaiting_review). */
1931
1980
  export interface WorkItemStatusChanged {
1932
1981
  type: 'work_item_status_changed';
@@ -2042,8 +2091,11 @@ export type ServerMessage =
2042
2091
  | WorkItemGetResponse
2043
2092
  | WorkItemCreateResponse
2044
2093
  | WorkItemUpdateResponse
2094
+ | WorkItemDeleteResponse
2045
2095
  | WorkItemRunTaskResponse
2096
+ | WorkItemOutputResponse
2046
2097
  | WorkItemStatusChanged
2098
+ | TasksChanged
2047
2099
  | OpenTasksWindow
2048
2100
  | SubagentSpawned
2049
2101
  | SubagentStatusChanged
@@ -22,6 +22,7 @@ import { initializeTools } from '../tools/registry.js';
22
22
  import { loadConfig } from '../config/loader.js';
23
23
  import { ensurePromptFiles } from '../config/system-prompt.js';
24
24
  import { DaemonServer } from './server.js';
25
+ import { listWorkItems, updateWorkItem } from '../work-items/work-item-store.js';
25
26
  import { getLogger, initLogger } from '../util/logger.js';
26
27
  import { DaemonError } from '../util/errors.js';
27
28
  import { initSentry } from '../instrument.js';
@@ -43,6 +44,7 @@ import { RuntimeHttpServer } from '../runtime/http-server.js';
43
44
  import { getHookManager } from '../hooks/manager.js';
44
45
  import { installTemplates } from '../hooks/templates.js';
45
46
  import { HeartbeatService } from '../workspace/heartbeat-service.js';
47
+ import { getEnrichmentService } from '../workspace/commit-message-enrichment-service.js';
46
48
 
47
49
  const log = getLogger('lifecycle');
48
50
 
@@ -279,6 +281,17 @@ export async function runDaemon(): Promise<void> {
279
281
  initializeDb();
280
282
  log.info('Daemon startup: DB initialized');
281
283
 
284
+ // Recover orphaned work items that were left in 'running' state when the
285
+ // daemon previously crashed or was killed mid-task.
286
+ const orphanedRunning = listWorkItems({ status: 'running' });
287
+ if (orphanedRunning.length > 0) {
288
+ for (const item of orphanedRunning) {
289
+ updateWorkItem(item.id, { status: 'failed', lastRunStatus: 'interrupted' });
290
+ log.info({ workItemId: item.id, title: item.title }, 'Recovered orphaned running work item → failed (interrupted)');
291
+ }
292
+ log.info({ count: orphanedRunning.length }, 'Recovered orphaned running work items');
293
+ }
294
+
282
295
  log.info('Daemon startup: loading config');
283
296
  const config = loadConfig();
284
297
 
@@ -328,7 +341,7 @@ export async function runDaemon(): Promise<void> {
328
341
 
329
342
  const scheduler = startScheduler(
330
343
  async (conversationId, message) => {
331
- await server.processMessage('schedule', conversationId, message);
344
+ await server.processMessage(conversationId, message);
332
345
  },
333
346
  (reminder) => {
334
347
  server.broadcast({
@@ -382,10 +395,10 @@ export async function runDaemon(): Promise<void> {
382
395
  port,
383
396
  hostname,
384
397
  bearerToken,
385
- processMessage: (assistantId, conversationId, content, attachmentIds, options, sourceChannel) =>
386
- server.processMessage(assistantId, conversationId, content, attachmentIds, options, sourceChannel),
387
- persistAndProcessMessage: (assistantId, conversationId, content, attachmentIds, options, sourceChannel) =>
388
- server.persistAndProcessMessage(assistantId, conversationId, content, attachmentIds, options, sourceChannel),
398
+ processMessage: (conversationId, content, attachmentIds, options, sourceChannel) =>
399
+ server.processMessage(conversationId, content, attachmentIds, options, sourceChannel),
400
+ persistAndProcessMessage: (conversationId, content, attachmentIds, options, sourceChannel) =>
401
+ server.persistAndProcessMessage(conversationId, content, attachmentIds, options, sourceChannel),
389
402
  runOrchestrator: server.createRunOrchestrator(),
390
403
  interfacesDir: getInterfacesDir(),
391
404
  });
@@ -477,6 +490,15 @@ export async function runDaemon(): Promise<void> {
477
490
  } catch (err) {
478
491
  log.warn({ err, phase: 'post_stop' }, 'Post-stop workspace commit failed');
479
492
  }
493
+
494
+ // Flush in-flight enrichment jobs so shutdown commit notes are not dropped.
495
+ // The enrichment service's shutdown() drains active jobs and discards pending ones.
496
+ try {
497
+ await getEnrichmentService().shutdown();
498
+ } catch (err) {
499
+ log.warn({ err }, 'Enrichment service shutdown failed (non-fatal)');
500
+ }
501
+
480
502
  if (runtimeHttp) await runtimeHttp.stop();
481
503
  await browserManager.closeAllPages();
482
504
  scheduler.stop();
@@ -967,7 +967,6 @@ export class DaemonServer {
967
967
  * is not blocked for the duration of the agent loop.
968
968
  */
969
969
  async persistAndProcessMessage(
970
- assistantId: string,
971
970
  conversationId: string,
972
971
  content: string,
973
972
  attachmentIds?: string[],
@@ -988,14 +987,14 @@ export class DaemonServer {
988
987
  throw new Error('Session is already processing a message');
989
988
  }
990
989
 
991
- // Set assistantId AFTER the isProcessing check so a rejected request
992
- // doesn't mutate the session state visible to an in-flight request.
993
- session.setAssistantId(assistantId);
990
+ // Set the session identity AFTER the isProcessing check so a rejected
991
+ // request doesn't mutate the session state visible to an in-flight request.
992
+ session.setAssistantId('self');
994
993
  session.setChannelCapabilities(resolveChannelCapabilities(sourceChannel));
995
994
 
996
995
  // Resolve attachment IDs to full attachment data for the session
997
996
  const attachments = attachmentIds
998
- ? attachmentsStore.getAttachmentsByIds(assistantId, attachmentIds).map((a) => ({
997
+ ? attachmentsStore.getAttachmentsByIds('self', attachmentIds).map((a) => ({
999
998
  id: a.id,
1000
999
  filename: a.originalFilename,
1001
1000
  mimeType: a.mimeType,
@@ -1022,7 +1021,6 @@ export class DaemonServer {
1022
1021
  * Used by the channel inbound endpoint which needs the assistant reply.
1023
1022
  */
1024
1023
  async processMessage(
1025
- assistantId: string,
1026
1024
  conversationId: string,
1027
1025
  content: string,
1028
1026
  attachmentIds?: string[],
@@ -1041,14 +1039,14 @@ export class DaemonServer {
1041
1039
  throw new Error('Session is already processing a message');
1042
1040
  }
1043
1041
 
1044
- // Set assistantId AFTER the isProcessing check so a rejected request
1045
- // doesn't mutate the session state visible to an in-flight request.
1046
- session.setAssistantId(assistantId);
1042
+ // Set the session identity AFTER the isProcessing check so a rejected
1043
+ // request doesn't mutate the session state visible to an in-flight request.
1044
+ session.setAssistantId('self');
1047
1045
  session.setChannelCapabilities(resolveChannelCapabilities(sourceChannel));
1048
1046
 
1049
1047
  // Resolve attachment IDs to full attachment data for the session
1050
1048
  const attachments = attachmentIds
1051
- ? attachmentsStore.getAttachmentsByIds(assistantId, attachmentIds).map((a) => ({
1049
+ ? attachmentsStore.getAttachmentsByIds('self', attachmentIds).map((a) => ({
1052
1050
  id: a.id,
1053
1051
  filename: a.originalFilename,
1054
1052
  mimeType: a.mimeType,
@@ -1072,8 +1070,8 @@ export class DaemonServer {
1072
1070
  return new RunOrchestrator({
1073
1071
  getOrCreateSession: (conversationId) =>
1074
1072
  this.getOrCreateSession(conversationId),
1075
- resolveAttachments: (assistantId, attachmentIds) =>
1076
- attachmentsStore.getAttachmentsByIds(assistantId, attachmentIds).map((a) => ({
1073
+ resolveAttachments: (attachmentIds) =>
1074
+ attachmentsStore.getAttachmentsByIds('self', attachmentIds).map((a) => ({
1077
1075
  id: a.id,
1078
1076
  filename: a.originalFilename,
1079
1077
  mimeType: a.mimeType,
@@ -181,6 +181,12 @@ export function createToolExecutor(
181
181
  ctx.sendToClient({ type: 'open_tasks_window' });
182
182
  }
183
183
 
184
+ // Broadcast tasks_changed so connected clients (e.g. macOS Tasks window)
185
+ // auto-refresh when the LLM mutates the task queue via tools
186
+ if ((name === 'task_list_add' || name === 'task_list_update' || name === 'task_list_remove') && !result.isError) {
187
+ broadcastToAllClients?.({ type: 'tasks_changed' });
188
+ }
189
+
184
190
  // Auto-refresh workspace surfaces when app files are edited
185
191
  if ((name === 'app_file_edit' || name === 'app_file_write') && !result.isError) {
186
192
  const appId = input.app_id as string | undefined;
@@ -1422,7 +1422,21 @@ export class Session {
1422
1422
  // the agent loop started, even if post-processing threw.
1423
1423
  if (turnStarted) {
1424
1424
  this.turnCount++;
1425
- await commitTurnChanges(this.workingDir, this.conversationId, this.turnCount);
1425
+ const config = getConfig();
1426
+ const maxWait = config.workspaceGit?.turnCommitMaxWaitMs ?? 4000;
1427
+ const deadlineMs = Date.now() + maxWait;
1428
+ const commitPromise = commitTurnChanges(
1429
+ this.workingDir, this.conversationId, this.turnCount,
1430
+ undefined, // use default commit message provider
1431
+ deadlineMs,
1432
+ );
1433
+ const outcome = await raceWithTimeout(commitPromise, maxWait);
1434
+ if (outcome === 'timed_out') {
1435
+ rlog.warn(
1436
+ { turnNumber: this.turnCount, maxWaitMs: maxWait, conversationId: this.conversationId },
1437
+ 'Turn-boundary commit timed out — continuing without waiting (commit still runs in background)',
1438
+ );
1439
+ }
1426
1440
  }
1427
1441
 
1428
1442
  this.profiler.emitSummary(this.traceEmitter, reqId);
@@ -1649,3 +1663,28 @@ function isToolResultOnlyMessage(message: Message): boolean {
1649
1663
  return message.content.length > 0
1650
1664
  && message.content.every((block) => block.type === 'tool_result');
1651
1665
  }
1666
+
1667
+ /**
1668
+ * Race a promise against a timeout. Returns 'completed' if the promise
1669
+ * resolves/rejects within the budget, or 'timed_out' if the timeout fires
1670
+ * first. The timer is always cleared in `finally` to prevent handle leaks.
1671
+ */
1672
+ async function raceWithTimeout<T>(
1673
+ promise: Promise<T>,
1674
+ timeoutMs: number,
1675
+ ): Promise<'completed' | 'timed_out'> {
1676
+ let timer: ReturnType<typeof setTimeout> | undefined;
1677
+ try {
1678
+ const result = await Promise.race([
1679
+ promise.then(() => 'completed' as const),
1680
+ new Promise<'timed_out'>((resolve) => {
1681
+ timer = setTimeout(() => resolve('timed_out'), timeoutMs);
1682
+ }),
1683
+ ]);
1684
+ return result;
1685
+ } finally {
1686
+ if (timer !== undefined) {
1687
+ clearTimeout(timer);
1688
+ }
1689
+ }
1690
+ }