chapterhouse 0.3.24 → 0.3.25

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.
@@ -383,14 +383,34 @@ app.get("/api/workers/:taskId/events", (req, res) => {
383
383
  ? parseInt(rawLastId.trim(), 10)
384
384
  : undefined;
385
385
  const sendEvent = (event) => {
386
- const payload = {
387
- taskId: event.taskId,
388
- seq: event.seq,
389
- ts: event.ts,
390
- kind: event.kind,
391
- toolName: event.toolName,
392
- summary: event.summary,
393
- };
386
+ let payload;
387
+ if (event.kind === "output_delta") {
388
+ payload = {
389
+ type: "output_delta",
390
+ taskId: event.taskId,
391
+ seq: event.seq,
392
+ text: event.text ?? "",
393
+ };
394
+ }
395
+ else if (event.kind === "task_status") {
396
+ payload = {
397
+ type: "task_status",
398
+ taskId: event.taskId,
399
+ seq: event.seq,
400
+ status: event.status ?? "running",
401
+ summary: event.summary,
402
+ };
403
+ }
404
+ else {
405
+ payload = {
406
+ taskId: event.taskId,
407
+ seq: event.seq,
408
+ ts: event.ts,
409
+ kind: event.kind,
410
+ toolName: event.toolName,
411
+ summary: event.summary,
412
+ };
413
+ }
394
414
  res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
395
415
  };
396
416
  let replayHighSeq = lastSeq;
@@ -427,24 +447,41 @@ app.get("/api/workers/:taskId/events", (req, res) => {
427
447
  const heartbeat = setInterval(() => {
428
448
  res.write(`: keep-alive\n\n`);
429
449
  }, 15_000);
430
- const unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
450
+ let cleaned = false;
451
+ let unsubscribeTaskLog;
452
+ let unsubscribeDestroyed;
453
+ let unsubscribeError;
454
+ const cleanup = () => {
455
+ if (cleaned)
456
+ return;
457
+ cleaned = true;
458
+ clearInterval(heartbeat);
459
+ unsubscribeTaskLog?.();
460
+ unsubscribeDestroyed?.();
461
+ unsubscribeError?.();
462
+ };
463
+ unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
431
464
  sendEvent(event);
465
+ // Close SSE when a terminal task_status event arrives
466
+ if (event.kind === "task_status" && event.status && TERMINAL_TASK_STATUSES.has(event.status)) {
467
+ cleanup();
468
+ res.end();
469
+ }
432
470
  });
433
- const unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
471
+ unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
434
472
  if (event.sessionId === taskId && isTerminal()) {
473
+ cleanup();
435
474
  res.end();
436
475
  }
437
476
  });
438
- const unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
477
+ unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
439
478
  if (event.sessionId === taskId && isTerminal()) {
479
+ cleanup();
440
480
  res.end();
441
481
  }
442
482
  });
443
483
  req.on("close", () => {
444
- clearInterval(heartbeat);
445
- unsubscribeTaskLog();
446
- unsubscribeDestroyed();
447
- unsubscribeError();
484
+ cleanup();
448
485
  });
449
486
  });
450
487
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import readline from "node:readline";
14
14
  import { startApiServer } from "./dist/api/server.js";
15
15
  import { agentEventBus } from "./dist/copilot/agent-event-bus.js";
16
16
  import { initTaskEventLog } from "./dist/copilot/task-event-log.js";
17
- import { appendTaskEvent, getDb } from "./dist/store/db.js";
17
+ import { appendTaskEvent, appendTaskOutputDeltaEvent, appendTaskStatusEvent, getDb } from "./dist/store/db.js";
18
18
 
19
19
  const db = getDb();
20
20
  initTaskEventLog();
@@ -33,12 +33,58 @@ function emitTaskEvent(taskId, kind, toolName, summary) {
33
33
  _seq: event.seq,
34
34
  _ts: event.ts,
35
35
  _summary: event.summary,
36
+ _text: event.text,
37
+ _status: event.status,
36
38
  ...(event.toolName ? { toolName: event.toolName } : {}),
37
39
  },
