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 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("Create a private device key and display its public enrollment request? No session facts are sent. [y/N] "))
1074
- return ok("Not paired.");
1075
- const prepared = await beginWorkspaceEnrollment(runtime.homeDirectory);
1076
- return ok([`Open ${WORKSPACE_ORIGIN}/settings/machines, paste this public request and confirm enrollment:`, prepared.bundle,
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" ? ok("Pairing kept.") : result.state === "not_paired" ? ok("No completed local pairing exists.")
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
- if (loaded?.diagnostics.some(item => item.code !== "directory_missing"))
1109
- return fail("Local source reading is incomplete. No facts were replaced or sent; resolve the reported source coverage before pushing.");
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): Promise<{
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. */
@@ -358,10 +358,14 @@ export async function workspaceStatus(home = homedir()) {
358
358
  throw error;
359
359
  }
360
360
  const state = await readState(path);
361
- return state ? { state: "connected", origin: state.device.origin, lastAcceptedAt: state.lastAcceptedAt,
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 Error("This machine is already connected. Disconnect it in the Workspace before pairing it again.");
585
+ throw new WorkspaceEnrollmentRefusal("connected");
575
586
  const retained = await readEnrollment(path);
576
587
  if (retained?.state === "exchange_uncertain")
577
- throw Error("The earlier exchange outcome is unknown. Revoke that enrollment in the Workspace before starting again.");
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.8",
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.8",
59
- "@agent-finops/report": "0.9.8",
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
  }