praxis-agent 0.24.0 → 0.25.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.
@@ -85,11 +85,21 @@ export declare class SessionMemoryController {
85
85
  * The persisted counters only advance once an extraction succeeds.
86
86
  */
87
87
  observeDelta(inputTokens: number, toolCalls: number, messageId: string, messages?: readonly ModelMessage[]): Promise<boolean>;
88
+ /**
89
+ * Records the observed totals and starts an extraction when the fixed
90
+ * eligibility contract is met. Returns true when an extraction is running or
91
+ * was just scheduled; callers on normal turns never await the extraction.
92
+ */
93
+ private scheduleExtraction;
88
94
  /** Safe snapshot of the loaded durable summary; empty when none exists. */
89
95
  summary(): Promise<string>;
90
96
  /** Safe snapshot of the loaded session memory state. */
91
97
  state(): Promise<SessionMemoryState>;
92
- /** Resolves when no extraction is running; rejects on failure or timeout. */
98
+ /**
99
+ * Resolves when no extraction is running; rejects on extraction failure.
100
+ * Compact callers wait softly: if extraction outlives the bounded
101
+ * `waitTimeoutMs`, this resolves so compaction can proceed anyway.
102
+ */
93
103
  waitForIdle(): Promise<void>;
94
104
  clear(): Promise<void>;
95
105
  private ensureLoaded;
@@ -2,6 +2,8 @@ import { readFile, rm } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { isClaudeSessionId } from '../compatibility/claude/paths.js';
4
4
  import { writeFileAtomically } from '../platform/atomic-write.js';
5
+ /** A persisted extraction this old is recovered as stale and safely re-extracted. */
6
+ const STALE_EXTRACTION_THRESHOLD_MS = 60_000;
5
7
  export class SessionMemoryStateError extends Error {
6
8
  name = 'SessionMemoryStateError';
7
9
  constructor(message) {
@@ -170,8 +172,8 @@ export class SessionMemoryController {
170
172
  this.options = options;
171
173
  this.initTokens = options.initTokens ?? 10_000;
172
174
  this.updateTokens = options.updateTokens ?? 5_000;
173
- this.updateToolCalls = options.updateToolCalls ?? 20;
174
- this.waitTimeoutMs = options.waitTimeoutMs ?? 30_000;
175
+ this.updateToolCalls = options.updateToolCalls ?? 3;
176
+ this.waitTimeoutMs = options.waitTimeoutMs ?? 15_000;
175
177
  for (const [name, value] of [
176
178
  ['initTokens', this.initTokens],
177
179
  ['updateTokens', this.updateTokens],
@@ -204,22 +206,10 @@ export class SessionMemoryController {
204
206
  toolCalls < state.lastObservedToolCalls) {
205
207
  throw new SessionMemoryStateError(`Session memory observed counters regressed (tokens ${tokens} < ${state.lastObservedTokens}, toolCalls ${toolCalls} < ${state.lastObservedToolCalls})`);
206
208
  }
207
- this.observedTokens = Math.max(this.observedTokens, tokens);
208
- this.observedToolCalls = Math.max(this.observedToolCalls, toolCalls);
209
- if (!this.isExtractionDue(this.observedTokens, this.observedToolCalls)) {
210
- return false;
211
- }
212
- if (this.inFlight === null) {
213
- const extraction = this.runExtraction(this.observedTokens, this.observedToolCalls, messageId, messages);
214
- this.inFlight = extraction;
215
- extraction
216
- .catch(() => undefined)
217
- .finally(() => {
218
- if (this.inFlight === extraction)
219
- this.inFlight = null;
220
- });
221
- }
222
- return true;
209
+ // A direct absolute observation reports a natural break when no tool calls
210
+ // have accumulated since the last successful extraction.
211
+ const naturalBreak = toolCalls === state.lastObservedToolCalls;
212
+ return this.scheduleExtraction(tokens, toolCalls, messageId, messages, naturalBreak);
223
213
  }
224
214
  /**
225
215
  * Adds non-negative deltas to the current cumulative observed totals and
@@ -241,7 +231,32 @@ export class SessionMemoryController {
241
231
  }
242
232
  this.observedTokens += inputTokens;
243
233
  this.observedToolCalls += toolCalls;
244
- return this.observe(this.observedTokens, this.observedToolCalls, messageId, messages);
234
+ // A zero-tool-call turn is a natural break: the last assistant turn made
235
+ // no tool calls.
236
+ return this.scheduleExtraction(this.observedTokens, this.observedToolCalls, messageId, messages, toolCalls === 0);
237
+ }
238
+ /**
239
+ * Records the observed totals and starts an extraction when the fixed
240
+ * eligibility contract is met. Returns true when an extraction is running or
241
+ * was just scheduled; callers on normal turns never await the extraction.
242
+ */
243
+ scheduleExtraction(tokens, toolCalls, messageId, messages, naturalBreak) {
244
+ this.observedTokens = Math.max(this.observedTokens, tokens);
245
+ this.observedToolCalls = Math.max(this.observedToolCalls, toolCalls);
246
+ if (!this.isExtractionDue(this.observedTokens, this.observedToolCalls, naturalBreak)) {
247
+ return false;
248
+ }
249
+ if (this.inFlight === null) {
250
+ const extraction = this.runExtraction(this.observedTokens, this.observedToolCalls, messageId, messages);
251
+ this.inFlight = extraction;
252
+ extraction
253
+ .catch(() => undefined)
254
+ .finally(() => {
255
+ if (this.inFlight === extraction)
256
+ this.inFlight = null;
257
+ });
258
+ }
259
+ return true;
245
260
  }
246
261
  /** Safe snapshot of the loaded durable summary; empty when none exists. */
247
262
  async summary() {
@@ -256,15 +271,19 @@ export class SessionMemoryController {
256
271
  }
257
272
  return { ...this.stateValue };
258
273
  }
259
- /** Resolves when no extraction is running; rejects on failure or timeout. */
274
+ /**
275
+ * Resolves when no extraction is running; rejects on extraction failure.
276
+ * Compact callers wait softly: if extraction outlives the bounded
277
+ * `waitTimeoutMs`, this resolves so compaction can proceed anyway.
278
+ */
260
279
  async waitForIdle() {
261
280
  await this.ensureLoaded();
262
281
  const extraction = this.inFlight;
263
282
  if (extraction === null)
264
283
  return;
265
284
  let timer;
266
- const timeout = new Promise((_, reject) => {
267
- timer = setTimeout(() => reject(new SessionMemoryTimeoutError(`Session memory extraction did not complete within ${this.waitTimeoutMs}ms`)), this.waitTimeoutMs);
285
+ const timeout = new Promise((resolve) => {
286
+ timer = setTimeout(resolve, this.waitTimeoutMs);
268
287
  });
269
288
  try {
270
289
  await Promise.race([extraction, timeout]);
@@ -307,7 +326,7 @@ export class SessionMemoryController {
307
326
  ...state,
308
327
  extractionStartedAt: null,
309
328
  extractionCompletedAt: null,
310
- extractionError: elapsed >= this.waitTimeoutMs
329
+ extractionError: elapsed >= STALE_EXTRACTION_THRESHOLD_MS
311
330
  ? `Session memory extraction is stale after ${elapsed}ms`
312
331
  : 'Session memory extraction was interrupted',
313
332
  };
@@ -321,14 +340,18 @@ export class SessionMemoryController {
321
340
  this.observedToolCalls = this.stateValue.lastObservedToolCalls;
322
341
  this.summaryValue = summary;
323
342
  }
324
- isExtractionDue(tokens, toolCalls) {
343
+ isExtractionDue(tokens, toolCalls, naturalBreak) {
325
344
  const state = this.stateValue;
326
345
  if (state === null)
327
346
  return false;
328
347
  if (!state.initialized)
329
348
  return tokens >= this.initTokens;
330
- return (tokens - state.lastObservedTokens >= this.updateTokens ||
331
- toolCalls - state.lastObservedToolCalls >= this.updateToolCalls);
349
+ // Unchanged context must not retrigger; tool-call growth alone is never
350
+ // enough without at least the update-token growth.
351
+ if (tokens - state.lastObservedTokens < this.updateTokens)
352
+ return false;
353
+ return (toolCalls - state.lastObservedToolCalls >= this.updateToolCalls ||
354
+ naturalBreak);
332
355
  }
333
356
  async runExtraction(tokens, toolCalls, messageId, messages) {
334
357
  const state = this.stateValue;
@@ -161,6 +161,7 @@ export interface RewindPoint {
161
161
  fileRestoreAvailable: boolean;
162
162
  }
163
163
  export declare function workflowTokenTarget(prompt: string): number | null;
164
+ export type HookSessionEndReason = 'clear' | 'resume' | 'other';
164
165
  export declare class ClaudeSessionService {
165
166
  private readonly options;
166
167
  private readonly schema;
@@ -182,6 +183,7 @@ export declare class ClaudeSessionService {
182
183
  private activeCostSessionId;
183
184
  private closeCostSavePromise;
184
185
  private readonly sessionMemoryControllers;
186
+ private readonly hookLifecycle;
185
187
  private runtimeCwd;
186
188
  constructor(options: ClaudeSessionServiceOptions);
187
189
  nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
@@ -198,6 +200,7 @@ export declare class ClaudeSessionService {
198
200
  stopTask(sessionId: string, taskId: string): Promise<void>;
199
201
  private applyPermissionUpdates;
200
202
  close(): Promise<void>;
203
+ transitionHookSession(sessionId: string, reason: Exclude<HookSessionEndReason, 'other'>): Promise<void>;
201
204
  createHostedToolRegistry(sessionId: string): ToolRegistry;
202
205
  run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[]): Promise<SessionRunResult>;
203
206
  runShell(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
@@ -319,6 +319,116 @@ function validSessionName(value) {
319
319
  ? name.toLocaleLowerCase()
320
320
  : null;
321
321
  }
322
+ const SESSION_END_HOOK_TIMEOUT_MS = 15_000;
323
+ class HookLifecycle {
324
+ hooks;
325
+ eventSink;
326
+ sessions = new Map();
327
+ pendingSource;
328
+ constructor(hooks, eventSink) {
329
+ this.hooks = hooks;
330
+ this.eventSink = eventSink;
331
+ }
332
+ async start(sessionId, input, fallbackSource, signal) {
333
+ let state = this.sessions.get(sessionId);
334
+ if (state?.started)
335
+ return undefined;
336
+ if (state?.starting)
337
+ return state.starting;
338
+ state = {
339
+ input,
340
+ started: false,
341
+ starting: undefined,
342
+ ending: undefined,
343
+ };
344
+ this.sessions.set(sessionId, state);
345
+ const source = this.pendingSource ?? fallbackSource;
346
+ this.pendingSource = undefined;
347
+ state.starting = this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source }, source, signal);
348
+ try {
349
+ const outcome = await state.starting;
350
+ state.started = true;
351
+ return outcome;
352
+ }
353
+ catch (error) {
354
+ this.sessions.delete(sessionId);
355
+ throw error;
356
+ }
357
+ finally {
358
+ state.starting = undefined;
359
+ }
360
+ }
361
+ async refresh(sessionId, input, signal) {
362
+ const state = this.sessions.get(sessionId);
363
+ if (state)
364
+ state.input = input;
365
+ return this.hooks?.run({ ...input, hook_event_name: 'SessionStart', source: 'compact' }, 'compact', signal);
366
+ }
367
+ async transition(sessionId, reason) {
368
+ await this.end(sessionId, reason);
369
+ this.pendingSource = reason;
370
+ }
371
+ async end(sessionId, reason) {
372
+ const state = this.sessions.get(sessionId);
373
+ if (!state)
374
+ return;
375
+ if (state.starting)
376
+ await state.starting;
377
+ if (!state.started)
378
+ return;
379
+ if (state.ending)
380
+ return state.ending;
381
+ state.ending = this.runEnd(state.input, reason).finally(() => {
382
+ this.sessions.delete(sessionId);
383
+ });
384
+ await state.ending;
385
+ }
386
+ async close() {
387
+ await Promise.all([...this.sessions.keys()].map((sessionId) => this.end(sessionId, 'other')));
388
+ }
389
+ async runEnd(input, reason) {
390
+ if (!this.hooks)
391
+ return;
392
+ const controller = new AbortController();
393
+ let timer;
394
+ try {
395
+ const timeout = new Promise((_resolve, reject) => {
396
+ timer = setTimeout(() => {
397
+ controller.abort();
398
+ reject(new Error(`timed out after ${SESSION_END_HOOK_TIMEOUT_MS}ms`));
399
+ }, SESSION_END_HOOK_TIMEOUT_MS);
400
+ });
401
+ const outcome = await Promise.race([
402
+ this.hooks.run({ ...input, hook_event_name: 'SessionEnd', reason }, reason, controller.signal),
403
+ timeout,
404
+ ]);
405
+ for (const execution of outcome.executions) {
406
+ if (execution.exitCode === 0)
407
+ continue;
408
+ const detail = execution.stderr.trim() ||
409
+ execution.stdout.trim() ||
410
+ `exit code ${execution.exitCode}`;
411
+ this.warn(detail);
412
+ }
413
+ if (outcome.blockedReason && outcome.executions.at(-1)?.exitCode === 0) {
414
+ this.warn(outcome.blockedReason);
415
+ }
416
+ }
417
+ catch (error) {
418
+ this.warn(error instanceof Error ? error.message : String(error));
419
+ }
420
+ finally {
421
+ if (timer !== undefined)
422
+ clearTimeout(timer);
423
+ }
424
+ }
425
+ warn(detail) {
426
+ this.eventSink?.({
427
+ type: 'warning',
428
+ message: `SessionEnd hook failed: ${detail}`,
429
+ });
430
+ }
431
+ }
322
432
  export class ClaudeSessionService {
323
433
  options;
324
434
  schema;
@@ -340,9 +450,11 @@ export class ClaudeSessionService {
340
450
  activeCostSessionId;
341
451
  closeCostSavePromise;
342
452
  sessionMemoryControllers = new Map();
453
+ hookLifecycle;
343
454
  runtimeCwd;
344
455
  constructor(options) {
345
456
  this.options = options;
457
+ this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
346
458
  this.runtimeCwd = options.workspace?.cwd() ?? options.cwd;
347
459
  this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
348
460
  this.scheduledPrompts =
@@ -481,6 +593,7 @@ export class ClaudeSessionService {
481
593
  }
482
594
  }
483
595
  async close() {
596
+ await this.hookLifecycle.close();
484
597
  this.scheduledPrompts?.close();
485
598
  await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
486
599
  await Promise.resolve();
@@ -494,6 +607,9 @@ export class ClaudeSessionService {
494
607
  this.mcpClosePromise ??= this.options.mcp?.close?.() ?? Promise.resolve();
495
608
  await this.mcpClosePromise;
496
609
  }
610
+ async transitionHookSession(sessionId, reason) {
611
+ await this.hookLifecycle.transition(sessionId, reason);
612
+ }
497
613
  createHostedToolRegistry(sessionId) {
498
614
  const baseTools = this.options.tools;
499
615
  if (!baseTools)
@@ -755,7 +871,7 @@ export class ClaudeSessionService {
755
871
  ...(onDelta ? { onTextDelta: onDelta } : {}),
756
872
  onMetrics: (recorded) => this.recordAuxiliaryMetrics(activeSessionId, recorded),
757
873
  });
758
- budget?.observeUsage(metrics.usage);
874
+ budget?.observeUsage(metrics.usage, messages);
759
875
  if (metrics.toolCalls.length > 0) {
760
876
  throw new Error('Side questions cannot call tools; press f to fork');
761
877
  }
@@ -2243,17 +2359,11 @@ export class ClaudeSessionService {
2243
2359
  }
2244
2360
  },
2245
2361
  };
2362
+ let turnCompleted = false;
2246
2363
  try {
2247
- if (this.options.hooks) {
2248
- const outcome = await this.options.hooks.run({
2249
- ...hookSession,
2250
- hook_event_name: 'SessionStart',
2251
- source: requireExisting ? 'resume' : 'startup',
2252
- }, requireExisting ? 'resume' : 'startup', signal);
2364
+ const outcome = await this.hookLifecycle.start(sessionId, hookSession, requireExisting ? 'resume' : 'startup', signal);
2365
+ if (outcome) {
2253
2366
  await recordHookOutcome(outcome, pendingRecoveryToolCallIds.size > 0);
2254
- if (outcome.blockedReason) {
2255
- throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
2256
- }
2257
2367
  }
2258
2368
  const approveRecovery = this.options.approveRecovery;
2259
2369
  const recoveryRequest = {
@@ -2620,15 +2730,9 @@ export class ClaudeSessionService {
2620
2730
  // runtime-only context so the next request retains current
2621
2731
  // instructions, plan state, session memory, and hook context.
2622
2732
  if (this.options.hooks) {
2623
- const outcome = await this.options.hooks.run({
2624
- ...hookSession,
2625
- hook_event_name: 'SessionStart',
2626
- source: 'compact',
2627
- }, 'compact', signal);
2628
- await recordHookOutcome(outcome);
2629
- if (outcome.blockedReason) {
2630
- throw new Error(`SessionStart hook error: ${outcome.blockedReason}`);
2631
- }
2733
+ const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
2734
+ if (outcome)
2735
+ await recordHookOutcome(outcome);
2632
2736
  }
2633
2737
  await refreshRuntimeContext();
2634
2738
  this.options.eventSink?.({
@@ -2947,7 +3051,7 @@ export class ClaudeSessionService {
2947
3051
  : undefined) ??
2948
3052
  Object.values(result.modelUsage ?? {})[0] ??
2949
3053
  result.usage;
2950
- budget?.observeUsage(observedUsage);
3054
+ budget?.observeUsage(observedUsage, runtimeRequest.messages, definitions);
2951
3055
  if (structuredCapture && structuredCapture.calls !== 1) {
2952
3056
  throw new Error(`StructuredOutput must be called exactly once (received ${structuredCapture.calls})`);
2953
3057
  }
@@ -3044,19 +3148,23 @@ export class ClaudeSessionService {
3044
3148
  }
3045
3149
  if (sessionMemory && finalLeafUuid) {
3046
3150
  const turnInputTokens = result.usage?.inputTokens ?? 0;
3151
+ const warn = (error) => this.options.eventSink?.({
3152
+ type: 'warning',
3153
+ message: `Session memory extraction failed: ${error instanceof Error ? error.message : String(error)}`,
3154
+ });
3047
3155
  try {
3048
3156
  await sessionMemory.observeDelta(turnInputTokens, currentTurnToolCalls, finalLeafUuid, projectClaudeModelMessages(snapshot.entries));
3049
- await sessionMemory.waitForIdle();
3157
+ // Normal turns schedule extraction without awaiting it; failures
3158
+ // surface as a warning while the sidecar stays retryable.
3159
+ sessionMemory.waitForIdle().catch(warn);
3050
3160
  }
3051
3161
  catch (error) {
3052
- // A failed extraction must not fail the user turn; the sidecar
3162
+ // A failed observation must not fail the user turn; the sidecar
3053
3163
  // retains a retryable error for the next observation.
3054
- this.options.eventSink?.({
3055
- type: 'warning',
3056
- message: `Session memory extraction failed: ${error instanceof Error ? error.message : String(error)}`,
3057
- });
3164
+ warn(error);
3058
3165
  }
3059
3166
  }
3167
+ turnCompleted = true;
3060
3168
  return {
3061
3169
  sessionId,
3062
3170
  text: structuredCapture && structuredCapture.calls === 1
@@ -3074,35 +3182,8 @@ export class ClaudeSessionService {
3074
3182
  };
3075
3183
  }
3076
3184
  finally {
3077
- try {
3078
- const outcome = await this.options.hooks?.run({
3079
- ...hookSession,
3080
- hook_event_name: 'SessionEnd',
3081
- reason: 'other',
3082
- }, 'other');
3083
- const failedExecutions = outcome?.executions.filter((execution) => execution.exitCode !== 0) ?? [];
3084
- for (const execution of failedExecutions) {
3085
- const detail = execution.stderr.trim() ||
3086
- execution.stdout.trim() ||
3087
- `exit code ${execution.exitCode}`;
3088
- this.options.eventSink?.({
3089
- type: 'warning',
3090
- message: `SessionEnd hook failed: ${detail}`,
3091
- });
3092
- }
3093
- if (outcome?.blockedReason &&
3094
- outcome.executions.at(-1)?.exitCode === 0) {
3095
- this.options.eventSink?.({
3096
- type: 'warning',
3097
- message: `SessionEnd hook failed: ${outcome.blockedReason}`,
3098
- });
3099
- }
3100
- }
3101
- catch (error) {
3102
- this.options.eventSink?.({
3103
- type: 'warning',
3104
- message: `SessionEnd hook failed: ${error instanceof Error ? error.message : String(error)}`,
3105
- });
3185
+ if (!turnCompleted) {
3186
+ await this.hookLifecycle.end(sessionId, 'other');
3106
3187
  }
3107
3188
  }
3108
3189
  });
@@ -3774,6 +3855,10 @@ export class ClaudeSessionService {
3774
3855
  return new ContextBudget({
3775
3856
  contextWindowTokens,
3776
3857
  windowSource: 'capability',
3858
+ onAccountingDiagnostic: (message) => this.options.eventSink?.({
3859
+ type: 'warning',
3860
+ message: `Context usage accounting: ${message}`,
3861
+ }),
3777
3862
  ...(this.options.contextReserveTokens === undefined
3778
3863
  ? {}
3779
3864
  : { reserveTokens: this.options.contextReserveTokens }),
@@ -1461,6 +1461,10 @@ export class ClaudeSubagentExecutor {
1461
1461
  const contextBudget = options.provider.capabilities.contextWindowTokens
1462
1462
  ? new ContextBudget({
1463
1463
  contextWindowTokens: options.provider.capabilities.contextWindowTokens,
1464
+ onAccountingDiagnostic: (message) => this.options.eventSink?.({
1465
+ type: 'warning',
1466
+ message: `Context usage accounting: ${message}`,
1467
+ }),
1464
1468
  ...(this.options.contextReserveTokens === undefined
1465
1469
  ? {}
1466
1470
  : { reserveTokens: this.options.contextReserveTokens }),
@@ -1469,6 +1473,7 @@ export class ClaudeSubagentExecutor {
1469
1473
  const definitions = options.provider.capabilities.tools
1470
1474
  ? runtimeTools.definitions()
1471
1475
  : [];
1476
+ let observedMessages;
1472
1477
  const assembleMessages = async () => {
1473
1478
  const assembledContext = await this.options.contextAssembler?.assemble({
1474
1479
  cwd,
@@ -1479,6 +1484,7 @@ export class ClaudeSubagentExecutor {
1479
1484
  ...injectFirstUserMessageContext(projectClaudeModelMessages(snapshot.entries), assembledContext?.firstUserMessageContext),
1480
1485
  ...preloadedSkills,
1481
1486
  ];
1487
+ observedMessages = messages;
1482
1488
  if (contextBudget) {
1483
1489
  contextBudget.assertFits(contextBudget.evaluate(messages, definitions));
1484
1490
  }
@@ -1547,6 +1553,7 @@ export class ClaudeSubagentExecutor {
1547
1553
  : {}),
1548
1554
  ...(options.signal ? { signal: options.signal } : {}),
1549
1555
  });
1556
+ contextBudget?.observeUsage(result.usage, observedMessages ?? [], definitions);
1550
1557
  this.options.eventSink?.({
1551
1558
  type: 'task-progress',
1552
1559
  taskId: options.agentId,
@@ -72,6 +72,7 @@ interface InteractiveSessionCommands {
72
72
  id: string;
73
73
  prompt: string;
74
74
  } | null>;
75
+ transitionHookSession?(sessionId: string, reason: 'clear' | 'resume'): Promise<void>;
75
76
  close?(): Promise<void>;
76
77
  }
77
78
  export interface InteractiveServiceFactory {
@@ -3435,9 +3435,16 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3435
3435
  if (selectingSession) {
3436
3436
  if (key.escape || value === '\u001B') {
3437
3437
  if (pickerIncludesNewSessionRef.current && allowNewSession) {
3438
- setSessionId(null);
3439
- setPendingFork(false);
3440
3438
  setSelectingSession(false);
3439
+ const previousSessionId = sessionIdRef.current;
3440
+ void (async () => {
3441
+ if (previousSessionId) {
3442
+ const commands = await service();
3443
+ await commands.transitionHookSession?.(previousSessionId, 'clear');
3444
+ }
3445
+ openSession(null);
3446
+ setPendingFork(false);
3447
+ })().catch(warn);
3441
3448
  }
3442
3449
  else if (allowNewSession) {
3443
3450
  setSelectingSession(false);
@@ -3462,10 +3469,18 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3462
3469
  const selected = filterSessionChoices(currentPickerChoices, sessionSearchRef.current)[selectedIndexRef.current];
3463
3470
  if (selected === undefined)
3464
3471
  return;
3465
- openSession(selected?.sessionId ?? null);
3466
- if (!selected)
3467
- setPendingFork(false);
3468
3472
  setSelectingSession(false);
3473
+ const nextSessionId = selected?.sessionId ?? null;
3474
+ const previousSessionId = sessionIdRef.current;
3475
+ void (async () => {
3476
+ if (previousSessionId && previousSessionId !== nextSessionId) {
3477
+ const commands = await service();
3478
+ await commands.transitionHookSession?.(previousSessionId, nextSessionId === null ? 'clear' : 'resume');
3479
+ }
3480
+ openSession(nextSessionId);
3481
+ if (!selected)
3482
+ setPendingFork(false);
3483
+ })().catch(warn);
3469
3484
  }
3470
3485
  else if (key.backspace || key.delete) {
3471
3486
  sessionSearchRef.current = sessionSearchRef.current.slice(0, -1);
@@ -5220,28 +5235,40 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5220
5235
  updateMenu({ kind: 'help', tabIndex: 0, selectedIndex: 0 });
5221
5236
  }
5222
5237
  else if (prompt === '/new') {
5223
- statusLineSessionId.current = randomUUID();
5224
- setSessionId(null);
5225
- setSessionColor(undefined);
5226
- setPendingFork(false);
5227
- append({ kind: 'notice', text: 'Started a new session.' });
5238
+ const previousSessionId = sessionIdRef.current;
5239
+ void (async () => {
5240
+ if (previousSessionId) {
5241
+ const commands = await service();
5242
+ await commands.transitionHookSession?.(previousSessionId, 'clear');
5243
+ }
5244
+ statusLineSessionId.current = randomUUID();
5245
+ openSession(null);
5246
+ setPendingFork(false);
5247
+ append({ kind: 'notice', text: 'Started a new session.' });
5248
+ })().catch(warn);
5228
5249
  }
5229
5250
  else if (prompt === '/clear') {
5230
- statusLineSessionId.current = randomUUID();
5231
- setSessionId(null);
5232
- setSessionColor(undefined);
5233
- setPendingFork(false);
5234
- setHistory([]);
5235
- setUsage(undefined);
5236
- setCostUsd(undefined);
5237
- streamingFrameRef.current?.resetText();
5238
- streamingFrameRef.current?.resetThinking();
5239
- streamingFrameRef.current?.flush();
5240
- setThinkingExpanded(false);
5241
- setStatus('ready');
5242
- inputHistoryRef.current = [];
5243
- inputHistoryIndexRef.current = null;
5244
- inputHistoryDraftRef.current = '';
5251
+ const previousSessionId = sessionIdRef.current;
5252
+ void (async () => {
5253
+ if (previousSessionId) {
5254
+ const commands = await service();
5255
+ await commands.transitionHookSession?.(previousSessionId, 'clear');
5256
+ }
5257
+ statusLineSessionId.current = randomUUID();
5258
+ openSession(null);
5259
+ setPendingFork(false);
5260
+ setHistory([]);
5261
+ setUsage(undefined);
5262
+ setCostUsd(undefined);
5263
+ streamingFrameRef.current?.resetText();
5264
+ streamingFrameRef.current?.resetThinking();
5265
+ streamingFrameRef.current?.flush();
5266
+ setThinkingExpanded(false);
5267
+ setStatus('ready');
5268
+ inputHistoryRef.current = [];
5269
+ inputHistoryIndexRef.current = null;
5270
+ inputHistoryDraftRef.current = '';
5271
+ })().catch(warn);
5245
5272
  }
5246
5273
  else if (prompt === '/model') {
5247
5274
  updateMenu({ kind: 'model', selectedIndex: 0 });
@@ -1577,6 +1577,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1577
1577
  rename: (sessionId, name) => service.rename(sessionId, name),
1578
1578
  sessionNameSuggestion: (sessionId, signal) => service.sessionNameSuggestion(sessionId, signal),
1579
1579
  nextScheduledPrompt: (signal) => service.nextScheduledPrompt(signal),
1580
+ transitionHookSession: (sessionId, reason) => service.transitionHookSession(sessionId, reason),
1580
1581
  close: async () => {
1581
1582
  let failure;
1582
1583
  try {
@@ -181,8 +181,10 @@ export function createClaudeNativeFork({ source, sourceSessionId, sessionId, res
181
181
  (metadata.direction === 'from' ||
182
182
  metadata.direction === 'up_to'));
183
183
  });
184
+ const hasCompactHistory = source.some((entry) => entry.isCompactSummary === true ||
185
+ (entry.type === 'system' && entry.subtype === 'compact_boundary'));
184
186
  const activeSource = resumeSessionAt === undefined
185
- ? hasSelectiveSummary
187
+ ? hasCompactHistory || hasSelectiveSummary
186
188
  ? selectClaudeActiveTranscript(source)
187
189
  : source
188
190
  : selectClaudeTranscriptAtMessage(source, resumeSessionAt);
@@ -18,7 +18,11 @@ function latestLeafUuid(entries) {
18
18
  entry.isSidechain !== true);
19
19
  if (!hasNewDescendant)
20
20
  return summary.uuid;
21
- break;
21
+ // After compaction the active leaf continues from the boundary's logical
22
+ // parent. Ignore unrelated physical entries that do not descend from the
23
+ // boundary and keep the compact summary as the leaf when none do.
24
+ const continuation = latestCompactBranchLeafUuid(entries, index + 1, boundary ? entryUuid(boundary) : null, summary.uuid);
25
+ return continuation === null ? summary.uuid : continuation;
22
26
  }
23
27
  for (let index = entries.length - 1; index >= 0; index -= 1) {
24
28
  const entry = entries[index];
@@ -33,6 +37,45 @@ function latestLeafUuid(entries) {
33
37
  }
34
38
  return null;
35
39
  }
40
+ function latestCompactBranchLeafUuid(entries, fromIndex, boundaryUuid, summaryUuid) {
41
+ const byUuid = new Map();
42
+ for (const entry of entries) {
43
+ const uuid = entryUuid(entry);
44
+ if (uuid && entry.isSidechain !== true)
45
+ byUuid.set(uuid, entry);
46
+ }
47
+ for (let index = entries.length - 1; index >= fromIndex; index -= 1) {
48
+ const entry = entries[index];
49
+ if (!entry || entry.isSidechain === true)
50
+ continue;
51
+ const candidate = entry.type === 'last-prompt' && typeof entry.leafUuid === 'string'
52
+ ? entry.leafUuid
53
+ : entryUuid(entry);
54
+ if (candidate === null)
55
+ continue;
56
+ let uuid = candidate;
57
+ const seen = new Set();
58
+ while (uuid !== null) {
59
+ if (uuid === summaryUuid || uuid === boundaryUuid)
60
+ return candidate;
61
+ if (seen.has(uuid))
62
+ break;
63
+ seen.add(uuid);
64
+ const node = byUuid.get(uuid);
65
+ if (!node)
66
+ break;
67
+ uuid =
68
+ node.type === 'system' &&
69
+ node.subtype === 'compact_boundary' &&
70
+ typeof node.logicalParentUuid === 'string'
71
+ ? node.logicalParentUuid
72
+ : typeof node.parentUuid === 'string'
73
+ ? node.parentUuid
74
+ : null;
75
+ }
76
+ }
77
+ return null;
78
+ }
36
79
  function ancestryUuids(entries, leafUuid) {
37
80
  const byUuid = new Map();
38
81
  for (const entry of entries) {
@@ -72,6 +115,9 @@ function selectAncestry(entries, leafUuid) {
72
115
  const uuid = entryUuid(entry);
73
116
  if (uuid) {
74
117
  return (active.has(uuid) ||
118
+ (entry.type === 'attachment' &&
119
+ typeof entry.parentUuid === 'string' &&
120
+ active.has(entry.parentUuid)) ||
75
121
  (entry.type === 'user' &&
76
122
  typeof entry.sourceToolAssistantUUID === 'string' &&
77
123
  active.has(entry.sourceToolAssistantUUID)));
@@ -5,6 +5,9 @@ export interface ContextBudgetOptions {
5
5
  /** Declares whether the configured window came from a provider capability so
6
6
  * reports can distinguish provider-derived decisions from estimates. */
7
7
  windowSource?: 'capability' | 'estimate';
8
+ /** Receives at most one bounded diagnostic when provider accounting is
9
+ * malformed and the deterministic estimate fallback is used. */
10
+ onAccountingDiagnostic?: (message: string) => void;
8
11
  }
9
12
  export interface ContextBudgetEvaluateOptions {
10
13
  /** Most recent provider usage observation; a positive `contextWindow` is
@@ -18,6 +21,11 @@ export interface ContextBudgetEvaluateOptions {
18
21
  export type ContextBudgetSource = 'provider' | 'capability' | 'estimate';
19
22
  export interface ContextBudgetReport {
20
23
  estimatedTokens: number;
24
+ /** Total context occupancy used for overflow accounting: the actual provider
25
+ * input/cache tokens at the observation watermark plus deterministic
26
+ * estimated tokens added after that watermark. Without a usable watermark
27
+ * this equals `estimatedTokens`. */
28
+ occupancyTokens: number;
21
29
  contextWindowTokens: number;
22
30
  reserveTokens: number;
23
31
  availableTokens: number;
@@ -37,10 +45,29 @@ export declare class ContextBudget {
37
45
  readonly reserveTokens: number;
38
46
  readonly windowSource: 'capability' | 'estimate';
39
47
  private observedUsage;
48
+ /** Actual provider input/cache tokens at the most recent usable observation;
49
+ * the watermark that anchors later occupancy. */
50
+ private watermarkActualInputTokens;
51
+ /** Deterministic estimate of the request snapshot captured at observation
52
+ * time; only growth beyond this baseline is added to the watermark. */
53
+ private watermarkBaselineEstimate;
54
+ private accountingDiagnosticEmitted;
55
+ private readonly onAccountingDiagnostic;
40
56
  constructor(options: ContextBudgetOptions);
41
- observeUsage(usage: ModelUsage): void;
57
+ /** Record a completed provider request: `usage` carries the actual token
58
+ * counts and `messages`/`tools` are the exact snapshot used for that
59
+ * request. The snapshot's deterministic estimate becomes the watermark
60
+ * baseline so later evaluations add only post-watermark growth. Malformed
61
+ * usage is ignored (fail-open) and never throws; a valid `contextWindow`
62
+ * still updates the effective window through `observedUsage`. */
63
+ observeUsage(usage: ModelUsage, messages?: readonly ModelMessage[], tools?: readonly ModelToolDefinition[]): void;
42
64
  effectiveContextWindow(usage?: ModelUsage): number;
43
65
  evaluate(messages: readonly ModelMessage[], tools?: readonly ModelToolDefinition[], options?: ContextBudgetEvaluateOptions): ContextBudgetReport;
66
+ /** Occupancy anchored at the actual input/cache watermark, adding only the
67
+ * deterministic estimated growth past the observation baseline. Without a
68
+ * usable watermark this is the plain estimate fallback. */
69
+ private anchoredOccupancyTokens;
70
+ private emitAccountingDiagnostic;
44
71
  assertFits(report: ContextBudgetReport): void;
45
72
  /** Returns the positive provider-reported context window, if any. */
46
73
  private providerContextWindow;
@@ -74,11 +74,38 @@ export function estimateModelRequestTokens(messages, tools = []) {
74
74
  estimateTextTokens(JSON.stringify(tool.inputSchema)), 0);
75
75
  return messageTokens + toolTokens;
76
76
  }
77
+ /** Normalized provider input occupancy counting input and cache-read/creation
78
+ * fields without output tokens. Returns `undefined` for malformed, negative,
79
+ * or non-safe usage so accounting fails open. */
80
+ function normalizedInputAndCacheTokens(usage) {
81
+ if (!Number.isSafeInteger(usage.inputTokens) ||
82
+ usage.inputTokens < 0 ||
83
+ (usage.cacheReadInputTokens !== undefined &&
84
+ (!Number.isSafeInteger(usage.cacheReadInputTokens) ||
85
+ usage.cacheReadInputTokens < 0)) ||
86
+ (usage.cacheCreationInputTokens !== undefined &&
87
+ (!Number.isSafeInteger(usage.cacheCreationInputTokens) ||
88
+ usage.cacheCreationInputTokens < 0))) {
89
+ return undefined;
90
+ }
91
+ const candidate = (usage.inputTokens ?? 0) +
92
+ (usage.cacheReadInputTokens ?? 0) +
93
+ (usage.cacheCreationInputTokens ?? 0);
94
+ return Number.isSafeInteger(candidate) ? candidate : undefined;
95
+ }
77
96
  export class ContextBudget {
78
97
  contextWindowTokens;
79
98
  reserveTokens;
80
99
  windowSource;
81
100
  observedUsage;
101
+ /** Actual provider input/cache tokens at the most recent usable observation;
102
+ * the watermark that anchors later occupancy. */
103
+ watermarkActualInputTokens;
104
+ /** Deterministic estimate of the request snapshot captured at observation
105
+ * time; only growth beyond this baseline is added to the watermark. */
106
+ watermarkBaselineEstimate;
107
+ accountingDiagnosticEmitted = false;
108
+ onAccountingDiagnostic;
82
109
  constructor(options) {
83
110
  requirePositiveInteger(options.contextWindowTokens, 'Context window tokens');
84
111
  const defaultReserve = Math.min(8192, Math.max(1, Math.floor(options.contextWindowTokens / 10)));
@@ -90,15 +117,32 @@ export class ContextBudget {
90
117
  this.contextWindowTokens = options.contextWindowTokens;
91
118
  this.reserveTokens = reserveTokens;
92
119
  this.windowSource = options.windowSource ?? 'estimate';
120
+ this.onAccountingDiagnostic = options.onAccountingDiagnostic;
93
121
  }
94
- observeUsage(usage) {
122
+ /** Record a completed provider request: `usage` carries the actual token
123
+ * counts and `messages`/`tools` are the exact snapshot used for that
124
+ * request. The snapshot's deterministic estimate becomes the watermark
125
+ * baseline so later evaluations add only post-watermark growth. Malformed
126
+ * usage is ignored (fail-open) and never throws; a valid `contextWindow`
127
+ * still updates the effective window through `observedUsage`. */
128
+ observeUsage(usage, messages = [], tools = []) {
95
129
  this.observedUsage = usage;
130
+ const actualInputTokens = normalizedInputAndCacheTokens(usage);
131
+ if (actualInputTokens === undefined) {
132
+ this.emitAccountingDiagnostic();
133
+ return;
134
+ }
135
+ if (messages.length === 0 && tools.length === 0)
136
+ return;
137
+ this.watermarkActualInputTokens = actualInputTokens;
138
+ this.watermarkBaselineEstimate = estimateModelRequestTokens(messages, tools);
96
139
  }
97
140
  effectiveContextWindow(usage) {
98
141
  return this.providerContextWindow(usage) ?? this.contextWindowTokens;
99
142
  }
100
143
  evaluate(messages, tools = [], options = {}) {
101
144
  const estimatedTokens = estimateModelRequestTokens(messages, tools);
145
+ const occupancyTokens = this.anchoredOccupancyTokens(estimatedTokens);
102
146
  const providerWindow = this.providerContextWindow(options.lastUsage);
103
147
  const contextWindowTokens = providerWindow ?? this.contextWindowTokens;
104
148
  const outputTokens = options.outputTokens !== undefined &&
@@ -107,10 +151,11 @@ export class ContextBudget {
107
151
  ? options.outputTokens
108
152
  : 0;
109
153
  const availableTokens = Math.max(0, contextWindowTokens - this.reserveTokens);
110
- const overflowTokens = Math.max(0, estimatedTokens + outputTokens - availableTokens);
154
+ const overflowTokens = Math.max(0, occupancyTokens + outputTokens - availableTokens);
111
155
  const shouldCompact = options.promptTooLong === true || overflowTokens > 0;
112
156
  return {
113
157
  estimatedTokens,
158
+ occupancyTokens,
114
159
  contextWindowTokens,
115
160
  reserveTokens: this.reserveTokens,
116
161
  availableTokens,
@@ -119,6 +164,29 @@ export class ContextBudget {
119
164
  source: providerWindow === undefined ? this.windowSource : 'provider',
120
165
  };
121
166
  }
167
+ /** Occupancy anchored at the actual input/cache watermark, adding only the
168
+ * deterministic estimated growth past the observation baseline. Without a
169
+ * usable watermark this is the plain estimate fallback. */
170
+ anchoredOccupancyTokens(estimatedTokens) {
171
+ if (this.watermarkActualInputTokens === undefined ||
172
+ this.watermarkBaselineEstimate === undefined) {
173
+ return estimatedTokens;
174
+ }
175
+ const growthAfterWatermark = Math.max(0, estimatedTokens - this.watermarkBaselineEstimate);
176
+ return this.watermarkActualInputTokens + growthAfterWatermark;
177
+ }
178
+ emitAccountingDiagnostic() {
179
+ if (this.accountingDiagnosticEmitted)
180
+ return;
181
+ this.accountingDiagnosticEmitted = true;
182
+ try {
183
+ this.onAccountingDiagnostic?.('Provider input usage was malformed; using deterministic context estimates.');
184
+ }
185
+ catch {
186
+ // Diagnostics are strictly best-effort. A broken sink must never turn
187
+ // fail-open accounting into a healthy-turn failure.
188
+ }
189
+ }
122
190
  assertFits(report) {
123
191
  if (report.shouldCompact)
124
192
  throw new ContextOverflowError(report);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",