@mandipadk7/kavi 0.1.1 → 0.1.3

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/dist/daemon.js CHANGED
@@ -1,12 +1,18 @@
1
+ import fs from "node:fs/promises";
2
+ import net from "node:net";
3
+ import path from "node:path";
1
4
  import { consumeCommands } from "./command-queue.js";
2
5
  import { buildPeerMessages as buildClaudePeerMessages, runClaudeTask } from "./adapters/claude.js";
3
6
  import { buildPeerMessages as buildCodexPeerMessages, runCodexTask } from "./adapters/codex.js";
7
+ import { buildDecisionReplay } from "./adapters/shared.js";
8
+ import { listApprovalRequests, resolveApprovalRequest } from "./approvals.js";
4
9
  import { addDecisionRecord, upsertPathClaim } from "./decision-ledger.js";
5
- import { listWorktreeChangedPaths } from "./git.js";
10
+ import { getWorktreeDiffReview, listWorktreeChangedPaths } from "./git.js";
6
11
  import { nowIso } from "./paths.js";
12
+ import { addReviewReply, addReviewNote, autoResolveReviewNotesForCompletedTask, linkReviewFollowUpTask, reviewNotesForTask, setReviewNoteStatus, updateReviewNote } from "./reviews.js";
7
13
  import { buildAdHocTask, buildKickoffTasks } from "./router.js";
