praxis-agent 0.24.0 → 0.24.1

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.
@@ -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)
@@ -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?.({
@@ -3057,6 +3161,7 @@ export class ClaudeSessionService {
3057
3161
  });
3058
3162
  }
3059
3163
  }
3164
+ turnCompleted = true;
3060
3165
  return {
3061
3166
  sessionId,
3062
3167
  text: structuredCapture && structuredCapture.calls === 1
@@ -3074,35 +3179,8 @@ export class ClaudeSessionService {
3074
3179
  };
3075
3180
  }
3076
3181
  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
- });
3182
+ if (!turnCompleted) {
3183
+ await this.hookLifecycle.end(sessionId, 'other');
3106
3184
  }
3107
3185
  }
3108
3186
  });
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",