@mandipadk7/kavi 0.1.1 → 0.1.2

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,17 @@
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";
7
12
  import { buildAdHocTask, buildKickoffTasks } from "./router.js";
8
- import { loadSessionRecord, recordEvent, saveSessionRecord } from "./session.js";
9
- import { saveTaskArtifact } from "./task-artifacts.js";
13
+ import { loadSessionRecord, readRecentEvents, recordEvent, saveSessionRecord } from "./session.js";
14
+ import { loadTaskArtifact, saveTaskArtifact } from "./task-artifacts.js";
10
15
  export class KaviDaemon {
11
16
  paths;
12
17
  session;
@@ -15,6 +20,10 @@ export class KaviDaemon {
15
20
  processing = false;
16
21
  interval = null;
17
22
  stopResolver = null;
23
+ rpcServer = null;
24
+ clients = new Set();
25
+ subscribers = new Set();
26
+ mutationQueue = Promise.resolve();
18
27
  constructor(paths){
19
28
  this.paths = paths;
20
29
  }
@@ -28,6 +37,7 @@ export class KaviDaemon {
28
37
  await recordEvent(this.paths, this.session.id, "daemon.started", {
29
38
  daemonPid: process.pid
30
39
  });
40
+ await this.startRpcServer();
31
41
  this.running = true;
32
42
  void this.tick();
33
43
  this.interval = setInterval(()=>{
@@ -37,6 +47,343 @@ export class KaviDaemon {
37
47
  this.stopResolver = resolve;
38
48
  });
39
49
  }
50
+ async startRpcServer() {
51
+ await fs.mkdir(path.dirname(this.paths.socketPath), {
52
+ recursive: true
53
+ });
54
+ await fs.rm(this.paths.socketPath, {
55
+ force: true
56
+ }).catch(()=>{});
57
+ this.rpcServer = net.createServer((socket)=>{
58
+ this.clients.add(socket);
59
+ socket.setEncoding("utf8");
60
+ let buffer = "";
61
+ socket.on("data", (chunk)=>{
62
+ buffer += chunk;
63
+ while(true){
64
+ const newlineIndex = buffer.indexOf("\n");
65
+ if (newlineIndex === -1) {
66
+ return;
67
+ }
68
+ const line = buffer.slice(0, newlineIndex).trim();
69
+ buffer = buffer.slice(newlineIndex + 1);
70
+ if (!line) {
71
+ continue;
72
+ }
73
+ let request;
74
+ try {
75
+ request = JSON.parse(line);
76
+ } catch (error) {
77
+ this.writeRpc(socket, {
78
+ id: "parse-error",
79
+ error: {
80
+ message: error instanceof Error ? error.message : "Unable to parse RPC payload."
81
+ }
82
+ });
83
+ continue;
84
+ }
85
+ void this.handleRpcRequest(socket, request);
86
+ }
87
+ });
88
+ const cleanup = ()=>{
89
+ this.clients.delete(socket);
90
+ this.subscribers.delete(socket);
91
+ };
92
+ socket.on("error", cleanup);
93
+ socket.on("close", cleanup);
94
+ });
95
+ await new Promise((resolve, reject)=>{
96
+ this.rpcServer?.once("error", reject);
97
+ this.rpcServer?.listen(this.paths.socketPath, ()=>{
98
+ this.rpcServer?.off("error", reject);
99
+ resolve();
100
+ });
101
+ });
102
+ }
103
+ writeRpc(socket, response, onWritten) {
104
+ socket.write(`${JSON.stringify(response)}\n`, onWritten);
105
+ }
106
+ writeNotification(socket, notification) {
107
+ socket.write(`${JSON.stringify(notification)}\n`);
108
+ }
109
+ async handleRpcRequest(socket, request) {
110
+ try {
111
+ const dispatch = await this.dispatchRpc(socket, request.method, request.params ?? {});
112
+ this.writeRpc(socket, {
113
+ id: request.id,
114
+ result: dispatch.result
115
+ }, ()=>{
116
+ if (dispatch.shutdownAfterResponse) {
117
+ void this.stopFromRpc();
118
+ }
119
+ });
120
+ } catch (error) {
121
+ this.writeRpc(socket, {
122
+ id: request.id,
123
+ error: {
124
+ message: error instanceof Error ? error.message : String(error)
125
+ }
126
+ });
127
+ }
128
+ }
129
+ async dispatchRpc(socket, method, params) {
130
+ switch(method){
131
+ case "ping":
132
+ return {
133
+ result: {
134
+ ok: true,
135
+ sessionId: this.session.id
136
+ }
137
+ };
138
+ case "snapshot":
139
+ return {
140
+ result: await this.buildSnapshot()
141
+ };
142
+ case "subscribe":
143
+ this.subscribers.add(socket);
144
+ return {
145
+ result: await this.buildSnapshot()
146
+ };
147
+ case "kickoff":
148
+ await this.kickoffFromRpc(params);
149
+ return {
150
+ result: {
151
+ ok: true
152
+ }
153
+ };
154
+ case "enqueueTask":
155
+ await this.enqueueRpcTask(params);
156
+ return {
157
+ result: {
158
+ ok: true
159
+ }
160
+ };
161
+ case "shutdown":
162
+ return {
163
+ result: {
164
+ ok: true
165
+ },
166
+ shutdownAfterResponse: true
167
+ };
168
+ case "resolveApproval":
169
+ await this.resolveApprovalFromRpc(params);
170
+ return {
171
+ result: {
172
+ ok: true
173
+ }
174
+ };
175
+ case "taskArtifact":
176
+ return {
177
+ result: await this.getTaskArtifactFromRpc(params)
178
+ };
179
+ case "events":
180
+ return {
181
+ result: await this.getEventsFromRpc(params)
182
+ };
183
+ case "worktreeDiff":
184
+ return {
185
+ result: await this.getWorktreeDiffFromRpc(params)
186
+ };
187
+ case "notifyExternalUpdate":
188
+ await this.publishSnapshot(typeof params.reason === "string" && params.reason.trim() ? params.reason.trim() : "external.update");
189
+ return {
190
+ result: {
191
+ ok: true
192
+ }
193
+ };
194
+ default:
195
+ throw new Error(`Unknown RPC method: ${method}`);
196
+ }
197
+ }
198
+ async buildSnapshot() {
199
+ const session = await loadSessionRecord(this.paths);
200
+ const events = await readRecentEvents(this.paths, 30);
201
+ const approvals = await listApprovalRequests(this.paths, {
202
+ includeResolved: true
203
+ });
204
+ const worktreeDiffs = await Promise.all(session.worktrees.map(async (worktree)=>({
205
+ agent: worktree.agent,
206
+ paths: await listWorktreeChangedPaths(worktree.path, session.baseCommit).catch(()=>[])
207
+ })));
208
+ return {
209
+ session,
210
+ events,
211
+ approvals,
212
+ worktreeDiffs
213
+ };
214
+ }
215
+ async runMutation(fn) {
216
+ const previous = this.mutationQueue;
217
+ let release = ()=>{};
218
+ this.mutationQueue = new Promise((resolve)=>{
219
+ release = resolve;
220
+ });
221
+ await previous;
222
+ try {
223
+ return await fn();
224
+ } finally{
225
+ release();
226
+ }
227
+ }
228
+ async enqueueRpcTask(params) {
229
+ await this.runMutation(async ()=>{
230
+ const prompt = typeof params.prompt === "string" ? params.prompt : "";
231
+ if (!prompt.trim()) {
232
+ throw new Error("enqueueTask requires a prompt.");
233
+ }
234
+ const owner = params.owner === "claude" ? "claude" : "codex";
235
+ const commandId = `rpc-${Date.now()}`;
236
+ const taskId = `task-${commandId}`;
237
+ const task = buildAdHocTask(owner, prompt, taskId, {
238
+ routeReason: typeof params.routeReason === "string" ? params.routeReason : null,
239
+ claimedPaths: Array.isArray(params.claimedPaths) ? params.claimedPaths.map((item)=>String(item)) : []
240
+ });
241
+ this.session.tasks.push(task);
242
+ addDecisionRecord(this.session, {
243
+ kind: "route",
244
+ agent: owner,
245
+ taskId,
246
+ summary: `Routed task to ${owner}`,
247
+ detail: typeof params.routeReason === "string" ? params.routeReason : `Task enqueued for ${owner}.`,
248
+ metadata: {
249
+ strategy: typeof params.routeStrategy === "string" ? params.routeStrategy : "unknown",
250
+ confidence: typeof params.routeConfidence === "number" ? params.routeConfidence : null,
251
+ claimedPaths: task.claimedPaths
252
+ }
253
+ });
254
+ upsertPathClaim(this.session, {
255
+ taskId,
256
+ agent: owner,
257
+ source: "route",
258
+ paths: task.claimedPaths,
259
+ note: task.routeReason
260
+ });
261
+ await saveSessionRecord(this.paths, this.session);
262
+ await recordEvent(this.paths, this.session.id, "task.enqueued", {
263
+ owner,
264
+ via: "rpc"
265
+ });
266
+ await this.publishSnapshot("task.enqueued");
267
+ });
268
+ }
269
+ async kickoffFromRpc(params) {
270
+ await this.runMutation(async ()=>{
271
+ const prompt = typeof params.prompt === "string" ? params.prompt : "";
272
+ if (!prompt.trim()) {
273
+ throw new Error("kickoff requires a prompt.");
274
+ }
275
+ this.session.goal = prompt;
276
+ this.session.tasks.push(...buildKickoffTasks(prompt));
277
+ await saveSessionRecord(this.paths, this.session);
278
+ await recordEvent(this.paths, this.session.id, "tasks.kickoff_enqueued", {
279
+ count: 2,
280
+ via: "rpc"
281
+ });
282
+ await this.publishSnapshot("tasks.kickoff_enqueued");
283
+ });
284
+ }
285
+ async stopFromRpc() {
286
+ await this.runMutation(async ()=>{
287
+ this.session.status = "stopped";
288
+ this.running = false;
289
+ this.session.daemonHeartbeatAt = new Date().toISOString();
290
+ await saveSessionRecord(this.paths, this.session);
291
+ await recordEvent(this.paths, this.session.id, "daemon.stopped", {
292
+ via: "rpc"
293
+ });
294
+ if (this.interval) {
295
+ clearInterval(this.interval);
296
+ this.interval = null;
297
+ }
298
+ await this.closeRpcServer();
299
+ this.stopResolver?.();
300
+ });
301
+ }
302
+ async resolveApprovalFromRpc(params) {
303
+ await this.runMutation(async ()=>{
304
+ const requestId = typeof params.requestId === "string" ? params.requestId : "";
305
+ const decision = params.decision === "deny" ? "deny" : "allow";
306
+ const remember = params.remember === true;
307
+ if (!requestId) {
308
+ throw new Error("resolveApproval requires a requestId.");
309
+ }
310
+ const request = await resolveApprovalRequest(this.paths, requestId, decision, remember);
311
+ addDecisionRecord(this.session, {
312
+ kind: "approval",
313
+ agent: request.agent,
314
+ summary: `${decision === "allow" ? "Approved" : "Denied"} ${request.toolName}`,
315
+ detail: request.summary,
316
+ metadata: {
317
+ requestId: request.id,
318
+ remember,
319
+ toolName: request.toolName
320
+ }
321
+ });
322
+ await saveSessionRecord(this.paths, this.session);
323
+ await recordEvent(this.paths, this.session.id, "approval.resolved", {
324
+ requestId: request.id,
325
+ decision,
326
+ remember,
327
+ agent: request.agent,
328
+ toolName: request.toolName,
329
+ via: "rpc"
330
+ });
331
+ await this.publishSnapshot("approval.resolved");
332
+ });
333
+ }
334
+ async getTaskArtifactFromRpc(params) {
335
+ const taskId = typeof params.taskId === "string" ? params.taskId : "";
336
+ if (!taskId) {
337
+ throw new Error("taskArtifact requires a taskId.");
338
+ }
339
+ return await loadTaskArtifact(this.paths, taskId);
340
+ }
341
+ async getEventsFromRpc(params) {
342
+ const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? params.limit : 20;
343
+ return await readRecentEvents(this.paths, limit);
344
+ }
345
+ async getWorktreeDiffFromRpc(params) {
346
+ const agent = params.agent === "claude" ? "claude" : "codex";
347
+ const filePath = typeof params.filePath === "string" ? params.filePath : null;
348
+ const worktree = this.session.worktrees.find((item)=>item.agent === agent);
349
+ if (!worktree) {
350
+ throw new Error(`No managed worktree found for ${agent}.`);
351
+ }
352
+ return await getWorktreeDiffReview(agent, worktree.path, this.session.baseCommit, filePath);
353
+ }
354
+ async publishSnapshot(reason) {
355
+ if (this.subscribers.size === 0) {
356
+ return;
357
+ }
358
+ const snapshot = await this.buildSnapshot();
359
+ const notification = {
360
+ method: "snapshot.updated",
361
+ params: {
362
+ reason,
363
+ snapshot
364
+ }
365
+ };
366
+ for (const subscriber of this.subscribers){
367
+ this.writeNotification(subscriber, notification);
368
+ }
369
+ }
370
+ async closeRpcServer() {
371
+ const server = this.rpcServer;
372
+ this.rpcServer = null;
373
+ for (const client of this.clients){
374
+ client.end();
375
+ }
376
+ this.clients.clear();
377
+ this.subscribers.clear();
378
+ if (server) {
379
+ await new Promise((resolve)=>{
380
+ server.close(()=>resolve());
381
+ });
382
+ }
383
+ await fs.rm(this.paths.socketPath, {
384
+ force: true
385
+ }).catch(()=>{});
386
+ }
40
387
  async tick() {
41
388
  if (!this.running || this.processing) {
42
389
  return;
@@ -50,6 +397,7 @@ export class KaviDaemon {
50
397
  clearInterval(this.interval);
51
398
  this.interval = null;
52
399
  }
400
+ await this.closeRpcServer();
53
401
  this.stopResolver?.();
54
402
  return;
55
403
  }
@@ -64,6 +412,7 @@ export class KaviDaemon {
64
412
  await recordEvent(this.paths, this.session.id, "tasks.kickoff_created", {
65
413
  count: this.session.tasks.length
66
414
  });
415
+ await this.publishSnapshot("tasks.kickoff_created");
67
416
  }
68
417
  const pending = this.session.tasks.filter((task)=>task.status === "pending");
69
418
  for (const task of pending){
@@ -89,6 +438,7 @@ export class KaviDaemon {
89
438
  clearInterval(this.interval);
90
439
  this.interval = null;
91
440
  }
441
+ await this.closeRpcServer();
92
442
  this.stopResolver?.();
93
443
  return;
94
444
  }
@@ -99,6 +449,7 @@ export class KaviDaemon {
99
449
  await recordEvent(this.paths, this.session.id, "tasks.kickoff_enqueued", {
100
450
  count: 2
101
451
  });
452
+ await this.publishSnapshot("tasks.kickoff_enqueued");
102
453
  continue;
103
454
  }
104
455
  if (command.type === "enqueue" && typeof command.payload.prompt === "string") {
@@ -132,6 +483,7 @@ export class KaviDaemon {
132
483
  await recordEvent(this.paths, this.session.id, "task.enqueued", {
133
484
  owner
134
485
  });
486
+ await this.publishSnapshot("task.enqueued");
135
487
  }
136
488
  }
137
489
  }
@@ -144,6 +496,7 @@ export class KaviDaemon {
144
496
  taskId: task.id,
145
497
  owner: task.owner
146
498
  });
499
+ await this.publishSnapshot("task.started");
147
500
  try {
148
501
  let envelope;
149
502
  let peerMessages;
@@ -159,7 +512,7 @@ export class KaviDaemon {
159
512
  envelope = result.envelope;
160
513
  rawOutput = result.raw;
161
514
  peerMessages = buildClaudePeerMessages(result.envelope, "claude", task.id);
162
- await this.markAgent("claude", result.envelope.summary, 0, `${this.session.id}-claude`);
515
+ await this.markAgent("claude", result.envelope.summary, 0, result.sessionId);
163
516
  } else {
164
517
  throw new Error(`Unsupported task owner ${task.owner}.`);
165
518
  }
@@ -194,6 +547,7 @@ export class KaviDaemon {
194
547
  });
195
548
  this.session.peerMessages.push(...peerMessages);
196
549
  await saveSessionRecord(this.paths, this.session);
550
+ const decisionReplay = buildDecisionReplay(this.session, task, task.owner === "claude" ? "claude" : "codex");
197
551
  await saveTaskArtifact(this.paths, {
198
552
  taskId: task.id,
199
553
  sessionId: this.session.id,
@@ -201,6 +555,9 @@ export class KaviDaemon {
201
555
  owner: task.owner,
202
556
  status: task.status,
203
557
  summary: task.summary,
558
+ routeReason: task.routeReason,
559
+ claimedPaths: task.claimedPaths,
560
+ decisionReplay,
204
561
  rawOutput,
205
562
  error: null,
206
563
  envelope,
@@ -213,6 +570,7 @@ export class KaviDaemon {
213
570
  status: task.status,
214
571
  peerMessages: peerMessages.length
215
572
  });
573
+ await this.publishSnapshot("task.completed");
216
574
  } catch (error) {
217
575
  task.status = "failed";
218
576
  task.summary = error instanceof Error ? error.message : String(error);
@@ -245,6 +603,7 @@ export class KaviDaemon {
245
603
  });
246
604
  await this.markAgent(task.owner, task.summary, 1, task.owner === "claude" ? `${this.session.id}-claude` : null);
247
605
  await saveSessionRecord(this.paths, this.session);
606
+ const decisionReplay = buildDecisionReplay(this.session, task, task.owner === "claude" ? "claude" : "codex");
248
607
  await saveTaskArtifact(this.paths, {
249
608
  taskId: task.id,
250
609
  sessionId: this.session.id,
@@ -252,6 +611,9 @@ export class KaviDaemon {
252
611
  owner: task.owner,
253
612
  status: task.status,
254
613
  summary: task.summary,
614
+ routeReason: task.routeReason,
615
+ claimedPaths: task.claimedPaths,
616
+ decisionReplay,
255
617
  rawOutput: null,
256
618
  error: task.summary,
257
619
  envelope: null,
@@ -263,6 +625,7 @@ export class KaviDaemon {
263
625
  owner: task.owner,
264
626
  error: task.summary
265
627
  });
628
+ await this.publishSnapshot("task.failed");
266
629
  }
267
630
  }
268
631
  async markAgent(agent, summary, exitCode, sessionId) {
package/dist/git.js CHANGED
@@ -59,6 +59,45 @@ function uniqueSorted(values) {
59
59
  ...new Set(values)
60
60
  ].sort();
61
61
  }
62
+ async function isUntrackedPath(worktreePath, filePath) {
63
+ const result = await runCommand("git", [
64
+ "ls-files",
65
+ "--others",
66
+ "--exclude-standard",
67
+ "--",
68
+ filePath
69
+ ], {
70
+ cwd: worktreePath
71
+ });
72
+ if (result.code !== 0) {
73
+ throw new Error(result.stderr.trim() || `Unable to inspect untracked status for ${filePath}.`);
74
+ }
75
+ return parsePathList(result.stdout).includes(filePath);
76
+ }
77
+ async function buildSyntheticAddedFilePatch(worktreePath, filePath) {
78
+ const absolutePath = path.join(worktreePath, filePath);
79
+ const content = await fs.readFile(absolutePath, "utf8");
80
+ const normalized = content.replaceAll("\r", "");
81
+ const endsWithNewline = normalized.endsWith("\n");
82
+ const lines = normalized.length === 0 ? [] : normalized.replace(/\n$/, "").split("\n");
83
+ const patchLines = [
84
+ `diff --git a/${filePath} b/${filePath}`,
85
+ "new file mode 100644",
86
+ "--- /dev/null",
87
+ `+++ b/${filePath}`
88
+ ];
89
+ if (lines.length > 0) {
90
+ patchLines.push(`@@ -0,0 +1,${lines.length} @@`);
91
+ patchLines.push(...lines.map((line)=>`+${line}`));
92
+ if (!endsWithNewline) {
93
+ patchLines.push("\");
94
+ }
95
+ }
96
+ return {
97
+ stat: lines.length > 0 ? `new file | ${lines.length} insertion${lines.length === 1 ? "" : "s"}(+)` : "new file | empty",
98
+ patch: patchLines.join("\n")
99
+ };
100
+ }
62
101
  export async function resolveTargetBranch(repoRoot, configuredBranch) {
63
102
  const exists = await runCommand("git", [
64
103
  "show-ref",
@@ -204,6 +243,64 @@ export async function listWorktreeChangedPaths(worktreePath, baseCommit) {
204
243
  ...parsePathList(untracked.stdout)
205
244
  ]);
206
245
  }
246
+ export async function getWorktreeDiffReview(agent, worktreePath, baseCommit, filePath) {
247
+ const changedPaths = await listWorktreeChangedPaths(worktreePath, baseCommit);
248
+ const selectedPath = filePath && changedPaths.includes(filePath) ? filePath : changedPaths[0] ?? null;
249
+ if (!selectedPath) {
250
+ return {
251
+ agent,
252
+ changedPaths,
253
+ selectedPath: null,
254
+ stat: "No changed files in this worktree.",
255
+ patch: ""
256
+ };
257
+ }
258
+ if (await isUntrackedPath(worktreePath, selectedPath)) {
259
+ const synthetic = await buildSyntheticAddedFilePatch(worktreePath, selectedPath);
260
+ return {
261
+ agent,
262
+ changedPaths,
263
+ selectedPath,
264
+ stat: synthetic.stat,
265
+ patch: synthetic.patch
266
+ };
267
+ }
268
+ const [statResult, patchResult] = await Promise.all([
269
+ runCommand("git", [
270
+ "diff",
271
+ "--stat",
272
+ "--find-renames",
273
+ baseCommit,
274
+ "--",
275
+ selectedPath
276
+ ], {
277
+ cwd: worktreePath
278
+ }),
279
+ runCommand("git", [
280
+ "diff",
281
+ "--find-renames",
282
+ "--unified=3",
283
+ baseCommit,
284
+ "--",
285
+ selectedPath
286
+ ], {
287
+ cwd: worktreePath
288
+ })
289
+ ]);
290
+ if (statResult.code !== 0) {
291
+ throw new Error(statResult.stderr.trim() || `Unable to build diff stat for ${selectedPath}.`);
292
+ }
293
+ if (patchResult.code !== 0) {
294
+ throw new Error(patchResult.stderr.trim() || `Unable to build diff patch for ${selectedPath}.`);
295
+ }
296
+ return {
297
+ agent,
298
+ changedPaths,
299
+ selectedPath,
300
+ stat: statResult.stdout.trim() || "No diff stat available.",
301
+ patch: patchResult.stdout.trim() || "No textual patch available."
302
+ };
303
+ }
207
304
  export async function findOverlappingWorktreePaths(worktrees, baseCommit) {
208
305
  const pathSets = await Promise.all(worktrees.map(async (worktree)=>({
209
306
  agent: worktree.agent,