@wyattjoh/demur 0.3.0 → 0.3.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/README.md +24 -3
- package/extensions/demur/cost-tracker.ts +185 -0
- package/extensions/demur/index.ts +248 -3
- package/package.json +4 -2
- package/src/adapters/pi-worker.ts +25 -0
package/README.md
CHANGED
|
@@ -90,9 +90,11 @@ pi install npm:@wyattjoh/demur
|
|
|
90
90
|
|
|
91
91
|
Pin a specific release when reproducibility matters:
|
|
92
92
|
|
|
93
|
+
<!-- x-release-please-start-version -->
|
|
93
94
|
```sh
|
|
94
|
-
pi install npm:@wyattjoh/demur@0.
|
|
95
|
+
pi install npm:@wyattjoh/demur@0.3.2
|
|
95
96
|
```
|
|
97
|
+
<!-- x-release-please-end -->
|
|
96
98
|
|
|
97
99
|
Launch Pi normally after configuring the credential:
|
|
98
100
|
|
|
@@ -100,8 +102,27 @@ Launch Pi normally after configuring the credential:
|
|
|
100
102
|
pi
|
|
101
103
|
```
|
|
102
104
|
|
|
103
|
-
The extension intercepts `bash` tool calls.
|
|
104
|
-
|
|
105
|
+
The extension intercepts `bash` tool calls. Because Pi runs extensions under
|
|
106
|
+
Node.js while demur uses `Bun.secrets`, the extension launches a package-local
|
|
107
|
+
Bun worker for each judgment. The API key remains inside that worker; only the
|
|
108
|
+
command request and resulting verdict cross its local stdio pipes. `ask` opens
|
|
109
|
+
an interactive confirmation dialog; without an interactive UI, demur blocks the
|
|
110
|
+
command.
|
|
111
|
+
|
|
112
|
+
After each run, Pi's interactive UI prints the decision, submitted input-token
|
|
113
|
+
count, the run's estimated input cost, the accumulated global estimate, and the
|
|
114
|
+
wall-clock evaluation time in human-readable units. The
|
|
115
|
+
estimate uses TypeSafe's published Jev price of
|
|
116
|
+
[$0.042 per million input tokens](https://typesafe.ai/blog/introducing-system-one-models-and-jev);
|
|
117
|
+
it is informational rather than an authoritative billing amount. Failure and
|
|
118
|
+
bypass paths that do not call Jev report that cost is unavailable.
|
|
119
|
+
|
|
120
|
+
The accumulated estimate is stored at `$XDG_STATE_HOME/demur/usage.json`, or
|
|
121
|
+
`~/.local/state/demur/usage.json` when `XDG_STATE_HOME` is unset. A lock
|
|
122
|
+
serializes concurrent Pi instances, and each update is written to a temporary
|
|
123
|
+
file before an atomic rename so the total cannot be partially written or lose a
|
|
124
|
+
concurrent increment. Cost-accounting failures do not change demur's guard
|
|
125
|
+
decision; the status reports `accumulated unavailable` instead.
|
|
105
126
|
|
|
106
127
|
Pi packages execute with the user's full system permissions. Review this
|
|
107
128
|
repository before installing it.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
rename,
|
|
6
|
+
rmdir,
|
|
7
|
+
unlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const LOCK_RETRY_MS = 10;
|
|
14
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TypeSafe's published Jev input price in US dollars per million tokens.
|
|
18
|
+
*
|
|
19
|
+
* Source: https://typesafe.ai/blog/introducing-system-one-models-and-jev
|
|
20
|
+
*/
|
|
21
|
+
export const JEV_INPUT_COST_USD_PER_MILLION = 0.042;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Persisted global usage and estimated-cost totals.
|
|
25
|
+
*/
|
|
26
|
+
export type CostTotals = {
|
|
27
|
+
version: 1;
|
|
28
|
+
totalInputTokens: number;
|
|
29
|
+
estimatedCostUsd: number;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the global demur usage file according to the XDG state convention.
|
|
35
|
+
*
|
|
36
|
+
* @param environment - Process environment used to resolve `XDG_STATE_HOME`
|
|
37
|
+
* @param homeDirectory - Home directory used when the XDG override is absent
|
|
38
|
+
* @returns Absolute path to demur's usage state file
|
|
39
|
+
*/
|
|
40
|
+
export function getCostStatePath(
|
|
41
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
42
|
+
homeDirectory: string = homedir(),
|
|
43
|
+
): string {
|
|
44
|
+
const stateDirectory = environment.XDG_STATE_HOME || join(homeDirectory, ".local", "state");
|
|
45
|
+
return join(stateDirectory, "demur", "usage.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Estimate the Jev input cost from TypeSafe's published per-token price.
|
|
50
|
+
*
|
|
51
|
+
* This is a display estimate rather than an authoritative billing amount.
|
|
52
|
+
*
|
|
53
|
+
* @param inputTokens - Number of input tokens submitted to Jev
|
|
54
|
+
* @returns Estimated cost in US dollars
|
|
55
|
+
*/
|
|
56
|
+
export function estimateInputCostUsd(inputTokens: number): number {
|
|
57
|
+
return (inputTokens * JEV_INPUT_COST_USD_PER_MILLION) / 1_000_000;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Atomically add one judgment's usage to the global accumulated estimate.
|
|
62
|
+
*
|
|
63
|
+
* A lock directory serializes read-modify-write operations across Pi processes.
|
|
64
|
+
* The updated JSON is written to a same-directory temporary file and atomically
|
|
65
|
+
* renamed over the previous state so readers never observe partial content.
|
|
66
|
+
*
|
|
67
|
+
* @param inputTokens - Number of input tokens submitted for this judgment
|
|
68
|
+
* @param statePath - Usage file to update
|
|
69
|
+
* @returns Updated accumulated totals
|
|
70
|
+
*/
|
|
71
|
+
export async function recordInputCost(
|
|
72
|
+
inputTokens: number,
|
|
73
|
+
statePath: string = getCostStatePath(),
|
|
74
|
+
): Promise<CostTotals> {
|
|
75
|
+
if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) {
|
|
76
|
+
throw new Error("input token count must be a non-negative safe integer");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await mkdir(dirname(statePath), { recursive: true, mode: 0o700 });
|
|
80
|
+
const release = await acquireLock(`${statePath}.lock`);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const current = await readTotals(statePath);
|
|
84
|
+
const next: CostTotals = {
|
|
85
|
+
version: 1,
|
|
86
|
+
totalInputTokens: current.totalInputTokens + inputTokens,
|
|
87
|
+
estimatedCostUsd:
|
|
88
|
+
current.estimatedCostUsd + estimateInputCostUsd(inputTokens),
|
|
89
|
+
updatedAt: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
await replaceJsonAtomically(statePath, next);
|
|
92
|
+
return next;
|
|
93
|
+
} finally {
|
|
94
|
+
await release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function acquireLock(lockPath: string): Promise<() => Promise<void>> {
|
|
99
|
+
const startedAt = Date.now();
|
|
100
|
+
|
|
101
|
+
while (true) {
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
104
|
+
return async () => {
|
|
105
|
+
await rmdir(lockPath);
|
|
106
|
+
};
|
|
107
|
+
} catch (error: unknown) {
|
|
108
|
+
if (!isErrorCode(error, "EEXIST")) throw error;
|
|
109
|
+
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
110
|
+
throw new Error(`timed out waiting for cost state lock: ${lockPath}`);
|
|
111
|
+
}
|
|
112
|
+
await delay(LOCK_RETRY_MS);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readTotals(statePath: string): Promise<CostTotals> {
|
|
118
|
+
let content: string;
|
|
119
|
+
try {
|
|
120
|
+
content = await readFile(statePath, "utf8");
|
|
121
|
+
} catch (error: unknown) {
|
|
122
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
123
|
+
return {
|
|
124
|
+
version: 1,
|
|
125
|
+
totalInputTokens: 0,
|
|
126
|
+
estimatedCostUsd: 0,
|
|
127
|
+
updatedAt: new Date(0).toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const value: unknown = JSON.parse(content);
|
|
134
|
+
if (value === null || typeof value !== "object") {
|
|
135
|
+
throw new Error(`invalid cost state in ${statePath}: expected an object`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { version, totalInputTokens, estimatedCostUsd, updatedAt } =
|
|
139
|
+
value as Record<string, unknown>;
|
|
140
|
+
if (
|
|
141
|
+
version !== 1 ||
|
|
142
|
+
!Number.isSafeInteger(totalInputTokens) ||
|
|
143
|
+
(totalInputTokens as number) < 0 ||
|
|
144
|
+
typeof estimatedCostUsd !== "number" ||
|
|
145
|
+
!Number.isFinite(estimatedCostUsd) ||
|
|
146
|
+
estimatedCostUsd < 0 ||
|
|
147
|
+
typeof updatedAt !== "string"
|
|
148
|
+
) {
|
|
149
|
+
throw new Error(`invalid cost state in ${statePath}: unsupported values`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return value as CostTotals;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function replaceJsonAtomically(
|
|
156
|
+
statePath: string,
|
|
157
|
+
totals: CostTotals,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const temporaryPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await writeFile(temporaryPath, `${JSON.stringify(totals, null, 2)}\n`, {
|
|
163
|
+
encoding: "utf8",
|
|
164
|
+
flag: "wx",
|
|
165
|
+
mode: 0o600,
|
|
166
|
+
});
|
|
167
|
+
await rename(temporaryPath, statePath);
|
|
168
|
+
} finally {
|
|
169
|
+
try {
|
|
170
|
+
await unlink(temporaryPath);
|
|
171
|
+
} catch (error: unknown) {
|
|
172
|
+
if (!isErrorCode(error, "ENOENT")) throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isErrorCode(error: unknown, code: string): boolean {
|
|
178
|
+
return error instanceof Error &&
|
|
179
|
+
"code" in error &&
|
|
180
|
+
(error as NodeJS.ErrnoException).code === code;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function delay(milliseconds: number): Promise<void> {
|
|
184
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
185
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
1
3
|
import {
|
|
2
4
|
isToolCallEventType,
|
|
3
5
|
type ExtensionAPI,
|
|
@@ -5,7 +7,16 @@ import {
|
|
|
5
7
|
type ToolCallEvent,
|
|
6
8
|
type ToolCallEventResult,
|
|
7
9
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
10
|
+
import type { Verdict } from "../../src/types.ts";
|
|
11
|
+
import {
|
|
12
|
+
estimateInputCostUsd,
|
|
13
|
+
recordInputCost,
|
|
14
|
+
} from "./cost-tracker.ts";
|
|
15
|
+
|
|
16
|
+
const WORKER_PATH = fileURLToPath(
|
|
17
|
+
new URL("../../src/adapters/pi-worker.ts", import.meta.url),
|
|
18
|
+
);
|
|
19
|
+
const MAX_WORKER_OUTPUT_BYTES = 64 * 1024;
|
|
9
20
|
|
|
10
21
|
/**
|
|
11
22
|
* Handle one `tool_call` event, guarding shell commands only.
|
|
@@ -26,11 +37,44 @@ export async function handleToolCall(
|
|
|
26
37
|
const command = event.input.command ?? "";
|
|
27
38
|
if (command.trim() === "") return undefined;
|
|
28
39
|
|
|
29
|
-
const
|
|
40
|
+
const evaluationStartedAt = performance.now();
|
|
41
|
+
let verdict: Verdict;
|
|
42
|
+
try {
|
|
43
|
+
verdict = await runGuardWorker(command, ctx.cwd, ctx.signal);
|
|
44
|
+
} catch (error: unknown) {
|
|
45
|
+
const evaluationMs = performance.now() - evaluationStartedAt;
|
|
46
|
+
notifyRun(ctx, "ERROR", undefined, undefined, evaluationMs, "warning");
|
|
47
|
+
return {
|
|
48
|
+
block: true,
|
|
49
|
+
reason: `demur: guard worker crashed — ${errorDetail(error)} Blocking because demur fails closed. Set DEMUR_DISABLE=1 to bypass.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const evaluationMs = performance.now() - evaluationStartedAt;
|
|
54
|
+
const inputTokens = verdict.usage?.inputTokens;
|
|
55
|
+
const accumulatedCostUsd = await recordAccumulatedCost(inputTokens);
|
|
30
56
|
|
|
31
|
-
if (verdict.decision === "allow")
|
|
57
|
+
if (verdict.decision === "allow") {
|
|
58
|
+
notifyRun(
|
|
59
|
+
ctx,
|
|
60
|
+
"ALLOW",
|
|
61
|
+
inputTokens,
|
|
62
|
+
accumulatedCostUsd,
|
|
63
|
+
evaluationMs,
|
|
64
|
+
"info",
|
|
65
|
+
);
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
32
68
|
|
|
33
69
|
if (verdict.decision === "deny") {
|
|
70
|
+
notifyRun(
|
|
71
|
+
ctx,
|
|
72
|
+
"DENY",
|
|
73
|
+
inputTokens,
|
|
74
|
+
accumulatedCostUsd,
|
|
75
|
+
evaluationMs,
|
|
76
|
+
"warning",
|
|
77
|
+
);
|
|
34
78
|
return { block: true, reason: verdict.reason };
|
|
35
79
|
}
|
|
36
80
|
|
|
@@ -38,6 +82,14 @@ export async function handleToolCall(
|
|
|
38
82
|
// better than the agent guessing. Without a UI there is nobody to ask, so the
|
|
39
83
|
// fail-closed posture applies and the command is blocked.
|
|
40
84
|
if (!ctx.hasUI) {
|
|
85
|
+
notifyRun(
|
|
86
|
+
ctx,
|
|
87
|
+
"ASK → BLOCK",
|
|
88
|
+
inputTokens,
|
|
89
|
+
accumulatedCostUsd,
|
|
90
|
+
evaluationMs,
|
|
91
|
+
"warning",
|
|
92
|
+
);
|
|
41
93
|
return {
|
|
42
94
|
block: true,
|
|
43
95
|
reason: `${verdict.reason} No interactive UI available to confirm, so blocking.`,
|
|
@@ -45,11 +97,140 @@ export async function handleToolCall(
|
|
|
45
97
|
}
|
|
46
98
|
|
|
47
99
|
const approved = await ctx.ui.confirm("demur", `${verdict.reason}\n\n${command}\n\nRun it anyway?`);
|
|
100
|
+
notifyRun(
|
|
101
|
+
ctx,
|
|
102
|
+
approved ? "ASK → ALLOW" : "ASK → BLOCK",
|
|
103
|
+
inputTokens,
|
|
104
|
+
accumulatedCostUsd,
|
|
105
|
+
evaluationMs,
|
|
106
|
+
approved ? "info" : "warning",
|
|
107
|
+
);
|
|
48
108
|
if (approved) return undefined;
|
|
49
109
|
|
|
50
110
|
return { block: true, reason: `${verdict.reason} Declined by the user.` };
|
|
51
111
|
}
|
|
52
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Format the compact status Pi prints after each demur run.
|
|
115
|
+
*
|
|
116
|
+
* @param result - Guard decision and any final user-confirmation outcome
|
|
117
|
+
* @param inputTokens - Submitted Jev input tokens, when a judgment completed
|
|
118
|
+
* @param accumulatedCostUsd - Persisted global estimate after this run
|
|
119
|
+
* @param evaluationMs - Wall-clock time spent obtaining the guard verdict
|
|
120
|
+
* @returns One-line status with costs and evaluation duration
|
|
121
|
+
*/
|
|
122
|
+
export function formatRunNotification(
|
|
123
|
+
result: string,
|
|
124
|
+
inputTokens: number | undefined,
|
|
125
|
+
accumulatedCostUsd: number | undefined,
|
|
126
|
+
evaluationMs: number,
|
|
127
|
+
): string {
|
|
128
|
+
const duration = `evaluated in ${formatEvaluationDuration(evaluationMs)}`;
|
|
129
|
+
if (inputTokens === undefined) {
|
|
130
|
+
return `demur: ${result} · cost unavailable · ${duration}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const accumulated =
|
|
134
|
+
accumulatedCostUsd === undefined
|
|
135
|
+
? "accumulated unavailable"
|
|
136
|
+
: `accumulated ${formatUsd(accumulatedCostUsd)}`;
|
|
137
|
+
return `demur: ${result} · ${inputTokens.toLocaleString("en-US")} input tokens · estimated cost ${formatUsd(estimateInputCostUsd(inputTokens))} · ${accumulated} · ${duration}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format an evaluation duration using compact human-readable units.
|
|
142
|
+
*
|
|
143
|
+
* @param milliseconds - Non-negative wall-clock duration in milliseconds
|
|
144
|
+
* @returns Duration rendered in milliseconds, seconds, or minutes
|
|
145
|
+
*/
|
|
146
|
+
export function formatEvaluationDuration(milliseconds: number): string {
|
|
147
|
+
const bounded = Math.max(0, milliseconds);
|
|
148
|
+
if (bounded < 1) return "<1 ms";
|
|
149
|
+
if (bounded < 1_000) return `${Math.round(bounded)} ms`;
|
|
150
|
+
if (bounded < 60_000) return `${formatDecimal(bounded / 1_000, bounded < 10_000 ? 2 : 1)} s`;
|
|
151
|
+
|
|
152
|
+
const minutes = Math.floor(bounded / 60_000);
|
|
153
|
+
const seconds = (bounded % 60_000) / 1_000;
|
|
154
|
+
return `${minutes}m ${formatDecimal(seconds, seconds < 10 ? 1 : 0)}s`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run the Bun-native guard behind Pi's Node-compatible extension boundary.
|
|
159
|
+
*
|
|
160
|
+
* The API key remains inside the worker process: only the command request and
|
|
161
|
+
* resulting verdict cross the local stdio pipes.
|
|
162
|
+
*
|
|
163
|
+
* @param command - The shell command Pi is about to execute
|
|
164
|
+
* @param cwd - Absolute working directory for the command
|
|
165
|
+
* @param signal - Optional cancellation signal from Pi
|
|
166
|
+
* @param environment - Environment inherited by the Bun worker
|
|
167
|
+
* @returns The guard verdict produced by the worker
|
|
168
|
+
*/
|
|
169
|
+
export function runGuardWorker(
|
|
170
|
+
command: string,
|
|
171
|
+
cwd: string,
|
|
172
|
+
signal: AbortSignal | undefined,
|
|
173
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
174
|
+
): Promise<Verdict> {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
if (signal?.aborted) {
|
|
177
|
+
reject(new Error("guard request cancelled"));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const child = spawn("bun", [WORKER_PATH], {
|
|
182
|
+
cwd,
|
|
183
|
+
env: environment,
|
|
184
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
185
|
+
});
|
|
186
|
+
let stdout = "";
|
|
187
|
+
let stderr = "";
|
|
188
|
+
let settled = false;
|
|
189
|
+
|
|
190
|
+
const finish = (outcome: () => void) => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
signal?.removeEventListener("abort", abort);
|
|
194
|
+
outcome();
|
|
195
|
+
};
|
|
196
|
+
const abort = () => {
|
|
197
|
+
child.kill();
|
|
198
|
+
finish(() => reject(new Error("guard request cancelled")));
|
|
199
|
+
};
|
|
200
|
+
const appendBounded = (current: string, chunk: Buffer): string =>
|
|
201
|
+
`${current}${chunk.toString("utf8")}`.slice(0, MAX_WORKER_OUTPUT_BYTES);
|
|
202
|
+
|
|
203
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
204
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
205
|
+
stdout = appendBounded(stdout, chunk);
|
|
206
|
+
if (stdout.length >= MAX_WORKER_OUTPUT_BYTES) child.kill();
|
|
207
|
+
});
|
|
208
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
209
|
+
stderr = appendBounded(stderr, chunk);
|
|
210
|
+
if (stderr.length >= MAX_WORKER_OUTPUT_BYTES) child.kill();
|
|
211
|
+
});
|
|
212
|
+
child.on("error", (error) => finish(() => reject(error)));
|
|
213
|
+
child.on("close", (code, exitSignal) => {
|
|
214
|
+
finish(() => {
|
|
215
|
+
if (code !== 0) {
|
|
216
|
+
const detail = stderr.trim() || `exit ${code ?? exitSignal ?? "unknown"}`;
|
|
217
|
+
reject(new Error(detail));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
resolve(parseVerdict(stdout));
|
|
223
|
+
} catch (error: unknown) {
|
|
224
|
+
reject(new Error(`invalid guard worker response: ${errorDetail(error)}`));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
child.stdin.on("error", (error) => finish(() => reject(error)));
|
|
230
|
+
child.stdin.end(JSON.stringify({ command, cwd }));
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
53
234
|
/**
|
|
54
235
|
* Pi extension entry point.
|
|
55
236
|
*
|
|
@@ -61,3 +242,67 @@ export async function handleToolCall(
|
|
|
61
242
|
export default function demur(pi: ExtensionAPI): void {
|
|
62
243
|
pi.on("tool_call", handleToolCall);
|
|
63
244
|
}
|
|
245
|
+
|
|
246
|
+
function notifyRun(
|
|
247
|
+
ctx: ExtensionContext,
|
|
248
|
+
result: string,
|
|
249
|
+
inputTokens: number | undefined,
|
|
250
|
+
accumulatedCostUsd: number | undefined,
|
|
251
|
+
evaluationMs: number,
|
|
252
|
+
level: "info" | "warning",
|
|
253
|
+
): void {
|
|
254
|
+
ctx.ui.notify(
|
|
255
|
+
formatRunNotification(
|
|
256
|
+
result,
|
|
257
|
+
inputTokens,
|
|
258
|
+
accumulatedCostUsd,
|
|
259
|
+
evaluationMs,
|
|
260
|
+
),
|
|
261
|
+
level,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function recordAccumulatedCost(
|
|
266
|
+
inputTokens: number | undefined,
|
|
267
|
+
): Promise<number | undefined> {
|
|
268
|
+
if (inputTokens === undefined) return undefined;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
return (await recordInputCost(inputTokens)).estimatedCostUsd;
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formatDecimal(value: number, fractionDigits: number): string {
|
|
278
|
+
return value
|
|
279
|
+
.toFixed(fractionDigits)
|
|
280
|
+
.replace(/(\.\d*?[1-9])0+$/, "$1")
|
|
281
|
+
.replace(/\.0+$/, "");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatUsd(value: number): string {
|
|
285
|
+
const decimal = value.toFixed(9).replace(/0+$/, "").replace(/\.$/, "");
|
|
286
|
+
return `$${decimal}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function parseVerdict(output: string): Verdict {
|
|
290
|
+
const value: unknown = JSON.parse(output);
|
|
291
|
+
if (value === null || typeof value !== "object") {
|
|
292
|
+
throw new Error("verdict must be an object");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { decision, reason } = value as Record<string, unknown>;
|
|
296
|
+
if (
|
|
297
|
+
(decision !== "allow" && decision !== "ask" && decision !== "deny") ||
|
|
298
|
+
typeof reason !== "string"
|
|
299
|
+
) {
|
|
300
|
+
throw new Error("verdict must contain a valid decision and reason");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return value as Verdict;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function errorDetail(error: unknown): string {
|
|
307
|
+
return error instanceof Error ? error.message : String(error);
|
|
308
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyattjoh/demur",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A proof-of-concept destructive-command guard for coding agents.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
"security"
|
|
23
23
|
],
|
|
24
24
|
"files": [
|
|
25
|
+
"extensions/demur/cost-tracker.ts",
|
|
25
26
|
"extensions/demur/index.ts",
|
|
26
27
|
"src/adapters/claude-code.ts",
|
|
28
|
+
"src/adapters/pi-worker.ts",
|
|
27
29
|
"src/analyze.ts",
|
|
28
30
|
"src/cli.ts",
|
|
29
31
|
"src/guard.internal.ts",
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
"scripts": {
|
|
46
48
|
"check": "tsc --noEmit",
|
|
47
49
|
"test": "vitest run",
|
|
48
|
-
"build:pi": "bun build extensions/demur/index.ts --target=
|
|
50
|
+
"build:pi": "bun build extensions/demur/index.ts --target=node --outfile=dist/demur-guard.js --format=esm --external @earendil-works/pi-coding-agent && bun build src/adapters/pi-worker.ts --target=bun --outfile=dist/demur-pi-worker.js --format=esm",
|
|
49
51
|
"build:claude": "bun build src/adapters/claude-code.ts --target=bun --outfile=dist/demur-hook.js --format=esm",
|
|
50
52
|
"build": "bun run build:pi && bun run build:claude",
|
|
51
53
|
"ci": "bun run check && bun run test && bun run build",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { guard } from "../guard.ts";
|
|
3
|
+
|
|
4
|
+
type GuardRequest = {
|
|
5
|
+
command: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const request = parseRequest(await Bun.stdin.text());
|
|
10
|
+
const verdict = await guard(request.command, request.cwd, "pi");
|
|
11
|
+
process.stdout.write(JSON.stringify(verdict));
|
|
12
|
+
|
|
13
|
+
function parseRequest(input: string): GuardRequest {
|
|
14
|
+
const value: unknown = JSON.parse(input);
|
|
15
|
+
if (value === null || typeof value !== "object") {
|
|
16
|
+
throw new Error("guard request must be an object");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { command, cwd } = value as Record<string, unknown>;
|
|
20
|
+
if (typeof command !== "string" || typeof cwd !== "string") {
|
|
21
|
+
throw new Error("guard request must contain string command and cwd fields");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { command, cwd };
|
|
25
|
+
}
|