@mandipadk7/kavi 0.1.0

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/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # Kavi
2
+
3
+ Kavi is a local terminal control plane for managed Codex and Claude collaboration.
4
+
5
+ Current capabilities:
6
+ - `kavi init`: create repo-local `.kavi` config, prompt files, and ignore rules.
7
+ - `kavi init --home`: also scaffold the user-local config file used for binary overrides.
8
+ - `kavi doctor`: verify Node, Codex, Claude, git worktree support, and local readiness.
9
+ - `kavi start`: start a managed session without attaching the TUI.
10
+ - `kavi open`: create a managed session with separate Codex and Claude worktrees and open the terminal dashboard.
11
+ - `kavi resume`: reopen the dashboard for the current repo session.
12
+ - `kavi status`: inspect session health and task counts from any terminal.
13
+ - `kavi paths`: inspect resolved repo-local, user-local, worktree, and runtime paths.
14
+ - `kavi task`: enqueue a task for `codex`, `claude`, or `auto` routing.
15
+ - `kavi tasks`: inspect the session task list with summaries and artifact availability.
16
+ - `kavi task-output`: inspect the normalized envelope and raw output for a completed task.
17
+ - `kavi approvals`: inspect the approval inbox.
18
+ - `kavi approve` and `kavi deny`: resolve a pending approval request, optionally with `--remember`.
19
+ - `kavi events`: inspect recent daemon and task events.
20
+ - `kavi stop`: stop the background daemon for the current repo session.
21
+ - `kavi land`: snapshot dirty agent worktrees into commits, merge them in a transient integration worktree, run validation there, and then fast-forward the target branch.
22
+
23
+ Runtime model:
24
+ - Repo-local state lives under `.kavi/`.
25
+ - Machine-local state defaults to `~/.config/kavi` and `~/.local/state/kavi`.
26
+ - In restricted environments you can override those with `KAVI_HOME_CONFIG_DIR` and `KAVI_HOME_STATE_DIR`.
27
+ - User-local runtime overrides live in `~/.config/kavi/config.toml` and can point Kavi at custom `node`, `codex`, and `claude` binaries.
28
+ - SQLite event mirroring is opt-in with `KAVI_ENABLE_SQLITE_HISTORY=1`; the default event log is JSONL under `.kavi/state/events.jsonl`.
29
+
30
+ Development:
31
+
32
+ ```bash
33
+ node bin/kavi.js init --home
34
+ node bin/kavi.js doctor --json
35
+ node bin/kavi.js paths
36
+ node bin/kavi.js start --goal "Build the auth backend"
37
+ node bin/kavi.js status
38
+ node bin/kavi.js task --agent auto "Design the dashboard shell"
39
+ node bin/kavi.js tasks
40
+ node bin/kavi.js task-output latest
41
+ node bin/kavi.js approvals
42
+ node bin/kavi.js open
43
+ node --experimental-strip-types --test src/**/*.test.ts
44
+ ```
45
+
46
+ Notes:
47
+ - The current managed task runners use the installed `codex` and `claude` CLIs directly.
48
+ - The dashboard uses a file-backed control loop instead of local sockets so it can run in restricted shells and sandboxes.
49
+
50
+ Local install options:
51
+
52
+ ```bash
53
+ # Run directly from the repo
54
+ node bin/kavi.js help
55
+
56
+ # Symlink into ~/.local/bin
57
+ ./scripts/install-local.sh
58
+
59
+ # Or use npm link from this repo
60
+ npm link
61
+ ```
62
+
63
+ Publishing for testers:
64
+
65
+ ```bash
66
+ # authenticate once
67
+ npm login
68
+
69
+ # verify the package before publish
70
+ npm run release:check
71
+
72
+ # publish a beta that friends can install immediately
73
+ npm run publish:beta
74
+
75
+ # later, publish the stable tag
76
+ npm run publish:latest
77
+ ```
78
+
79
+ Install commands for testers:
80
+
81
+ ```bash
82
+ # beta channel
83
+ npm install -g @mandipadk7/kavi@beta
84
+
85
+ # one-off
86
+ npx @mandipadk7/kavi@beta help
87
+ ```
88
+
89
+ Notes on publish:
90
+ - The package name is scoped as `@mandipadk7/kavi` to match the npm user `mandipadk7`.
91
+ - The publish flow now builds a compiled `dist/` directory first, and the installed CLI prefers `dist/main.js`. Source mode remains available for local development.
92
+ - `prepublishOnly` runs the release checks automatically during publish.
93
+
94
+ User-local config example:
95
+
96
+ ```toml
97
+ version = 1
98
+
99
+ [runtime]
100
+ node_bin = ""
101
+ codex_bin = "codex"
102
+ claude_bin = "claude"
103
+ ```
104
+
105
+ Planned release shape:
106
+ - npm global install: `npm install -g @mandipadk7/kavi`
107
+ - one-off use: `npx @mandipadk7/kavi`
108
+ - optional native installer script or package-managed distribution after the npm workflow is stable
package/bin/kavi.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ function nodeMajor(version) {
10
+ return Number(version.split(".")[0] ?? 0);
11
+ }
12
+
13
+ if (nodeMajor(process.versions.node) < 25) {
14
+ console.error(`Kavi requires Node 25 or newer. Current runtime: ${process.version}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ const distEntrypoint = path.join(here, "..", "dist", "main.js");
20
+ const srcEntrypoint = path.join(here, "..", "src", "main.ts");
21
+ const useDist = fs.existsSync(distEntrypoint);
22
+ const entrypoint = useDist ? distEntrypoint : srcEntrypoint;
23
+ const nodeArgs = useDist
24
+ ? [entrypoint, ...process.argv.slice(2)]
25
+ : ["--experimental-strip-types", entrypoint, ...process.argv.slice(2)];
26
+
27
+ const child = spawn(process.execPath, nodeArgs, {
28
+ stdio: "inherit",
29
+ env: process.env
30
+ });
31
+
32
+ child.on("exit", (code, signal) => {
33
+ if (signal) {
34
+ process.kill(process.pid, signal);
35
+ return;
36
+ }
37
+
38
+ process.exit(code ?? 1);
39
+ });
40
+
41
+ child.on("error", (error) => {
42
+ console.error(error instanceof Error ? error.message : String(error));
43
+ process.exit(1);
44
+ });
@@ -0,0 +1,122 @@
1
+ import fs from "node:fs/promises";
2
+ import { buildEnvelopeInstruction, buildPeerMessages, buildSharedContext, extractJsonObject } from "./shared.js";
3
+ import { runCommand } from "../process.js";
4
+ import { buildKaviShellCommand } from "../runtime.js";
5
+ function findWorktree(session, agent) {
6
+ const worktree = session.worktrees.find((item)=>item.agent === agent);
7
+ if (!worktree) {
8
+ throw new Error(`Missing worktree for ${agent}.`);
9
+ }
10
+ return worktree;
11
+ }
12
+ export async function writeClaudeSettings(paths, session) {
13
+ const buildHookCommand = (event)=>buildKaviShellCommand(session.runtime, [
14
+ "__hook",
15
+ "--repo-root",
16
+ paths.repoRoot,
17
+ "--agent",
18
+ "claude",
19
+ "--event",
20
+ event
21
+ ]);
22
+ const settings = {
23
+ permissions: {
24
+ defaultMode: "plan"
25
+ },
26
+ hooks: {
27
+ SessionStart: [
28
+ {
29
+ matcher: "startup|resume|compact",
30
+ hooks: [
31
+ {
32
+ type: "command",
33
+ command: buildHookCommand("SessionStart")
34
+ }
35
+ ]
36
+ }
37
+ ],
38
+ PreToolUse: [
39
+ {
40
+ matcher: "Bash|Edit|Write|MultiEdit",
41
+ hooks: [
42
+ {
43
+ type: "command",
44
+ command: buildHookCommand("PreToolUse")
45
+ }
46
+ ]
47
+ }
48
+ ],
49
+ PostToolUse: [
50
+ {
51
+ matcher: "Bash|Edit|Write|MultiEdit",
52
+ hooks: [
53
+ {
54
+ type: "command",
55
+ command: buildHookCommand("PostToolUse")
56
+ }
57
+ ]
58
+ }
59
+ ],
60
+ Notification: [
61
+ {
62
+ matcher: "permission_prompt|idle_prompt|auth_success",
63
+ hooks: [
64
+ {
65
+ type: "command",
66
+ command: buildHookCommand("Notification")
67
+ }
68
+ ]
69
+ }
70
+ ],
71
+ Stop: [
72
+ {
73
+ matcher: "stop",
74
+ hooks: [
75
+ {
76
+ type: "command",
77
+ command: buildHookCommand("Stop")
78
+ }
79
+ ]
80
+ }
81
+ ]
82
+ },
83
+ env: {
84
+ KAVI_SESSION_ID: session.id
85
+ }
86
+ };
87
+ await fs.writeFile(paths.claudeSettingsFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
88
+ }
89
+ export async function runClaudeTask(session, task, paths) {
90
+ const worktree = findWorktree(session, "claude");
91
+ const claudeSessionId = `${session.id}-claude`;
92
+ await writeClaudeSettings(paths, session);
93
+ const prompt = [
94
+ buildSharedContext(session, task, "claude"),
95
+ "",
96
+ `User goal or prompt:\n${task.prompt}`,
97
+ "",
98
+ buildEnvelopeInstruction("claude", worktree.path)
99
+ ].join("\n");
100
+ const result = await runCommand(session.runtime.claudeExecutable, [
101
+ "-p",
102
+ "--session-id",
103
+ claudeSessionId,
104
+ "--settings",
105
+ paths.claudeSettingsFile,
106
+ "--permission-mode",
107
+ "plan",
108
+ prompt
109
+ ], {
110
+ cwd: worktree.path
111
+ });
112
+ const rawOutput = result.code === 0 ? result.stdout : `${result.stdout}\n${result.stderr}`;
113
+ const envelope = extractJsonObject(rawOutput);
114
+ return {
115
+ envelope,
116
+ raw: rawOutput
117
+ };
118
+ }
119
+ export { buildPeerMessages };
120
+
121
+
122
+ //# sourceURL=adapters/claude.ts
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { runCommand } from "../process.js";
4
+ import { buildEnvelopeInstruction, buildPeerMessages, buildSharedContext, extractJsonObject } from "./shared.js";
5
+ function findWorktree(session, agent) {
6
+ const worktree = session.worktrees.find((item)=>item.agent === agent);
7
+ if (!worktree) {
8
+ throw new Error(`Missing worktree for ${agent}.`);
9
+ }
10
+ return worktree;
11
+ }
12
+ export async function runCodexTask(session, task, paths) {
13
+ const worktree = findWorktree(session, "codex");
14
+ const outputFile = path.join(paths.runtimeDir, `codex-${task.id}.txt`);
15
+ const prompt = [
16
+ buildSharedContext(session, task, "codex"),
17
+ "",
18
+ `User goal or prompt:\n${task.prompt}`,
19
+ "",
20
+ buildEnvelopeInstruction("codex", worktree.path)
21
+ ].join("\n");
22
+ const result = await runCommand(session.runtime.codexExecutable, [
23
+ "exec",
24
+ "--skip-git-repo-check",
25
+ "--cd",
26
+ worktree.path,
27
+ "--sandbox",
28
+ "workspace-write",
29
+ "--output-last-message",
30
+ outputFile,
31
+ prompt
32
+ ], {
33
+ cwd: session.repoRoot
34
+ });
35
+ const rawOutput = result.code === 0 ? await fs.readFile(outputFile, "utf8") : `${result.stdout}\n${result.stderr}`;
36
+ const envelope = extractJsonObject(rawOutput);
37
+ return {
38
+ envelope,
39
+ raw: rawOutput
40
+ };
41
+ }
42
+ export { buildPeerMessages };
43
+
44
+
45
+ //# sourceURL=adapters/codex.ts
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { nowIso } from "../paths.js";
3
+ export function extractJsonObject(rawOutput) {
4
+ const trimmed = rawOutput.trim();
5
+ const fencedMatch = trimmed.match(/```json\s*([\s\S]*?)```/i);
6
+ const candidate = fencedMatch ? fencedMatch[1] : trimmed.slice(trimmed.indexOf("{"), trimmed.lastIndexOf("}") + 1);
7
+ if (!candidate || !candidate.trim().startsWith("{")) {
8
+ throw new Error(`Unable to find JSON object in output:\n${rawOutput}`);
9
+ }
10
+ const parsed = JSON.parse(candidate);
11
+ return {
12
+ summary: parsed.summary ?? "",
13
+ status: parsed.status ?? "completed",
14
+ blockers: Array.isArray(parsed.blockers) ? parsed.blockers.map((item)=>String(item)) : [],
15
+ nextRecommendation: parsed.nextRecommendation === null || typeof parsed.nextRecommendation === "string" ? parsed.nextRecommendation : null,
16
+ peerMessages: Array.isArray(parsed.peerMessages) ? parsed.peerMessages.map((message)=>({
17
+ to: message.to,
18
+ intent: message.intent,
19
+ subject: String(message.subject ?? ""),
20
+ body: String(message.body ?? "")
21
+ })) : []
22
+ };
23
+ }
24
+ export function buildPeerMessages(envelope, from, taskId) {
25
+ return envelope.peerMessages.map((message)=>({
26
+ id: randomUUID(),
27
+ taskId,
28
+ from,
29
+ to: message.to,
30
+ intent: message.intent,
31
+ subject: message.subject,
32
+ body: message.body,
33
+ createdAt: nowIso()
34
+ }));
35
+ }
36
+ export function buildSharedContext(session, task, agent) {
37
+ const inbox = session.peerMessages.filter((message)=>message.to === agent).slice(-session.config.messageLimit).map((message)=>`- [${message.intent}] ${message.subject}: ${message.body}`).join("\n");
38
+ const tasks = session.tasks.map((item)=>`- ${item.id} | ${item.owner} | ${item.status} | ${item.title}`).join("\n");
39
+ return [
40
+ `Session goal: ${session.goal ?? "No goal recorded."}`,
41
+ `Current task: ${task.title}`,
42
+ "Task board:",
43
+ tasks || "- none",
44
+ `Peer inbox for ${agent}:`,
45
+ inbox || "- empty"
46
+ ].join("\n");
47
+ }
48
+ export function buildEnvelopeInstruction(agent, worktreePath) {
49
+ const peer = agent === "codex" ? "claude" : "codex";
50
+ const focus = agent === "codex" ? "Focus on planning, architecture, backend concerns, codebase structure, and implementation risks." : "Focus on intent, user experience, frontend structure, and interaction quality.";
51
+ return [
52
+ focus,
53
+ `You are working inside the worktree at ${worktreePath}.`,
54
+ `Return JSON only with this exact shape:`,
55
+ "{",
56
+ ' "summary": "short summary",',
57
+ ' "status": "completed" | "blocked" | "needs_review",',
58
+ ' "blockers": ["optional blockers"],',
59
+ ' "nextRecommendation": "optional next step or null",',
60
+ ' "peerMessages": [',
61
+ ` { "to": "${peer}", "intent": "question|handoff|review_request|blocked|context_share", "subject": "short", "body": "short" }`,
62
+ " ]",
63
+ "}",
64
+ "Do not wrap the JSON in Markdown."
65
+ ].join("\n");
66
+ }
67
+
68
+
69
+ //# sourceURL=adapters/shared.ts
@@ -0,0 +1,185 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ensureDir, fileExists, readJson, writeJson } from "./fs.js";
5
+ import { nowIso } from "./paths.js";
6
+ function normalizeWhitespace(value) {
7
+ return value.replaceAll(/\s+/g, " ").trim();
8
+ }
9
+ function safeJson(value) {
10
+ try {
11
+ return JSON.stringify(value);
12
+ } catch {
13
+ return String(value);
14
+ }
15
+ }
16
+ function truncate(value, limit) {
17
+ if (value.length <= limit) {
18
+ return value;
19
+ }
20
+ return `${value.slice(0, Math.max(0, limit - 3))}...`;
21
+ }
22
+ function readString(input, key) {
23
+ const value = input[key];
24
+ return typeof value === "string" ? value : null;
25
+ }
26
+ function readObject(input, key) {
27
+ const value = input[key];
28
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
29
+ }
30
+ export function describeToolUse(payload) {
31
+ const toolName = readString(payload, "tool_name") ?? "UnknownTool";
32
+ const toolInput = readObject(payload, "tool_input");
33
+ let detail = "";
34
+ switch(toolName){
35
+ case "Bash":
36
+ detail = normalizeWhitespace(readString(toolInput, "command") ?? safeJson(toolInput));
37
+ break;
38
+ case "Write":
39
+ case "Edit":
40
+ case "MultiEdit":
41
+ case "Read":
42
+ detail = readString(toolInput, "file_path") ?? safeJson(toolInput);
43
+ break;
44
+ case "Glob":
45
+ case "Grep":
46
+ detail = readString(toolInput, "pattern") ?? safeJson(toolInput);
47
+ break;
48
+ case "WebFetch":
49
+ detail = readString(toolInput, "url") ?? safeJson(toolInput);
50
+ break;
51
+ default:
52
+ detail = safeJson(toolInput);
53
+ break;
54
+ }
55
+ const normalized = normalizeWhitespace(detail);
56
+ return {
57
+ toolName,
58
+ summary: `${toolName}: ${truncate(normalized || "(no details)", 140)}`,
59
+ matchKey: `${toolName}:${normalized.toLowerCase()}`
60
+ };
61
+ }
62
+ async function loadRequests(paths) {
63
+ if (!await fileExists(paths.approvalsFile)) {
64
+ return [];
65
+ }
66
+ return readJson(paths.approvalsFile);
67
+ }
68
+ async function saveRequests(paths, requests) {
69
+ await writeJson(paths.approvalsFile, requests);
70
+ }
71
+ export async function listApprovalRequests(paths, options = {}) {
72
+ const requests = await loadRequests(paths);
73
+ const filtered = options.includeResolved ? requests : requests.filter((request)=>request.status === "pending");
74
+ return filtered.sort((left, right)=>left.createdAt.localeCompare(right.createdAt));
75
+ }
76
+ export async function createApprovalRequest(paths, input) {
77
+ const descriptor = describeToolUse(input.payload);
78
+ const timestamp = nowIso();
79
+ const request = {
80
+ id: randomUUID(),
81
+ sessionId: input.sessionId,
82
+ repoRoot: input.repoRoot,
83
+ agent: input.agent,
84
+ hookEvent: input.hookEvent,
85
+ toolName: descriptor.toolName,
86
+ summary: descriptor.summary,
87
+ matchKey: descriptor.matchKey,
88
+ payload: input.payload,
89
+ status: "pending",
90
+ decision: null,
91
+ remember: false,
92
+ createdAt: timestamp,
93
+ updatedAt: timestamp,
94
+ resolvedAt: null
95
+ };
96
+ const requests = await loadRequests(paths);
97
+ requests.push(request);
98
+ await saveRequests(paths, requests);
99
+ return request;
100
+ }
101
+ export async function loadApprovalRequest(paths, requestId) {
102
+ const requests = await loadRequests(paths);
103
+ return requests.find((request)=>request.id === requestId) ?? null;
104
+ }
105
+ export async function resolveApprovalRequest(paths, requestId, decision, remember) {
106
+ const requests = await loadRequests(paths);
107
+ const request = requests.find((item)=>item.id === requestId);
108
+ if (!request) {
109
+ throw new Error(`Approval request ${requestId} not found.`);
110
+ }
111
+ request.status = decision === "allow" ? "approved" : "denied";
112
+ request.decision = decision;
113
+ request.remember = remember;
114
+ request.updatedAt = nowIso();
115
+ request.resolvedAt = request.updatedAt;
116
+ await saveRequests(paths, requests);
117
+ if (remember) {
118
+ await upsertApprovalRule(paths, request, decision);
119
+ }
120
+ return request;
121
+ }
122
+ export async function expireApprovalRequest(paths, requestId) {
123
+ const requests = await loadRequests(paths);
124
+ const request = requests.find((item)=>item.id === requestId);
125
+ if (!request || request.status !== "pending") {
126
+ return;
127
+ }
128
+ request.status = "expired";
129
+ request.updatedAt = nowIso();
130
+ request.resolvedAt = request.updatedAt;
131
+ await saveRequests(paths, requests);
132
+ }
133
+ export async function waitForApprovalDecision(paths, requestId, timeoutMs = 300_000) {
134
+ const startedAt = Date.now();
135
+ while(Date.now() - startedAt < timeoutMs){
136
+ const request = await loadApprovalRequest(paths, requestId);
137
+ if (request && request.status !== "pending") {
138
+ return request;
139
+ }
140
+ await new Promise((resolve)=>setTimeout(resolve, 500));
141
+ }
142
+ await expireApprovalRequest(paths, requestId);
143
+ return loadApprovalRequest(paths, requestId);
144
+ }
145
+ async function loadRules(paths) {
146
+ if (!await fileExists(paths.homeApprovalRulesFile)) {
147
+ return [];
148
+ }
149
+ return readJson(paths.homeApprovalRulesFile);
150
+ }
151
+ async function saveRules(paths, rules) {
152
+ await ensureDir(path.dirname(paths.homeApprovalRulesFile));
153
+ await writeJson(paths.homeApprovalRulesFile, rules);
154
+ }
155
+ export async function findApprovalRule(paths, input) {
156
+ const rules = await loadRules(paths);
157
+ return rules.find((rule)=>rule.repoRoot === input.repoRoot && rule.agent === input.agent && rule.toolName === input.toolName && rule.matchKey === input.matchKey) ?? null;
158
+ }
159
+ async function upsertApprovalRule(paths, request, decision) {
160
+ const rules = await loadRules(paths);
161
+ const existing = rules.find((rule)=>rule.repoRoot === request.repoRoot && rule.agent === request.agent && rule.toolName === request.toolName && rule.matchKey === request.matchKey);
162
+ if (existing) {
163
+ existing.decision = decision;
164
+ existing.summary = request.summary;
165
+ existing.updatedAt = nowIso();
166
+ await saveRules(paths, rules);
167
+ return;
168
+ }
169
+ const timestamp = nowIso();
170
+ rules.push({
171
+ id: randomUUID(),
172
+ repoRoot: request.repoRoot,
173
+ agent: request.agent,
174
+ toolName: request.toolName,
175
+ matchKey: request.matchKey,
176
+ summary: request.summary,
177
+ decision,
178
+ createdAt: timestamp,
179
+ updatedAt: timestamp
180
+ });
181
+ await saveRules(paths, rules);
182
+ }
183
+
184
+
185
+ //# sourceURL=approvals.ts
@@ -0,0 +1,25 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import { ensureDir, fileExists } from "./fs.js";
4
+ import { nowIso } from "./paths.js";
5
+ export async function appendCommand(paths, type, payload) {
6
+ const command = {
7
+ id: randomUUID(),
8
+ type,
9
+ createdAt: nowIso(),
10
+ payload
11
+ };
12
+ await ensureDir(paths.runtimeDir);
13
+ await fs.appendFile(paths.commandsFile, `${JSON.stringify(command)}\n`, "utf8");
14
+ }
15
+ export async function consumeCommands(paths) {
16
+ if (!await fileExists(paths.commandsFile)) {
17
+ return [];
18
+ }
19
+ const content = await fs.readFile(paths.commandsFile, "utf8");
20
+ await fs.writeFile(paths.commandsFile, "", "utf8");
21
+ return content.split(/\r?\n/).filter(Boolean).map((line)=>JSON.parse(line));
22
+ }
23
+
24
+
25
+ //# sourceURL=command-queue.ts