38
40
  });
39
41
  return event;
40
42
  }
41
43
 
44
+ function emitOutputDelta(taskId, text) {
45
+ const event = appendTaskOutputDeltaEvent(taskId, text);
46
+ if (!event) {
47
+ throw new Error("appendTaskOutputDeltaEvent returned undefined");
48
+ }
49
+ agentEventBus.emit({
50
+ type: "session:tool_call",
51
+ sessionId: taskId,
52
+ payload: {
53
+ toolName: "",
54
+ toolArgs: {},
55
+ _kind: event.kind,
56
+ _seq: event.seq,
57
+ _ts: event.ts,
58
+ _summary: null,
59
+ _text: event.text,
60
+ _status: null,
61
+ },
62
+ });
63
+ return event;
64
+ }
65
+
66
+ function emitStatusEvent(taskId, status, summary) {
67
+ const event = appendTaskStatusEvent(taskId, status, summary ?? null);
68
+ if (!event) {
69
+ throw new Error("appendTaskStatusEvent returned undefined");
70
+ }
71
+ agentEventBus.emit({
72
+ type: "session:tool_call",
73
+ sessionId: taskId,
74
+ payload: {
75
+ toolName: "",
76
+ toolArgs: {},
77
+ _kind: event.kind,
78
+ _seq: event.seq,
79
+ _ts: event.ts,
80
+ _summary: event.summary,
81
+ _text: null,
82
+ _status: event.status,
83
+ },
84
+ });
85
+ return event;
86
+ }
87
+
42
88
  await startApiServer();
43
89
  process.stdout.write("${READY_PREFIX}\\n");
44
90
 
@@ -61,6 +107,18 @@ for await (const line of rl) {
61
107
  event: emitTaskEvent(command.taskId, command.kind, command.toolName ?? null, command.summary ?? null),
62
108
  });
63
109
  break;
110
+ case "appendOutputDelta":
111
+ reply({
112
+ ok: true,
113
+ event: emitOutputDelta(command.taskId, command.text),
114
+ });
115
+ break;
116
+ case "appendStatusEvent":
117
+ reply({
118
+ ok: true,
119
+ event: emitStatusEvent(command.taskId, command.status, command.summary ?? null),
120
+ });
121
+ break;
64
122
  case "finishTask": {
65
123
  db.prepare(
66
124
  "UPDATE agent_tasks SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?"
@@ -333,4 +391,186 @@ test("worker events SSE replays backlog, streams live events, and closes on comp
333
391
  }
334
392
  }, 30_000);
335
393
  });