8
- import { loadSessionRecord, recordEvent, saveSessionRecord } from "./session.js";
9
- import { saveTaskArtifact } from "./task-artifacts.js";
14
+ import { loadSessionRecord, readRecentEvents, recordEvent, saveSessionRecord } from "./session.js";
15
+ import { loadTaskArtifact, saveTaskArtifact } from "./task-artifacts.js";
10
16
  export class KaviDaemon {
11
17
  paths;
12
18
  session;
@@ -15,6 +21,10 @@ export class KaviDaemon {
15
21
  processing = false;
16
22
  interval = null;
17
23
  stopResolver = null;
24
+ rpcServer = null;
25
+ clients = new Set();
26
+ subscribers = new Set();
27
+ mutationQueue = Promise.resolve();
18
28
  constructor(paths){
19
29
  this.paths = paths;
20
30
  }
@@ -28,6 +38,7 @@ export class KaviDaemon {
28
38
  await recordEvent(this.paths, this.session.id, "daemon.started", {
29
39
  daemonPid: process.pid
30
40
  });
41
+ await this.startRpcServer();
31
42
  this.running = true;
32
43
  void this.tick();
33
44
  this.interval = setInterval(()=>{
@@ -37,6 +48,636 @@ export class KaviDaemon {
37
48
  this.stopResolver = resolve;
38
49
  });
39
50
  }
51
+ async startRpcServer() {
52
+ await fs.mkdir(path.dirname(this.paths.socketPath), {
53
+ recursive: true
54
+ });
55
+ await fs.rm(this.paths.socketPath, {
56
+ force: true
57
+ }).catch(()=>{});
58
+ this.rpcServer = net.createServer((socket)=>{
59
+ this.clients.add(socket);
60
+ socket.setEncoding("utf8");
61
+ let buffer = "";
62
+ socket.on("data", (chunk)=>{
63
+ buffer += chunk;
64
+ while(true){
65
+ const newlineIndex = buffer.indexOf("\n");
66
+ if (newlineIndex === -1) {
67
+ return;
68
+ }
69
+ const line = buffer.slice(0, newlineIndex).trim();
70
+ buffer = buffer.slice(newlineIndex + 1);
71
+ if (!line) {
72
+ continue;
73
+ }
74
+ let request;
75
+ try {
76
+ request = JSON.parse(line);
77
+ } catch (error) {
78
+ this.writeRpc(socket, {
79
+ id: "parse-error",
80
+ error: {
81
+ message: error instanceof Error ? error.message : "Unable to parse RPC payload."
82
+ }
83
+ });
84
+ continue;
85
+ }
86
+ void this.handleRpcRequest(socket, request);
87
+ }
88
+ });
89
+ const cleanup = ()=>{
90
+ this.clients.delete(socket);
91
+ this.subscribers.delete(socket);
92
+ };
93
+ socket.on("error", cleanup);
94
+ socket.on("close", cleanup);
95
+ });
96
+ await new Promise((resolve, reject)=>{
97
+ this.rpcServer?.once("error", reject);
98
+ this.rpcServer?.listen(this.paths.socketPath, ()=>{
99
+ this.rpcServer?.off("error", reject);
100
+ resolve();
101
+ });
102
+ });
103
+ }
104
+ writeRpc(socket, response, onWritten) {
105
+ socket.write(`${JSON.stringify(response)}\n`, onWritten);
106
+ }
107
+ writeNotification(socket, notification) {
108
+ socket.write(`${JSON.stringify(notification)}\n`);
109
+ }
110
+ async handleRpcRequest(socket, request) {
111
+ try {
112
+ const dispatch = await this.dispatchRpc(socket, request.method, request.params ?? {});
113
+ this.writeRpc(socket, {
114
+ id: request.id,
115
+ result: dispatch.result
116
+ }, ()=>{
117
+ if (dispatch.shutdownAfterResponse) {
118
+ void this.stopFromRpc();
119
+ }
120
+ });
121
+ } catch (error) {
122
+ this.writeRpc(socket, {
123
+ id: request.id,
124
+ error: {
125
+ message: error instanceof Error ? error.message : String(error)
126
+ }
127
+ });
128
+ }
129
+ }
130
+ async dispatchRpc(socket, method, params) {
131
+ switch(method){
132
+ case "ping":
133
+ return {
134
+ result: {
135
+ ok: true,
136
+ sessionId: this.session.id
137
+ }
138
+ };
139
+ case "snapshot":
140
+ return {
141
+ result: await this.buildSnapshot()
142
+ };
143
+ case "subscribe":
144
+ this.subscribers.add(socket);
145
+ return {
146
+ result: await this.buildSnapshot()
147
+ };
148
+ case "kickoff":
149
+ await this.kickoffFromRpc(params);
150
+ return {
151
+ result: {
152
+ ok: true
153
+ }
154
+ };
155
+ case "enqueueTask":
156
+ await this.enqueueRpcTask(params);
157
+ return {
158
+ result: {
159
+ ok: true
160
+ }
161
+ };
162
+ case "shutdown":
163
+ return {
164
+ result: {
165
+ ok: true
166
+ },
167
+ shutdownAfterResponse: true
168
+ };
169
+ case "resolveApproval":
170
+ await this.resolveApprovalFromRpc(params);
171
+ return {
172
+ result: {
173
+ ok: true
174
+ }
175
+ };
176
+ case "taskArtifact":
177
+ return {
178
+ result: await this.getTaskArtifactFromRpc(params)
179
+ };
180
+ case "events":
181
+ return {
182
+ result: await this.getEventsFromRpc(params)
183
+ };
184
+ case "worktreeDiff":
185
+ return {
186
+ result: await this.getWorktreeDiffFromRpc(params)
187
+ };
188
+ case "notifyExternalUpdate":
189
+ await this.publishSnapshot(typeof params.reason === "string" && params.reason.trim() ? params.reason.trim() : "external.update");
190
+ return {
191
+ result: {
192
+ ok: true
193
+ }
194
+ };
195
+ case "addReviewNote":
196
+ await this.addReviewNoteFromRpc(params);
197
+ return {
198
+ result: {
199
+ ok: true
200
+ }
201
+ };
202
+ case "updateReviewNote":
203
+ await this.updateReviewNoteFromRpc(params);
204
+ return {
205
+ result: {
206
+ ok: true
207
+ }
208
+ };
209
+ case "addReviewReply":
210
+ await this.addReviewReplyFromRpc(params);
211
+ return {
212
+ result: {
213
+ ok: true
214
+ }
215
+ };
216
+ case "setReviewNoteStatus":
217
+ await this.setReviewNoteStatusFromRpc(params);
218
+ return {
219
+ result: {
220
+ ok: true
221
+ }
222
+ };
223
+ case "enqueueReviewFollowUp":
224
+ await this.enqueueReviewFollowUpFromRpc(params);
225
+ return {
226
+ result: {
227
+ ok: true
228
+ }
229
+ };
230
+ default:
231
+ throw new Error(`Unknown RPC method: ${method}`);
232
+ }
233
+ }
234
+ async buildSnapshot() {
235
+ const session = await loadSessionRecord(this.paths);
236
+ const events = await readRecentEvents(this.paths, 30);
237
+ const approvals = await listApprovalRequests(this.paths, {
238
+ includeResolved: true
239
+ });
240
+ const worktreeDiffs = await Promise.all(session.worktrees.map(async (worktree)=>({
241
+ agent: worktree.agent,
242
+ paths: await listWorktreeChangedPaths(worktree.path, session.baseCommit).catch(()=>[])
243
+ })));
244
+ return {
245
+ session,
246
+ events,
247
+ approvals,
248
+ worktreeDiffs
249
+ };
250
+ }
251
+ async runMutation(fn) {
252
+ const previous = this.mutationQueue;
253
+ let release = ()=>{};
254
+ this.mutationQueue = new Promise((resolve)=>{
255
+ release = resolve;
256
+ });
257
+ await previous;
258
+ try {
259
+ return await fn();
260
+ } finally{
261
+ release();
262
+ }
263
+ }
264
+ async enqueueRpcTask(params) {
265
+ await this.runMutation(async ()=>{
266
+ const prompt = typeof params.prompt === "string" ? params.prompt : "";
267
+ if (!prompt.trim()) {
268
+ throw new Error("enqueueTask requires a prompt.");
269
+ }
270
+ const owner = params.owner === "claude" ? "claude" : "codex";
271
+ const commandId = `rpc-${Date.now()}`;
272
+ const taskId = `task-${commandId}`;
273
+ const task = buildAdHocTask(owner, prompt, taskId, {
274
+ routeReason: typeof params.routeReason === "string" ? params.routeReason : null,
275
+ claimedPaths: Array.isArray(params.claimedPaths) ? params.claimedPaths.map((item)=>String(item)) : []
276
+ });
277
+ this.session.tasks.push(task);
278
+ addDecisionRecord(this.session, {
279
+ kind: "route",
280
+ agent: owner,
281
+ taskId,
282
+ summary: `Routed task to ${owner}`,
283
+ detail: typeof params.routeReason === "string" ? params.routeReason : `Task enqueued for ${owner}.`,
284
+ metadata: {
285
+ strategy: typeof params.routeStrategy === "string" ? params.routeStrategy : "unknown",
286
+ confidence: typeof params.routeConfidence === "number" ? params.routeConfidence : null,
287
+ claimedPaths: task.claimedPaths
288
+ }
289
+ });
290
+ upsertPathClaim(this.session, {
291
+ taskId,
292
+ agent: owner,
293
+ source: "route",
294
+ paths: task.claimedPaths,
295
+ note: task.routeReason
296
+ });
297
+ await saveSessionRecord(this.paths, this.session);
298
+ await recordEvent(this.paths, this.session.id, "task.enqueued", {
299
+ owner,
300
+ via: "rpc"
301
+ });
302
+ await this.publishSnapshot("task.enqueued");
303
+ });
304
+ }
305
+ async kickoffFromRpc(params) {
306
+ await this.runMutation(async ()=>{
307
+ const prompt = typeof params.prompt === "string" ? params.prompt : "";
308
+ if (!prompt.trim()) {
309
+ throw new Error("kickoff requires a prompt.");
310
+ }
311
+ this.session.goal = prompt;
312
+ this.session.tasks.push(...buildKickoffTasks(prompt));
313
+ await saveSessionRecord(this.paths, this.session);
314
+ await recordEvent(this.paths, this.session.id, "tasks.kickoff_enqueued", {
315
+ count: 2,
316
+ via: "rpc"
317
+ });
318
+ await this.publishSnapshot("tasks.kickoff_enqueued");
319
+ });
320
+ }
321
+ async stopFromRpc() {
322
+ await this.runMutation(async ()=>{
323
+ this.session.status = "stopped";
324
+ this.running = false;
325
+ this.session.daemonHeartbeatAt = new Date().toISOString();
326
+ await saveSessionRecord(this.paths, this.session);
327
+ await recordEvent(this.paths, this.session.id, "daemon.stopped", {
328
+ via: "rpc"
329
+ });
330
+ if (this.interval) {
331
+ clearInterval(this.interval);
332
+ this.interval = null;
333
+ }
334
+ await this.closeRpcServer();
335
+ this.stopResolver?.();
336
+ });
337
+ }
338
+ async resolveApprovalFromRpc(params) {
339
+ await this.runMutation(async ()=>{
340
+ const requestId = typeof params.requestId === "string" ? params.requestId : "";
341
+ const decision = params.decision === "deny" ? "deny" : "allow";
342
+ const remember = params.remember === true;
343
+ if (!requestId) {
344
+ throw new Error("resolveApproval requires a requestId.");
345
+ }
346
+ const request = await resolveApprovalRequest(this.paths, requestId, decision, remember);
347
+ addDecisionRecord(this.session, {
348
+ kind: "approval",
349
+ agent: request.agent,
350
+ summary: `${decision === "allow" ? "Approved" : "Denied"} ${request.toolName}`,
351
+ detail: request.summary,
352
+ metadata: {
353
+ requestId: request.id,
354
+ remember,
355
+ toolName: request.toolName
356
+ }
357
+ });
358
+ await saveSessionRecord(this.paths, this.session);
359
+ await recordEvent(this.paths, this.session.id, "approval.resolved", {
360
+ requestId: request.id,
361
+ decision,
362
+ remember,
363
+ agent: request.agent,
364
+ toolName: request.toolName,
365
+ via: "rpc"
366
+ });
367
+ await this.publishSnapshot("approval.resolved");
368
+ });
369
+ }
370
+ async getTaskArtifactFromRpc(params) {
371
+ const taskId = typeof params.taskId === "string" ? params.taskId : "";
372
+ if (!taskId) {
373
+ throw new Error("taskArtifact requires a taskId.");
374
+ }
375
+ return await loadTaskArtifact(this.paths, taskId);
376
+ }
377
+ async getEventsFromRpc(params) {
378
+ const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? params.limit : 20;
379
+ return await readRecentEvents(this.paths, limit);
380
+ }
381
+ async getWorktreeDiffFromRpc(params) {
382
+ const agent = params.agent === "claude" ? "claude" : "codex";
383
+ const filePath = typeof params.filePath === "string" ? params.filePath : null;
384
+ const worktree = this.session.worktrees.find((item)=>item.agent === agent);
385
+ if (!worktree) {
386
+ throw new Error(`No managed worktree found for ${agent}.`);
387
+ }
388
+ return await getWorktreeDiffReview(agent, worktree.path, this.session.baseCommit, filePath);
389
+ }
390
+ async addReviewNoteFromRpc(params) {
391
+ await this.runMutation(async ()=>{
392
+ const agent = params.agent === "claude" ? "claude" : "codex";
393
+ const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
394
+ const disposition = params.disposition === "approve" || params.disposition === "concern" || params.disposition === "question" ? params.disposition : "note";
395
+ const body = typeof params.body === "string" ? params.body.trim() : "";
396
+ const taskId = typeof params.taskId === "string" ? params.taskId : null;
397
+ const hunkIndex = typeof params.hunkIndex === "number" ? params.hunkIndex : null;
398
+ const hunkHeader = typeof params.hunkHeader === "string" ? params.hunkHeader : null;
399
+ if (!filePath) {
400
+ throw new Error("addReviewNote requires a filePath.");
401
+ }
402
+ if (!body) {
403
+ throw new Error("addReviewNote requires a note body.");
404
+ }
405
+ const note = addReviewNote(this.session, {
406
+ agent,
407
+ taskId,
408
+ filePath,
409
+ hunkIndex,
410
+ hunkHeader,
411
+ disposition,
412
+ body
413
+ });
414
+ addDecisionRecord(this.session, {
415
+ kind: "review",
416
+ agent,
417
+ taskId,
418
+ summary: note.summary,
419
+ detail: note.body,
420
+ metadata: {
421
+ filePath: note.filePath,
422
+ hunkIndex: note.hunkIndex,
423
+ hunkHeader: note.hunkHeader,
424
+ disposition: note.disposition,
425
+ reviewNoteId: note.id
426
+ }
427
+ });
428
+ await saveSessionRecord(this.paths, this.session);
429
+ if (taskId) {
430
+ await this.refreshTaskArtifactReviewNotes(taskId);
431
+ }
432
+ await recordEvent(this.paths, this.session.id, "review.note_added", {
433
+ reviewNoteId: note.id,
434
+ agent: note.agent,
435
+ taskId: note.taskId,
436
+ filePath: note.filePath,
437
+ hunkIndex: note.hunkIndex,
438
+ disposition: note.disposition
439
+ });
440
+ await this.publishSnapshot("review.note_added");
441
+ });
442
+ }
443
+ async updateReviewNoteFromRpc(params) {
444
+ await this.runMutation(async ()=>{
445
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
446
+ const body = typeof params.body === "string" ? params.body.trim() : "";
447
+ if (!noteId) {
448
+ throw new Error("updateReviewNote requires a noteId.");
449
+ }
450
+ if (!body) {
451
+ throw new Error("updateReviewNote requires a note body.");
452
+ }
453
+ const note = updateReviewNote(this.session, noteId, {
454
+ body
455
+ });
456
+ if (!note) {
457
+ throw new Error(`Review note ${noteId} was not found.`);
458
+ }
459
+ addDecisionRecord(this.session, {
460
+ kind: "review",
461
+ agent: note.agent,
462
+ taskId: note.taskId,
463
+ summary: `Edited review note ${note.id}`,
464
+ detail: note.body,
465
+ metadata: {
466
+ reviewNoteId: note.id,
467
+ filePath: note.filePath,
468
+ hunkIndex: note.hunkIndex
469
+ }
470
+ });
471
+ await saveSessionRecord(this.paths, this.session);
472
+ if (note.taskId) {
473
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
474
+ }
475
+ await recordEvent(this.paths, this.session.id, "review.note_updated", {
476
+ reviewNoteId: note.id,
477
+ taskId: note.taskId,
478
+ agent: note.agent,
479
+ filePath: note.filePath
480
+ });
481
+ await this.publishSnapshot("review.note_updated");
482
+ });
483
+ }
484
+ async addReviewReplyFromRpc(params) {
485
+ await this.runMutation(async ()=>{
486
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
487
+ const body = typeof params.body === "string" ? params.body.trim() : "";
488
+ if (!noteId) {
489
+ throw new Error("addReviewReply requires a noteId.");
490
+ }
491
+ if (!body) {
492
+ throw new Error("addReviewReply requires a reply body.");
493
+ }
494
+ const note = addReviewReply(this.session, noteId, body);
495
+ if (!note) {
496
+ throw new Error(`Review note ${noteId} was not found.`);
497
+ }
498
+ addDecisionRecord(this.session, {
499
+ kind: "review",
500
+ agent: note.agent,
501
+ taskId: note.taskId,
502
+ summary: `Replied to review note ${note.id}`,
503
+ detail: body,
504
+ metadata: {
505
+ reviewNoteId: note.id,
506
+ filePath: note.filePath,
507
+ hunkIndex: note.hunkIndex,
508
+ replyCount: note.comments.length
509
+ }
510
+ });
511
+ await saveSessionRecord(this.paths, this.session);
512
+ if (note.taskId) {
513
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
514
+ }
515
+ await recordEvent(this.paths, this.session.id, "review.reply_added", {
516
+ reviewNoteId: note.id,
517
+ taskId: note.taskId,
518
+ agent: note.agent,
519
+ filePath: note.filePath,
520
+ replyCount: note.comments.length
521
+ });
522
+ await this.publishSnapshot("review.reply_added");
523
+ });
524
+ }
525
+ async setReviewNoteStatusFromRpc(params) {
526
+ await this.runMutation(async ()=>{
527
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
528
+ const status = params.status === "resolved" ? "resolved" : "open";
529
+ if (!noteId) {
530
+ throw new Error("setReviewNoteStatus requires a noteId.");
531
+ }
532
+ const note = setReviewNoteStatus(this.session, noteId, status);
533
+ if (!note) {
534
+ throw new Error(`Review note ${noteId} was not found.`);
535
+ }
536
+ addDecisionRecord(this.session, {
537
+ kind: "review",
538
+ agent: note.agent,
539
+ taskId: note.taskId,
540
+ summary: `${status === "resolved" ? "Resolved" : "Reopened"} review note ${note.id}`,
541
+ detail: note.summary,
542
+ metadata: {
543
+ reviewNoteId: note.id,
544
+ status,
545
+ filePath: note.filePath,
546
+ hunkIndex: note.hunkIndex
547
+ }
548
+ });
549
+ await saveSessionRecord(this.paths, this.session);
550
+ if (note.taskId) {
551
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
552
+ }
553
+ await recordEvent(this.paths, this.session.id, "review.note_status_changed", {
554
+ reviewNoteId: note.id,
555
+ taskId: note.taskId,
556
+ agent: note.agent,
557
+ filePath: note.filePath,
558
+ status: note.status
559
+ });
560
+ await this.publishSnapshot("review.note_status_changed");
561
+ });
562
+ }
563
+ async enqueueReviewFollowUpFromRpc(params) {
564
+ await this.runMutation(async ()=>{
565
+ const noteId = typeof params.noteId === "string" ? params.noteId : "";
566
+ const owner = params.owner === "claude" ? "claude" : "codex";
567
+ const mode = params.mode === "handoff" ? "handoff" : "fix";
568
+ if (!noteId) {
569
+ throw new Error("enqueueReviewFollowUp requires a noteId.");
570
+ }
571
+ const note = this.session.reviewNotes.find((item)=>item.id === noteId) ?? null;
572
+ if (!note) {
573
+ throw new Error(`Review note ${noteId} was not found.`);
574
+ }
575
+ const taskId = `task-review-${Date.now()}`;
576
+ const scope = note.hunkHeader ? `${note.filePath} ${note.hunkHeader}` : note.filePath;
577
+ const promptLines = [
578
+ `${mode === "handoff" ? "Handle a review handoff" : "Address a review note"} for ${scope}.`,
579
+ `Disposition: ${note.disposition}.`,
580
+ `Review note: ${note.body}`
581
+ ];
582
+ if (note.taskId) {
583
+ promptLines.push(`Originating task: ${note.taskId}.`);
584
+ }
585
+ if (mode === "handoff") {
586
+ promptLines.push(`This was handed off from ${note.agent} work to ${owner}.`);
587
+ }
588
+ promptLines.push(`Focus the change in ${note.filePath} and update the managed worktree accordingly.`);
589
+ const task = buildAdHocTask(owner, promptLines.join(" "), taskId, {
590
+ routeReason: mode === "handoff" ? `Operator handed off review note ${note.id} to ${owner}.` : `Operator created a follow-up task from review note ${note.id}.`,
591
+ claimedPaths: [
592
+ note.filePath
593
+ ]
594
+ });
595
+ this.session.tasks.push(task);
596
+ linkReviewFollowUpTask(this.session, note.id, taskId);
597
+ upsertPathClaim(this.session, {
598
+ taskId,
599
+ agent: owner,
600
+ source: "route",
601
+ paths: task.claimedPaths,
602
+ note: task.routeReason
603
+ });
604
+ addDecisionRecord(this.session, {
605
+ kind: "review",
606
+ agent: owner,
607
+ taskId,
608
+ summary: `Queued ${mode} follow-up from review note ${note.id}`,
609
+ detail: note.body,
610
+ metadata: {
611
+ reviewNoteId: note.id,
612
+ owner,
613
+ mode,
614
+ filePath: note.filePath,
615
+ sourceAgent: note.agent
616
+ }
617
+ });
618
+ await saveSessionRecord(this.paths, this.session);
619
+ if (note.taskId) {
620
+ await this.refreshTaskArtifactReviewNotes(note.taskId);
621
+ }
622
+ await recordEvent(this.paths, this.session.id, "review.followup_queued", {
623
+ reviewNoteId: note.id,
624
+ followUpTaskId: taskId,
625
+ owner,
626
+ mode,
627
+ filePath: note.filePath
628
+ });
629
+ await this.publishSnapshot("review.followup_queued");
630
+ });
631
+ }
632
+ async refreshTaskArtifactReviewNotes(taskId) {
633
+ const artifact = await loadTaskArtifact(this.paths, taskId);
634
+ if (!artifact) {
635
+ return;
636
+ }
637
+ artifact.reviewNotes = reviewNotesForTask(this.session, taskId);
638
+ await saveTaskArtifact(this.paths, artifact);
639
+ }
640
+ async refreshReviewArtifactsForNotes(notes) {
641
+ const taskIds = [
642
+ ...new Set(notes.map((note)=>note.taskId).filter((value)=>Boolean(value)))
643
+ ];
644
+ for (const taskId of taskIds){
645
+ await this.refreshTaskArtifactReviewNotes(taskId);
646
+ }
647
+ }
648
+ async publishSnapshot(reason) {
649
+ if (this.subscribers.size === 0) {
650
+ return;
651
+ }
652
+ const snapshot = await this.buildSnapshot();
653
+ const notification = {
654
+ method: "snapshot.updated",
655
+ params: {
656
+ reason,
657
+ snapshot
658
+ }
659
+ };
660
+ for (const subscriber of this.subscribers){
661
+ this.writeNotification(subscriber, notification);
662
+ }
663
+ }
664
+ async closeRpcServer() {
665
+ const server = this.rpcServer;
666
+ this.rpcServer = null;
667
+ for (const client of this.clients){
668
+ client.end();
669
+ }
670
+ this.clients.clear();
671
+ this.subscribers.clear();
672
+ if (server) {
673
+ await new Promise((resolve)=>{
674
+ server.close(()=>resolve());
675
+ });
676
+ }
677
+ await fs.rm(this.paths.socketPath, {
678
+ force: true
679
+ }).catch(()=>{});
680
+ }
40
681
  async tick() {
41
682
  if (!this.running || this.processing) {
42
683
  return;
@@ -50,6 +691,7 @@ export class KaviDaemon {
50
691
  clearInterval(this.interval);
51
692
  this.interval = null;
52
693
  }
694
+ await this.closeRpcServer();
53
695
  this.stopResolver?.();
54
696
  return;
55
697
  }
@@ -64,6 +706,7 @@ export class KaviDaemon {
64
706
  await recordEvent(this.paths, this.session.id, "tasks.kickoff_created", {
65
707
  count: this.session.tasks.length
66
708
  });
709
+ await this.publishSnapshot("tasks.kickoff_created");
67
710
  }
68
711
  const pending = this.session.tasks.filter((task)=>task.status === "pending");
69
712
  for (const task of pending){
@@ -89,6 +732,7 @@ export class KaviDaemon {
89
732
  clearInterval(this.interval);
90
733
  this.interval = null;
91
734
  }
735
+ await this.closeRpcServer();
92
736
  this.stopResolver?.();
93
737
  return;
94
738
  }
@@ -99,6 +743,7 @@ export class KaviDaemon {
99
743
  await recordEvent(this.paths, this.session.id, "tasks.kickoff_enqueued", {
100
744
  count: 2
101
745
  });
746
+ await this.publishSnapshot("tasks.kickoff_enqueued");
102
747
  continue;
103
748
  }
104
749
  if (command.type === "enqueue" && typeof command.payload.prompt === "string") {
@@ -132,6 +777,7 @@ export class KaviDaemon {
132
777
  await recordEvent(this.paths, this.session.id, "task.enqueued", {
133
778
  owner
134
779
  });
780
+ await this.publishSnapshot("task.enqueued");
135
781
  }
136
782
  }
137
783
  }
@@ -144,6 +790,7 @@ export class KaviDaemon {
144
790
  taskId: task.id,
145
791
  owner: task.owner
146
792
  });
793
+ await this.publishSnapshot("task.started");
147
794
  try {
148
795
  let envelope;
149
796
  let peerMessages;
@@ -159,7 +806,7 @@ export class KaviDaemon {
159
806
  envelope = result.envelope;
160
807
  rawOutput = result.raw;
161
808
  peerMessages = buildClaudePeerMessages(result.envelope, "claude", task.id);
162
- await this.markAgent("claude", result.envelope.summary, 0, `${this.session.id}-claude`);
809
+ await this.markAgent("claude", result.envelope.summary, 0, result.sessionId);
163
810
  } else {
164
811
  throw new Error(`Unsupported task owner ${task.owner}.`);
165
812
  }
@@ -192,8 +839,25 @@ export class KaviDaemon {
192
839
  claimedPaths: task.claimedPaths
193
840
  }
194
841
  });
842
+ const autoResolvedNotes = autoResolveReviewNotesForCompletedTask(this.session, task.id);
843
+ for (const note of autoResolvedNotes){
844
+ addDecisionRecord(this.session, {
845
+ kind: "review",
846
+ agent: note.agent,
847
+ taskId: note.taskId,
848
+ summary: `Auto-resolved review note ${note.id}`,
849
+ detail: `Closed because linked follow-up task ${task.id} completed successfully.`,
850
+ metadata: {
851
+ reviewNoteId: note.id,
852
+ filePath: note.filePath,
853
+ followUpTaskId: task.id,
854
+ reason: "follow-up-task-completed"
855
+ }
856
+ });
857
+ }
195
858
  this.session.peerMessages.push(...peerMessages);
196
859
  await saveSessionRecord(this.paths, this.session);
860
+ const decisionReplay = buildDecisionReplay(this.session, task, task.owner === "claude" ? "claude" : "codex");
197
861
  await saveTaskArtifact(this.paths, {
198
862
  taskId: task.id,
199
863
  sessionId: this.session.id,
@@ -201,18 +865,33 @@ export class KaviDaemon {
201
865
  owner: task.owner,
202
866
  status: task.status,
203
867
  summary: task.summary,
868
+ routeReason: task.routeReason,
869
+ claimedPaths: task.claimedPaths,
870
+ decisionReplay,
204
871
  rawOutput,
205
872
  error: null,
206
873
  envelope,
874
+ reviewNotes: reviewNotesForTask(this.session, task.id),
207
875
  startedAt,
208
876
  finishedAt: task.updatedAt
209
877
  });
878
+ await this.refreshReviewArtifactsForNotes(autoResolvedNotes);
210
879
  await recordEvent(this.paths, this.session.id, "task.completed", {
211
880
  taskId: task.id,
212
881
  owner: task.owner,
213
882
  status: task.status,
214
883
  peerMessages: peerMessages.length
215
884
  });
885
+ for (const note of autoResolvedNotes){
886
+ await recordEvent(this.paths, this.session.id, "review.note_auto_resolved", {
887
+ reviewNoteId: note.id,
888
+ taskId: note.taskId,
889
+ followUpTaskId: task.id,
890
+ agent: note.agent,
891
+ filePath: note.filePath
892
+ });
893
+ }
894
+ await this.publishSnapshot("task.completed");
216
895
  } catch (error) {
217
896
  task.status = "failed";
218
897
  task.summary = error instanceof Error ? error.message : String(error);
@@ -245,6 +924,7 @@ export class KaviDaemon {
245
924
  });
246
925
  await this.markAgent(task.owner, task.summary, 1, task.owner === "claude" ? `${this.session.id}-claude` : null);
247
926
  await saveSessionRecord(this.paths, this.session);
927
+ const decisionReplay = buildDecisionReplay(this.session, task, task.owner === "claude" ? "claude" : "codex");
248
928
  await saveTaskArtifact(this.paths, {
249
929
  taskId: task.id,
250
930
  sessionId: this.session.id,
@@ -252,9 +932,13 @@ export class KaviDaemon {
252
932
  owner: task.owner,
253
933
  status: task.status,
254
934
  summary: task.summary,
935
+ routeReason: task.routeReason,
936
+ claimedPaths: task.claimedPaths,
937
+ decisionReplay,
255
938
  rawOutput: null,
256
939
  error: task.summary,
257
940
  envelope: null,
941
+ reviewNotes: reviewNotesForTask(this.session, task.id),
258
942
  startedAt,
259
943
  finishedAt: task.updatedAt
260
944
  });
@@ -263,6 +947,7 @@ export class KaviDaemon {
263
947
  owner: task.owner,
264
948
  error: task.summary
265
949
  });
950
+ await this.publishSnapshot("task.failed");
266
951
  }
267
952
  }
268
953
  async markAgent(agent, summary, exitCode, sessionId) {