@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 +108 -0
- package/bin/kavi.js +44 -0
- package/dist/adapters/claude.js +122 -0
- package/dist/adapters/codex.js +45 -0
- package/dist/adapters/shared.js +69 -0
- package/dist/approvals.js +185 -0
- package/dist/command-queue.js +25 -0
- package/dist/config.js +175 -0
- package/dist/daemon.js +202 -0
- package/dist/doctor.js +78 -0
- package/dist/fs.js +30 -0
- package/dist/git.js +289 -0
- package/dist/history.js +39 -0
- package/dist/main.js +667 -0
- package/dist/paths.js +43 -0
- package/dist/process.js +72 -0
- package/dist/router.js +55 -0
- package/dist/runtime.js +43 -0
- package/dist/session.js +113 -0
- package/dist/task-artifacts.js +37 -0
- package/dist/toml.js +55 -0
- package/dist/tui.js +92 -0
- package/dist/types.js +3 -0
- package/package.json +53 -0
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
|