394
+ test("worker events SSE streams output_delta events in backlog and live", async () => {
395
+ await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
396
+ const taskId = "worker-events-sse-delta-001";
397
+ await sendCommand({
398
+ type: "createTask",
399
+ taskId,
400
+ description: "Stream output deltas",
401
+ status: "running",
402
+ });
403
+ await sendCommand({
404
+ type: "appendOutputDelta",
405
+ taskId,
406
+ text: "Hello ",
407
+ });
408
+ await sendCommand({
409
+ type: "appendOutputDelta",
410
+ taskId,
411
+ text: "world!",
412
+ });
413
+ const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
414
+ headers: {
415
+ Authorization: authHeader,
416
+ Accept: "text/event-stream",
417
+ },
418
+ });
419
+ assert.equal(response.status, 200);
420
+ assert.ok(response.body, "SSE response should have a readable body");
421
+ const reader = createSseReader(response.body);
422
+ try {
423
+ const backlogFrames = await reader.readFrames(2, 2_000);
424
+ assert.equal(backlogFrames.length, 2, "Expected 2 backlog output_delta frames");
425
+ assert.deepEqual(backlogFrames[0]?.data, {
426
+ type: "output_delta",
427
+ taskId,
428
+ seq: 1,
429
+ text: "Hello ",
430
+ });
431
+ assert.deepEqual(backlogFrames[1]?.data, {
432
+ type: "output_delta",
433
+ taskId,
434
+ seq: 2,
435
+ text: "world!",
436
+ });
437
+ assert.equal(backlogFrames[0]?.id, "1");
438
+ assert.equal(backlogFrames[1]?.id, "2");
439
+ // Live output_delta
440
+ await sendCommand({
441
+ type: "appendOutputDelta",
442
+ taskId,
443
+ text: " More text.",
444
+ });
445
+ const liveFrames = await reader.readFrames(1, 2_000);
446
+ assert.equal(liveFrames.length, 1);
447
+ assert.deepEqual(liveFrames[0]?.data, {
448
+ type: "output_delta",
449
+ taskId,
450
+ seq: 3,
451
+ text: " More text.",
452
+ });
453
+ await sendCommand({
454
+ type: "finishTask",
455
+ taskId,
456
+ status: "completed",
457
+ reason: "done",
458
+ });
459
+ assert.equal(await reader.waitForClose(2_000), true);
460
+ }
461
+ finally {
462
+ await reader.cancel();
463
+ }
464
+ }, 30_000);
465
+ });
466
+ test("worker events SSE closes on terminal task_status event", async () => {
467
+ await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
468
+ const taskId = "worker-events-sse-status-001";
469
+ await sendCommand({
470
+ type: "createTask",
471
+ taskId,
472
+ description: "Task status close test",
473
+ status: "running",
474
+ });
475
+ const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
476
+ headers: {
477
+ Authorization: authHeader,
478
+ Accept: "text/event-stream",
479
+ },
480
+ });
481
+ assert.equal(response.status, 200);
482
+ assert.ok(response.body, "SSE response should have a readable body");
483
+ const reader = createSseReader(response.body);
484
+ try {
485
+ // Emit a task_status completed event (which also marks DB terminal)
486
+ await sendCommand({
487
+ type: "appendStatusEvent",
488
+ taskId,
489
+ status: "completed",
490
+ summary: "All done",
491
+ });
492
+ // Also mark in DB so isTerminal() returns true
493
+ await sendCommand({
494
+ type: "finishTask",
495
+ taskId,
496
+ status: "completed",
497
+ reason: "complete",
498
+ });
499
+ const frames = await reader.readFrames(1, 2_000);
500
+ assert.ok(frames.length >= 1, "Expected at least one task_status frame");
501
+ const statusFrame = frames.find((f) => f.data.type === "task_status");
502
+ assert.ok(statusFrame, "Should have received a task_status frame");
503
+ assert.deepEqual(statusFrame?.data, {
504
+ type: "task_status",
505
+ taskId,
506
+ seq: 1,
507
+ status: "completed",
508
+ summary: "All done",
509
+ });
510
+ assert.equal(await reader.waitForClose(2_000), true, "SSE should close on terminal task_status");
511
+ }
512
+ finally {
513
+ await reader.cancel();
514
+ }
515
+ }, 30_000);
516
+ });
517
+ test("worker events SSE Last-Event-ID skips output_delta events before the ID", async () => {
518
+ await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
519
+ const taskId = "worker-events-sse-reconnect-001";
520
+ await sendCommand({
521
+ type: "createTask",
522
+ taskId,
523
+ description: "Reconnect test",
524
+ status: "running",
525
+ });
526
+ await sendCommand({
527
+ type: "appendOutputDelta",
528
+ taskId,
529
+ text: "chunk-1",
530
+ });
531
+ await sendCommand({
532
+ type: "appendOutputDelta",
533
+ taskId,
534
+ text: "chunk-2",
535
+ });
536
+ await sendCommand({
537
+ type: "appendEvent",
538
+ taskId,
539
+ kind: "tool_start",
540
+ toolName: "bash",
541
+ summary: "echo hi",
542
+ });
543
+ // Reconnect after seq 1 — should only get seq 2 and 3
544
+ const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
545
+ headers: {
546
+ Authorization: authHeader,
547
+ Accept: "text/event-stream",
548
+ "Last-Event-ID": "1",
549
+ },
550
+ });
551
+ assert.equal(response.status, 200);
552
+ assert.ok(response.body);
553
+ const reader = createSseReader(response.body);
554
+ try {
555
+ const frames = await reader.readFrames(2, 2_000);
556
+ assert.equal(frames.length, 2, "Expected 2 frames after Last-Event-ID=1");
557
+ assert.equal(frames[0]?.id, "2");
558
+ assert.deepEqual(frames[0]?.data, {
559
+ type: "output_delta",
560
+ taskId,
561
+ seq: 2,
562
+ text: "chunk-2",
563
+ });
564
+ assert.equal(frames[1]?.id, "3");
565
+ // tool_start events use the old format
566
+ const toolFrame = frames[1]?.data;
567
+ assert.equal(toolFrame.kind, "tool_start");
568
+ assert.equal(toolFrame.toolName, "bash");
569
+ }
570
+ finally {
571
+ await reader.cancel();
572
+ await sendCommand({ type: "finishTask", taskId, status: "completed", reason: "done" });
573
+ }
574
+ }, 30_000);
575
+ });
336
576
  //# sourceMappingURL=worker-events-sse.integration.test.js.map
