ai-spend-agent 0.9.8 → 0.9.10
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/index.js +47 -11
- package/dist/workspaceConnect.d.ts +12 -1
- package/dist/workspaceConnect.js +20 -9
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { beginWorkspaceEnrollment, finishWorkspaceEnrollment, workspaceStatus, prepareWorkspacePush, sendWorkspacePending, disconnectWorkspace, WORKSPACE_ORIGIN, workspaceCanonicalJson, } from "./workspaceConnect.js";
|
|
2
|
+
import { beginWorkspaceEnrollment, finishWorkspaceEnrollment, workspaceStatus, prepareWorkspacePush, sendWorkspacePending, disconnectWorkspace, WORKSPACE_ORIGIN, workspaceCanonicalJson, WorkspaceEnrollmentRefusal, } from "./workspaceConnect.js";
|
|
3
3
|
import { workspaceClock } from "./lib/clock.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { realpathSync } from "node:fs";
|
|
@@ -1063,6 +1063,14 @@ async function workspaceCommand(args, runtime) {
|
|
|
1063
1063
|
const status = await workspaceStatus(runtime.homeDirectory);
|
|
1064
1064
|
if (status.state === "not_connected")
|
|
1065
1065
|
return ok("This machine has no completed local Workspace pairing. No logs or network were read.");
|
|
1066
|
+
if (status.state === "enrollment_pending")
|
|
1067
|
+
return ok([
|
|
1068
|
+
`Unfinished connection request: ${status.requestId.slice(-8)}`,
|
|
1069
|
+
status.enrollment === "prepared"
|
|
1070
|
+
? "No native exchange was attempted. Run npx aibill@latest workspace connect to display this request again, or npx aibill@latest workspace connect --restart to replace an expired or cancelled request."
|
|
1071
|
+
: `The exchange outcome is unknown. Local keys remain. Do not restart or replay; reconcile this machine at ${WORKSPACE_ORIGIN}/settings/machines.`,
|
|
1072
|
+
"No logs or network were read."
|
|
1073
|
+
].join("\n"));
|
|
1066
1074
|
return ok([`Workspace: ${status.origin}`, `Last accepted push: ${status.lastAcceptedAt ?? "no push yet"}`,
|
|
1067
1075
|
`Pairing state: local record present; disconnect ${status.disconnect ?? "not requested"}`,
|
|
1068
1076
|
`Acknowledged fact keys: ${status.acknowledgedFacts}`, `Pending batch: ${status.pending?.state ?? "none"}`,
|
|
@@ -1070,10 +1078,14 @@ async function workspaceCommand(args, runtime) {
|
|
|
1070
1078
|
}
|
|
1071
1079
|
if (action === "connect") {
|
|
1072
1080
|
if (!args.workspaceCode) {
|
|
1073
|
-
if (!await consent(
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1081
|
+
if (!await consent(args.workspaceRestart
|
|
1082
|
+
? "Replace any unused connection request with a new private key and public request? The previous unused key will be removed; connected or uncertain exchanges cannot be restarted. No session facts are sent. [y/N] "
|
|
1083
|
+
: "Create a private device key, or display an existing unused connection request? No session facts are sent. [y/N] "))
|
|
1084
|
+
return fail("Connection request unchanged. No new request was created.");
|
|
1085
|
+
const prepared = await beginWorkspaceEnrollment(runtime.homeDirectory, { restart: args.workspaceRestart });
|
|
1086
|
+
return ok([`${prepared.disposition === "reused" ? "Reused" : "New"} connection request: ${prepared.request.requestId.slice(-8)}`,
|
|
1087
|
+
...(prepared.disposition === "reused" ? ["This is the same unfinished request. If it expired or was cancelled in the browser, run npx aibill@latest workspace connect --restart."] : []),
|
|
1088
|
+
`Open ${WORKSPACE_ORIGIN}/settings/machines, paste this public request and confirm enrollment:`, prepared.bundle,
|
|
1077
1089
|
"Then run: npx aibill workspace connect <response-bundle>", "The private device key stays on this machine. No session facts were sent."].join("\n"));
|
|
1078
1090
|
}
|
|
1079
1091
|
const result = await finishWorkspaceEnrollment({ home: runtime.homeDirectory, bundle: args.workspaceCode,
|
|
@@ -1092,7 +1104,7 @@ async function workspaceCommand(args, runtime) {
|
|
|
1092
1104
|
: "Revoke this machine's Workspace grant and remove its local pairing after the accepted receipt? [y/N] ") });
|
|
1093
1105
|
return result.state === "revoked" ? ok("Workspace revoked this machine grant. Local pairing was removed.")
|
|
1094
1106
|
: result.state === "abandoned" ? ok("Unused native pairing key removed. No remote revocation was claimed. Run npx aibill workspace connect to start a new request.")
|
|
1095
|
-
: result.state === "cancelled" ?
|
|
1107
|
+
: result.state === "cancelled" ? fail("Pairing kept. Disconnect was not confirmed; no request or key was removed.") : result.state === "not_paired" ? ok("No completed local pairing exists.")
|
|
1096
1108
|
: fail(`Disconnect outcome is unknown. Local keys remain; no retry was sent. Reconcile this machine at ${WORKSPACE_ORIGIN}/settings/machines.`);
|
|
1097
1109
|
}
|
|
1098
1110
|
const status = await workspaceStatus(runtime.homeDirectory);
|
|
@@ -1104,12 +1116,27 @@ async function workspaceCommand(args, runtime) {
|
|
|
1104
1116
|
return fail("A retained batch was refused. No new envelope or nonce was created; resolve the refusal before pushing again.");
|
|
1105
1117
|
const now = runtime.workspaceNow ?? workspaceClock.now();
|
|
1106
1118
|
const loaded = status.pending ? undefined : runtime.workspaceLoadCalls ? await runtime.workspaceLoadCalls()
|
|
1107
|
-
: await loadLocalAgentFinancialUsage({ workspaceDailyFacts: true, sinceIso: workspaceClock.daysBefore(now, 30)
|
|
1108
|
-
|
|
1109
|
-
|
|
1119
|
+
: await loadLocalAgentFinancialUsage({ workspaceDailyFacts: true, sinceIso: workspaceClock.daysBefore(now, 30),
|
|
1120
|
+
untilIso: `${now.slice(0, 10)}T00:00:00.000Z` });
|
|
1121
|
+
const representedUnknowns = loaded?.diagnostics.filter(item => item.code === "unsupported_token_shape"
|
|
1122
|
+
&& item.workspaceFactCoverage === "unknown_tokens").reduce((count, item) => count + item.count, 0) ?? 0;
|
|
1123
|
+
const blockingDiagnostics = loaded?.diagnostics.filter(item => item.code !== "directory_missing"
|
|
1124
|
+
&& !(item.code === "unsupported_token_shape" && item.workspaceFactCoverage === "unknown_tokens")) ?? [];
|
|
1125
|
+
if (blockingDiagnostics.length) {
|
|
1126
|
+
const counts = new Map();
|
|
1127
|
+
for (const diagnostic of blockingDiagnostics) {
|
|
1128
|
+
const label = `${localAgentFormatLabel(diagnostic.agent)}: ${diagnostic.code}${diagnostic.workspaceReason ? ` (${diagnostic.workspaceReason})` : ""}`;
|
|
1129
|
+
counts.set(label, (counts.get(label) ?? 0) + diagnostic.count);
|
|
1130
|
+
}
|
|
1131
|
+
return fail(["Local source reading is incomplete. No facts were replaced or sent.",
|
|
1132
|
+
"Local scan diagnostics (counts, without paths or transcript contents):",
|
|
1133
|
+
...[...counts].sort(([left], [right]) => left.localeCompare(right)).map(([label, count]) => `${label}: ${count}`),
|
|
1134
|
+
"Share these diagnostic counts for help before retrying. Do not delete logs or reconnect this machine."].join("\n"));
|
|
1135
|
+
}
|
|
1110
1136
|
const prepared = await prepareWorkspacePush({ home: runtime.homeDirectory, calls: loaded?.calls ?? [], generatedAt: now,
|
|
1111
1137
|
confirm: (payload, coverage) => consent(["Exact outgoing local facts (no prompts, paths, session IDs, or amounts):", payload,
|
|
1112
1138
|
`Excluded calls: ${coverage.excludedCalls}. Missing token components: ${coverage.incompleteComponents}.`,
|
|
1139
|
+
...(representedUnknowns ? [`${representedUnknowns} incomplete local usage records are represented by unknown token values, never zero.`] : []),
|
|
1113
1140
|
status.pending?.state === "uncertain" ? "This retries only the identical retained signed batch with its original nonce. Send this exact batch again? [y/N] "
|
|
1114
1141
|
: "Machine tokens remain separate from provider-reported tokens and billed costs. Send this batch? [y/N] "].join("\n")) });
|
|
1115
1142
|
if (prepared.state === "unchanged")
|
|
@@ -1125,8 +1152,12 @@ async function workspaceCommand(args, runtime) {
|
|
|
1125
1152
|
: sent.state === "refused" ? fail("Workspace refused this batch. Its exact envelope and refusal are retained; no new nonce was created.")
|
|
1126
1153
|
: fail("Push outcome is unknown. The exact signed envelope is retained; run workspace push to review and explicitly retry this exact batch.");
|
|
1127
1154
|
}
|
|
1128
|
-
catch {
|
|
1155
|
+
catch (error) {
|
|
1129
1156
|
// Never echo remote payloads, raw argument bundles, tokens or filesystem contents.
|
|
1157
|
+
if (error instanceof WorkspaceEnrollmentRefusal)
|
|
1158
|
+
return fail(error.reason === "connected"
|
|
1159
|
+
? "This machine is already connected. Disconnect it before starting another connection request."
|
|
1160
|
+
: `The earlier exchange outcome is unknown. Local keys remain. Do not restart or replay; reconcile this machine at ${WORKSPACE_ORIGIN}/settings/machines.`);
|
|
1130
1161
|
return fail("Workspace operation stopped safely. Local state was not reset; inspect pairing status and permissions before continuing.");
|
|
1131
1162
|
}
|
|
1132
1163
|
}
|
|
@@ -6721,8 +6752,12 @@ function parseArgs(argv) {
|
|
|
6721
6752
|
parsed.workspaceAction = rest.shift();
|
|
6722
6753
|
if (parsed.workspaceAction === "connect" && rest[0] && !rest[0].startsWith("--"))
|
|
6723
6754
|
parsed.workspaceCode = rest.shift();
|
|
6755
|
+
if (parsed.workspaceAction === "connect" && !parsed.workspaceCode && rest.length === 1 && rest[0] === "--restart") {
|
|
6756
|
+
parsed.workspaceRestart = true;
|
|
6757
|
+
rest.shift();
|
|
6758
|
+
}
|
|
6724
6759
|
if (rest.length)
|
|
6725
|
-
parsed.parseErrors.push("workspace accepts only connect [response-bundle], push, status, or disconnect");
|
|
6760
|
+
parsed.parseErrors.push("workspace accepts only connect [response-bundle], connect --restart, push, status, or disconnect");
|
|
6726
6761
|
}
|
|
6727
6762
|
if (command === "statusline" && rest[0] && !rest[0].startsWith("--")) {
|
|
6728
6763
|
parsed.statuslineAction = rest.shift();
|
|
@@ -7389,6 +7424,7 @@ function helpText(telemetryDisclosure) {
|
|
|
7389
7424
|
"",
|
|
7390
7425
|
"Optional Workspace machine attribution (explicit consent, no provider calls):",
|
|
7391
7426
|
" npx aibill workspace connect Prepare this machine's public pairing request",
|
|
7427
|
+
" npx aibill workspace connect --restart Replace an unused request after browser expiry or cancellation",
|
|
7392
7428
|
" npx aibill workspace connect <code> Finish the browser-confirmed pairing",
|
|
7393
7429
|
" npx aibill workspace push Preview and send local session facts",
|
|
7394
7430
|
" npx aibill workspace status Read local pairing/pending status only",
|
|
@@ -92,6 +92,10 @@ export declare function aggregateWorkspaceFacts(calls: readonly LocalAgentCall[]
|
|
|
92
92
|
export declare function buildWorkspaceEnvelope(device: WorkspaceDevice, facts: WorkspaceFact[], generatedAt: string): WorkspaceEnvelope;
|
|
93
93
|
export type WorkspaceStatus = {
|
|
94
94
|
state: "not_connected";
|
|
95
|
+
} | {
|
|
96
|
+
state: "enrollment_pending";
|
|
97
|
+
enrollment: "prepared" | "exchange_uncertain";
|
|
98
|
+
requestId: string;
|
|
95
99
|
} | {
|
|
96
100
|
state: "connected";
|
|
97
101
|
origin: string;
|
|
@@ -191,10 +195,17 @@ export type WorkspaceEnrollmentResponse = {
|
|
|
191
195
|
};
|
|
192
196
|
};
|
|
193
197
|
export declare function encodeWorkspaceBundle(value: WorkspaceEnrollmentRequest | WorkspaceEnrollmentResponse): string;
|
|
198
|
+
export declare class WorkspaceEnrollmentRefusal extends Error {
|
|
199
|
+
readonly reason: "connected" | "exchange_uncertain";
|
|
200
|
+
constructor(reason: "connected" | "exchange_uncertain");
|
|
201
|
+
}
|
|
194
202
|
/** Generates native custody before displaying public enrollment material. No network call. */
|
|
195
|
-
export declare function beginWorkspaceEnrollment(home?: string
|
|
203
|
+
export declare function beginWorkspaceEnrollment(home?: string, options?: {
|
|
204
|
+
restart?: boolean;
|
|
205
|
+
}): Promise<{
|
|
196
206
|
request: WorkspaceEnrollmentRequest;
|
|
197
207
|
bundle: string;
|
|
208
|
+
disposition: "created" | "reused" | "restarted";
|
|
198
209
|
}>;
|
|
199
210
|
export declare function validateWorkspaceEnrollmentResponse(request: WorkspaceEnrollmentRequest, response: WorkspaceEnrollmentResponse, now: string): string;
|
|
200
211
|
/** Exchange is marked uncertain durably before dispatch and is never retried implicitly. */
|
package/dist/workspaceConnect.js
CHANGED
|
@@ -358,10 +358,14 @@ export async function workspaceStatus(home = homedir()) {
|
|
|
358
358
|
throw error;
|
|
359
359
|
}
|
|
360
360
|
const state = await readState(path);
|
|
361
|
-
|
|
361
|
+
if (!state) {
|
|
362
|
+
const enrollment = await readEnrollment(path);
|
|
363
|
+
return enrollment ? { state: "enrollment_pending", enrollment: enrollment.state, requestId: enrollment.request.requestId }
|
|
364
|
+
: { state: "not_connected" };
|
|
365
|
+
}
|
|
366
|
+
return { state: "connected", origin: state.device.origin, lastAcceptedAt: state.lastAcceptedAt,
|
|
362
367
|
acknowledgedFacts: Object.keys(state.facts).length, disconnect: state.disconnect?.state ?? null, pending: state.pending ? { state: state.pending.state,
|
|
363
|
-
factCount: state.pending.envelope.facts.length, generatedAt: state.pending.envelope.generatedAt, refusal: state.pending.refusal } : null }
|
|
364
|
-
: { state: "not_connected" };
|
|
368
|
+
factCount: state.pending.envelope.facts.length, generatedAt: state.pending.envelope.generatedAt, refusal: state.pending.refusal } : null };
|
|
365
369
|
}
|
|
366
370
|
/** HTTPS native transport avoids browser Origin/Sec-Fetch headers and never follows redirects. */
|
|
367
371
|
export const workspaceTransport = (path, payload, token) => workspaceNativeRequest(path, payload, token);
|
|
@@ -567,22 +571,29 @@ async function readEnrollment(path) {
|
|
|
567
571
|
await handle.close();
|
|
568
572
|
}
|
|
569
573
|
}
|
|
574
|
+
export class WorkspaceEnrollmentRefusal extends Error {
|
|
575
|
+
reason;
|
|
576
|
+
constructor(reason) {
|
|
577
|
+
super("Workspace enrollment unavailable");
|
|
578
|
+
this.reason = reason;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
570
581
|
/** Generates native custody before displaying public enrollment material. No network call. */
|
|
571
|
-
export async function beginWorkspaceEnrollment(home = homedir()) {
|
|
582
|
+
export async function beginWorkspaceEnrollment(home = homedir(), options = {}) {
|
|
572
583
|
return withState(home, async (device, _save, path) => {
|
|
573
584
|
if (device)
|
|
574
|
-
throw
|
|
585
|
+
throw new WorkspaceEnrollmentRefusal("connected");
|
|
575
586
|
const retained = await readEnrollment(path);
|
|
576
587
|
if (retained?.state === "exchange_uncertain")
|
|
577
|
-
throw
|
|
578
|
-
if (retained)
|
|
579
|
-
return { request: retained.request, bundle: encodeWorkspaceBundle(retained.request) };
|
|
588
|
+
throw new WorkspaceEnrollmentRefusal("exchange_uncertain");
|
|
589
|
+
if (retained && !options.restart)
|
|
590
|
+
return { request: retained.request, bundle: encodeWorkspaceBundle(retained.request), disposition: "reused" };
|
|
580
591
|
const pair = generateKeyPairSync("ed25519"), ref = () => `oref_${randomBytes(32).toString("base64url")}`;
|
|
581
592
|
const request = { schemaVersion: "1", kind: "tilden_machine_enrollment_request", origin: WORKSPACE_ORIGIN,
|
|
582
593
|
publicKey: pair.publicKey.export({ format: "jwk" }).x, keyId: ref(), requestId: ref() };
|
|
583
594
|
await atomicPrivateJson(path, ENROLLMENT_FILE, { version: 1, request,
|
|
584
595
|
privateKeyPem: pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), state: "prepared" });
|
|
585
|
-
return { request, bundle: encodeWorkspaceBundle(request) };
|
|
596
|
+
return { request, bundle: encodeWorkspaceBundle(request), disposition: retained ? "restarted" : "created" };
|
|
586
597
|
});
|
|
587
598
|
}
|
|
588
599
|
export function validateWorkspaceEnrollmentResponse(request, response, now) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10",
|
|
4
4
|
"funding": "https://asktilden.com",
|
|
5
5
|
"description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
|
|
6
6
|
"type": "module",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"prepack": "npm run build"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@agent-finops/core": "0.9.
|
|
59
|
-
"@agent-finops/report": "0.9.
|
|
58
|
+
"@agent-finops/core": "0.9.10",
|
|
59
|
+
"@agent-finops/report": "0.9.10",
|
|
60
60
|
"yocto-spinner": "^1.2.0"
|
|
61
61
|
}
|
|
62
62
|
}
|