praxis-agent 0.20.3 → 0.20.4

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.
@@ -1,5 +1,5 @@
1
1
  import type { ModelUsage } from '../core/runtime.js';
2
- import type { ClaudeSessionCostState } from '../persistence/claude-cost-state-store.js';
2
+ import type { ClaudeSessionCostState, ClaudeStoredModelCostUsage } from '../persistence/claude-cost-state-store.js';
3
3
  export interface ClaudeSessionCostTrackerOptions {
4
4
  readonly sessionId: string;
5
5
  readonly restored?: ClaudeSessionCostState | null;
@@ -25,7 +25,16 @@ export interface ClaudeSessionDurationsInput {
25
25
  readonly apiDurationWithoutRetriesMs?: number;
26
26
  readonly toolDurationMs?: number;
27
27
  }
28
- export interface ClaudeSessionCostSnapshot extends ClaudeSessionCostState {
28
+ export interface ClaudeSessionCostSnapshot {
29
+ readonly sessionId: string;
30
+ readonly totalCostUsd: number;
31
+ readonly apiDurationMs: number;
32
+ readonly apiDurationWithoutRetriesMs: number;
33
+ readonly toolDurationMs: number;
34
+ readonly wallDurationMs: number;
35
+ readonly linesAdded: number;
36
+ readonly linesRemoved: number;
37
+ readonly modelUsage: Readonly<Record<string, ClaudeStoredModelCostUsage>>;
29
38
  readonly hasUnknownModelCost: boolean;
30
39
  }
31
40
  export declare class ClaudeSessionCostTracker {
@@ -71,6 +71,7 @@ export class ClaudeSessionCostTracker {
71
71
  this.toolDurationMs = restored.toolDurationMs;
72
72
  this.linesAdded = restored.linesAdded;
73
73
  this.linesRemoved = restored.linesRemoved;
74
+ this.hasUnknownModelCost = restored.hasUnknownModelCost === true;
74
75
  for (const [model, usage] of Object.entries(restored.modelUsage)) {
75
76
  this.modelUsage.set(model, {
76
77
  inputTokens: usage.inputTokens,
package/dist/cli.js CHANGED
@@ -776,6 +776,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
776
776
  const costStateStore = new ClaudeCostStateStore({
777
777
  statePath: claudeStatePath,
778
778
  projectIdentity: await resolveClaudeProjectIdentity({ cwd }),
779
+ sidecarPath: join(configRoot, 'praxis', 'unknown-cost-sidecar.json'),
779
780
  });
780
781
  const options = {
781
782
  configRoot,
@@ -16,11 +16,14 @@ export interface ClaudeSessionCostState {
16
16
  readonly linesAdded: number;
17
17
  readonly linesRemoved: number;
18
18
  readonly modelUsage: Readonly<Record<string, ClaudeStoredModelCostUsage>>;
19
+ readonly hasUnknownModelCost?: boolean;
19
20
  }
20
21
  export interface ClaudeCostStateStoreOptions {
21
22
  statePath: string;
22
23
  projectIdentity: string;
23
24
  lockFile?: string;
25
+ sidecarPath?: string;
26
+ sidecarLockFile?: string;
24
27
  }
25
28
  export interface ClaudeCostStateSaveHooks {
26
29
  readonly afterValidation?: () => Promise<void>;
@@ -30,6 +33,7 @@ export declare class ClaudeCostStateStore {
30
33
  readonly projectIdentity: string;
31
34
  readonly lockFile: string;
32
35
  private readonly lease;
36
+ private readonly sidecar;
33
37
  constructor(options: ClaudeCostStateStoreOptions);
34
38
  load(sessionId: string): Promise<ClaudeSessionCostState | null>;
35
39
  save(state: ClaudeSessionCostState, hooks?: ClaudeCostStateSaveHooks): Promise<void>;
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { setTimeout as sleep } from 'node:timers/promises';
6
6
  import { writeFileAtomically } from '../platform/atomic-write.js';
7
7
  import { ExclusiveFileLease } from '../platform/exclusive-file-lease.js';
8
+ import { UnknownCostSidecar } from './unknown-cost-sidecar.js';
8
9
  function isErrnoException(err) {
9
10
  return (typeof err === 'object' &&
10
11
  err !== null &&
@@ -64,6 +65,7 @@ export class ClaudeCostStateStore {
64
65
  projectIdentity;
65
66
  lockFile;
66
67
  lease;
68
+ sidecar;
67
69
  constructor(options) {
68
70
  assertNonBlank(options.statePath, 'statePath');
69
71
  assertNonBlank(options.projectIdentity, 'projectIdentity');
@@ -73,6 +75,15 @@ export class ClaudeCostStateStore {
73
75
  options.lockFile ??
74
76
  join(dirname(options.statePath), '.praxis-claude-state.lock');
75
77
  this.lease = new ExclusiveFileLease(this.lockFile);
78
+ this.sidecar =
79
+ options.sidecarPath === undefined
80
+ ? null
81
+ : new UnknownCostSidecar({
82
+ sidecarPath: options.sidecarPath,
83
+ ...(options.sidecarLockFile === undefined
84
+ ? {}
85
+ : { lockFile: options.sidecarLockFile }),
86
+ });
76
87
  }
77
88
  async load(sessionId) {
78
89
  assertNonBlank(sessionId, 'sessionId');
@@ -80,7 +91,14 @@ export class ClaudeCostStateStore {
80
91
  if (content === null) {
81
92
  return null;
82
93
  }
83
- return this.restoreMatchingSession(content, sessionId);
94
+ const native = this.restoreMatchingSession(content, sessionId);
95
+ if (native === null) {
96
+ return null;
97
+ }
98
+ const hasUnknownModelCost = this.sidecar
99
+ ? await this.sidecar.readFlag(sessionId)
100
+ : false;
101
+ return { ...native, hasUnknownModelCost };
84
102
  }
85
103
  async save(state, hooks = {}) {
86
104
  this.validateState(state);
@@ -92,6 +110,7 @@ export class ClaudeCostStateStore {
92
110
  finally {
93
111
  await handle.release();
94
112
  }
113
+ await this.sidecar?.writeFlag(state.sessionId, state.hasUnknownModelCost ?? false);
95
114
  }
96
115
  async readStateFile() {
97
116
  let handle;
@@ -161,6 +180,10 @@ export class ClaudeCostStateStore {
161
180
  requireMetric(state.wallDurationMs, 'wallDurationMs');
162
181
  requireCounter(state.linesAdded, 'linesAdded');
163
182
  requireCounter(state.linesRemoved, 'linesRemoved');
183
+ if (state.hasUnknownModelCost !== undefined &&
184
+ typeof state.hasUnknownModelCost !== 'boolean') {
185
+ throw new TypeError('hasUnknownModelCost must be a boolean');
186
+ }
164
187
  const rawUsage = requireObject(state.modelUsage, 'modelUsage');
165
188
  for (const [modelKey, rawValue] of Object.entries(rawUsage)) {
166
189
  assertNonBlank(modelKey, `modelUsage.${modelKey}`);
@@ -0,0 +1,23 @@
1
+ export interface UnknownCostSidecarOptions {
2
+ sidecarPath: string;
3
+ lockFile?: string;
4
+ }
5
+ /**
6
+ * Praxis-private sidecar for the `/cost` unknown-model flag. The native
7
+ * `.claude.json` project record must stay Claude-compatible, so this flag lives
8
+ * in a disposable JSON document keyed by session ID. Reads fail closed to
9
+ * `false` when the sidecar is missing or malformed.
10
+ */
11
+ export declare class UnknownCostSidecar {
12
+ private readonly sidecarPath;
13
+ private readonly lockFile;
14
+ private readonly lease;
15
+ constructor(options: UnknownCostSidecarOptions);
16
+ readFlag(sessionId: string): Promise<boolean>;
17
+ writeFlag(sessionId: string, flag: boolean): Promise<void>;
18
+ private readDocument;
19
+ private readContent;
20
+ private parseDocument;
21
+ private acquireLease;
22
+ }
23
+ //# sourceMappingURL=unknown-cost-sidecar.d.ts.map
@@ -0,0 +1,125 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { writeFileAtomically } from '../platform/atomic-write.js';
6
+ import { ExclusiveFileLease } from '../platform/exclusive-file-lease.js';
7
+ const SIDECAR_VERSION = 1;
8
+ function isObject(value) {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ }
11
+ function assertNonBlank(value, field) {
12
+ if (typeof value !== 'string' || value.trim() === '') {
13
+ throw new TypeError(`${field} must not be blank`);
14
+ }
15
+ }
16
+ function emptyDocument() {
17
+ return { version: SIDECAR_VERSION, sessions: {} };
18
+ }
19
+ /**
20
+ * Praxis-private sidecar for the `/cost` unknown-model flag. The native
21
+ * `.claude.json` project record must stay Claude-compatible, so this flag lives
22
+ * in a disposable JSON document keyed by session ID. Reads fail closed to
23
+ * `false` when the sidecar is missing or malformed.
24
+ */
25
+ export class UnknownCostSidecar {
26
+ sidecarPath;
27
+ lockFile;
28
+ lease;
29
+ constructor(options) {
30
+ assertNonBlank(options.sidecarPath, 'sidecarPath');
31
+ this.sidecarPath = options.sidecarPath;
32
+ this.lockFile =
33
+ options.lockFile ??
34
+ join(dirname(options.sidecarPath), 'locks', 'unknown-cost-sidecar.lock');
35
+ this.lease = new ExclusiveFileLease(this.lockFile);
36
+ }
37
+ async readFlag(sessionId) {
38
+ assertNonBlank(sessionId, 'sessionId');
39
+ const document = await this.readDocument();
40
+ if (document === null)
41
+ return false;
42
+ return document.sessions[sessionId]?.hasUnknownModelCost === true;
43
+ }
44
+ async writeFlag(sessionId, flag) {
45
+ assertNonBlank(sessionId, 'sessionId');
46
+ if (typeof flag !== 'boolean') {
47
+ throw new TypeError('flag must be a boolean');
48
+ }
49
+ const handle = await this.acquireLease();
50
+ try {
51
+ const document = (await this.readDocument()) ?? emptyDocument();
52
+ const sessions = {
53
+ ...document.sessions,
54
+ [sessionId]: { hasUnknownModelCost: flag },
55
+ };
56
+ const content = `${JSON.stringify({ version: SIDECAR_VERSION, sessions }, null, 2)}\n`;
57
+ await writeFileAtomically(this.sidecarPath, content, { mode: 0o600 });
58
+ }
59
+ finally {
60
+ await handle.release();
61
+ }
62
+ }
63
+ async readDocument() {
64
+ const content = await this.readContent();
65
+ if (content === null)
66
+ return null;
67
+ return this.parseDocument(content);
68
+ }
69
+ async readContent() {
70
+ let handle;
71
+ try {
72
+ handle = await open(this.sidecarPath, constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ try {
78
+ const stat = await handle.stat();
79
+ if (!stat.isFile())
80
+ return null;
81
+ return await handle.readFile('utf8');
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ finally {
87
+ await handle.close().catch(() => undefined);
88
+ }
89
+ }
90
+ parseDocument(content) {
91
+ let root;
92
+ try {
93
+ root = JSON.parse(content);
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ if (!isObject(root) || root.version !== SIDECAR_VERSION)
99
+ return null;
100
+ const rawSessions = root.sessions;
101
+ if (!isObject(rawSessions))
102
+ return null;
103
+ const sessions = {};
104
+ for (const [sessionId, value] of Object.entries(rawSessions)) {
105
+ if (sessionId.trim() === '')
106
+ return null;
107
+ if (!isObject(value))
108
+ return null;
109
+ if (typeof value.hasUnknownModelCost !== 'boolean')
110
+ return null;
111
+ sessions[sessionId] = { hasUnknownModelCost: value.hasUnknownModelCost };
112
+ }
113
+ return { version: SIDECAR_VERSION, sessions };
114
+ }
115
+ async acquireLease() {
116
+ for (let attempt = 0; attempt < 400; attempt += 1) {
117
+ const handle = await this.lease.tryAcquire();
118
+ if (handle)
119
+ return handle;
120
+ await sleep(5);
121
+ }
122
+ throw new Error(`Timed out acquiring unknown-cost sidecar lock for ${this.sidecarPath}`);
123
+ }
124
+ }
125
+ //# sourceMappingURL=unknown-cost-sidecar.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.20.3",
3
+ "version": "0.20.4",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",