@@ -97,6 +97,8 @@ function emitTaskEvent(taskId, event) {
97
97
  _seq: event.seq,
98
98
  _ts: event.ts,
99
99
  _summary: event.summary,
100
+ _text: event.text ?? null,
101
+ _status: event.status ?? null,
100
102
  },
101
103
  timestamp: new Date(event.ts),
102
104
  });
@@ -69,14 +69,19 @@ export function initTaskEventLog() {
69
69
  if (!taskId)
70
70
  return;
71
71
  const p = event.payload;
72
+ const kind = p._kind === "tool_complete" || p._kind === "output_delta" || p._kind === "task_status"
73
+ ? p._kind
74
+ : "tool_start";
72
75
  const taskEvent = {
73
76
  id: 0, // not a DB row — id is meaningless for ring-buffer entries
74
77
  taskId,
75
78
  seq: p._seq ?? 0,
76
79
  ts: p._ts ?? Date.now(),
77
- kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
80
+ kind,
78
81
  toolName: p.toolName ?? null,
79
82
  summary: p._summary ?? null,
83
+ text: p._text ?? null,
84
+ status: p._status ?? null,
80
85
  };
81
86
  const buf = getOrCreateBuffer(taskId);
82
87
  buf.push(taskEvent);
@@ -197,5 +197,50 @@ describe("task event log — bus-wired", () => {
197
197
  assert.equal(getTaskLogEvents("task-Y").length, 0);
198
198
  clearTaskLog("task-X");
199
199
  });
200
+ it("preserves output_delta payloads from the bus for live subscribers", () => {
201
+ agentEventBus.emit({
202
+ type: "session:tool_call",
203
+ sessionId: "task-output-live",
204
+ payload: {
205
+ toolName: "",
206
+ toolArgs: {},
207
+ _kind: "output_delta",
208
+ _seq: 4,
209
+ _ts: Date.now(),
210
+ _summary: null,
211
+ _text: "chunk-1",
212
+ _status: null,
213
+ },
214
+ timestamp: new Date(),
215
+ });
216
+ const events = getTaskLogEvents("task-output-live");
217
+ assert.equal(events.length, 1);
218
+ assert.equal(events[0].kind, "output_delta");
219
+ assert.equal(events[0].text, "chunk-1");
220
+ clearTaskLog("task-output-live");
221
+ });
222
+ it("preserves task_status payloads from the bus", () => {
223
+ agentEventBus.emit({
224
+ type: "session:tool_call",
225
+ sessionId: "task-status-live",
226
+ payload: {
227
+ toolName: "",
228
+ toolArgs: {},
229
+ _kind: "task_status",
230
+ _seq: 5,
231
+ _ts: Date.now(),
232
+ _summary: "All done",
233
+ _text: null,
234
+ _status: "completed",
235
+ },
236
+ timestamp: new Date(),
237
+ });
238
+ const events = getTaskLogEvents("task-status-live");
239
+ assert.equal(events.length, 1);
240
+ assert.equal(events[0].kind, "task_status");
241
+ assert.equal(events[0].status, "completed");
242
+ assert.equal(events[0].summary, "All done");
243
+ clearTaskLog("task-status-live");
244
+ });
200
245
  });
