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
|
-
|
|
2248
|
-
|
|
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.
|
|
2624
|
-
|
|
2625
|
-
|
|
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
|
-
|
|
3078
|
-
|
|
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
|
});
|
package/dist/cli/interactive.js
CHANGED
|
@@ -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
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
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
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
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 });
|
package/dist/cli-runtime.js
CHANGED
|
@@ -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 {
|