201
246
  //# sourceMappingURL=task-event-log.test.js.map
@@ -1,11 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { approveAll, defineTool } from "@github/copilot-sdk";
3
- import { getDb } from "../store/db.js";
3
+ import { getDb, appendTaskOutputDeltaEvent, appendTaskStatusEvent, updateTaskResult } from "../store/db.js";
4
4
  import { readdirSync, readFileSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
+ import { agentEventBus } from "./agent-event-bus.js";
9
10
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
10
11
  import { getRouterConfig, updateRouterConfig } from "./router.js";
11
12
  import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
@@ -137,6 +138,34 @@ export function createTools(deps) {
137
138
  // `executeOnSession` finishes.
138
139
  const parentActivity = getCurrentActivityCallback();
139
140
  const childUnsubs = [];
141
+ const emitTaskLogEvent = (taskEvent) => {
142
+ void agentEventBus.emit({
143
+ type: "session:tool_call",
144
+ sessionId: task.taskId,
145
+ payload: {
146
+ toolName: "",
147
+ toolArgs: {},
148
+ _kind: taskEvent.kind,
149
+ _seq: taskEvent.seq,
150
+ _ts: taskEvent.ts,
151
+ _summary: taskEvent.summary,
152
+ _text: taskEvent.text,
153
+ _status: taskEvent.status,
154
+ },
155
+ timestamp: new Date(taskEvent.ts),
156
+ });
157
+ };
158
+ let workerOutput = "";
159
+ childUnsubs.push(session.on("assistant.message_delta", (event) => {
160
+ const delta = typeof event.data.deltaContent === "string" ? event.data.deltaContent : "";
161
+ if (!delta)
162
+ return;
163
+ workerOutput += delta;
164
+ const taskEvent = appendTaskOutputDeltaEvent(task.taskId, delta);
165
+ if (!taskEvent)
166
+ return;
167
+ emitTaskLogEvent(taskEvent);
168
+ }));
140
169
  if (parentActivity) {
141
170
  childUnsubs.push(session.on("assistant.reasoning_delta", (event) => {
142
171
  parentActivity({
@@ -179,15 +208,21 @@ export function createTools(deps) {
179
208
  (async () => {
180
209
  try {
181
210
  const result = await session.sendAndWait({ prompt: taskPrompt }, timeoutMs);
182
- const output = result?.data?.content || "No response";
211
+ const output = workerOutput || result?.data?.content || "No response";
183
212
  completeTask(task.taskId, output);
184
- db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(output.slice(0, 10000), task.taskId);
213
+ updateTaskResult(task.taskId, "completed", output);
214
+ const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
215
+ if (statusEvent)
216
+ emitTaskLogEvent(statusEvent);
185
217
  deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
186
218
  }
187
219
  catch (err) {
188
220
  const msg = err instanceof Error ? err.message : String(err);
189
221
  failTask(task.taskId, msg);
190
- db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(msg, task.taskId);
222
+ updateTaskResult(task.taskId, "error", msg);
223
+ const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
224
+ if (statusEvent)
225
+ emitTaskLogEvent(statusEvent);
191
226
  deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
192
227
  }
193
228
  finally {
package/dist/store/db.js CHANGED
@@ -126,19 +126,45 @@ export function getDb() {
126
126
  if (!taskCols.some((c) => c.name === "prompt")) {
127
127
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN prompt TEXT`);
128
128
  }
129
- // agent_task_events: append-only per-task tool-call activity log for /workers streaming
129
+ // agent_task_events: append-only per-task activity log for /workers streaming
130
130
  db.exec(`
131
131
  CREATE TABLE IF NOT EXISTS agent_task_events (
132
132
  id INTEGER PRIMARY KEY AUTOINCREMENT,
133
133
  task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
134
134
  seq INTEGER NOT NULL,
135
135
  ts INTEGER NOT NULL,
136
- kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete')),
136
+ kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
137
137
  tool_name TEXT,
138
- summary TEXT
138
+ summary TEXT,
139
+ text TEXT,
140
+ status TEXT
139
141
  )
140
142
  `);
141
143
  db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
144
+ // Migrate existing agent_task_events tables that lack text/status columns
145
+ const taskEventCols = db.prepare(`PRAGMA table_info(agent_task_events)`).all();
146
+ if (!taskEventCols.some((c) => c.name === "text") || !taskEventCols.some((c) => c.name === "status")) {
147
+ db.exec(`ALTER TABLE agent_task_events RENAME TO agent_task_events_old`);
148
+ db.exec(`
149
+ CREATE TABLE agent_task_events (
150
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
151
+ task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
152
+ seq INTEGER NOT NULL,
153
+ ts INTEGER NOT NULL,
154
+ kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
155
+ tool_name TEXT,
156
+ summary TEXT,
157
+ text TEXT,
158
+ status TEXT
159
+ )
160
+ `);
161
+ db.exec(`
162
+ INSERT INTO agent_task_events (id, task_id, seq, ts, kind, tool_name, summary)
163
+ SELECT id, task_id, seq, ts, kind, tool_name, summary FROM agent_task_events_old
164
+ `);
165
+ db.exec(`DROP TABLE agent_task_events_old`);
166
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
167
+ }
142
168
  // Migrate: add event_seq column to agent_tasks for monotonic event numbering
143
169
  const taskColsNow = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
144
170
  if (!taskColsNow.some((c) => c.name === 'event_seq')) {
@@ -342,7 +368,7 @@ export function getSessionMessages(sessionKey, limit) {
342
368
  * Uses a transaction so seq is monotonically incremented.
343
369
  * Non-fatal: silently ignores DB errors (task may not exist yet due to race).
344
370
  */
345
- export function appendTaskEvent(taskId, kind, toolName, summary) {
371
+ export function appendTaskEvent(taskId, kind, toolName, summary, text = null, status = null) {
346
372
  const db = getDb();
347
373
  try {
348
374
  return db.transaction(() => {
@@ -352,20 +378,30 @@ export function appendTaskEvent(taskId, kind, toolName, summary) {
352
378
  return undefined;
353
379
  const seq = row.event_seq;
354
380
  const ts = Date.now();
355
- const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary);
356
- return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary };
381
+ const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary, text, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary, text, status);
382
+ return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary, text, status };
357
383
  })();
358
384
  }
359
385
  catch {
360
386
  return undefined;
361
387
  }
362
388
  }
389
+ export function appendTaskOutputDeltaEvent(taskId, text) {
390
+ return appendTaskEvent(taskId, "output_delta", null, null, text, null);
391
+ }
392
+ export function appendTaskStatusEvent(taskId, status, summary = null) {
393
+ return appendTaskEvent(taskId, "task_status", null, summary, null, status);
394
+ }
395
+ export function updateTaskResult(taskId, status, result) {
396
+ const db = getDb();
397
+ db.prepare(`UPDATE agent_tasks SET status = ?, result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(status, result ? result.slice(0, 10000) : null, taskId);
398
+ }
363
399
  /**
364
400
  * Return all events for a task ordered by seq ascending.
365
401
  */
366
402
  export function getTaskEvents(taskId, afterSeq = 0) {
367
403
  const db = getDb();
368
- const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary
404
+ const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary, text, status
369
405
  FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
370
406
  return rows.map((r) => ({
371
407
  id: r.id,
@@ -375,6 +411,8 @@ export function getTaskEvents(taskId, afterSeq = 0) {
375
411
  kind: r.kind,
376
412
  toolName: r.tool_name,
377
413
  summary: r.summary,
414
+ text: r.text,
415
+ status: r.status,
378
416
  }));
379
417
  }
380
418
  export function closeDb() {