praxis-agent 0.2.0 → 0.4.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.
Files changed (110) hide show
  1. package/README.md +19 -4
  2. package/dist/application/session-service.d.ts +55 -0
  3. package/dist/application/session-service.d.ts.map +1 -1
  4. package/dist/application/session-service.js +939 -39
  5. package/dist/application/session-service.js.map +1 -1
  6. package/dist/cli/interactive.d.ts +72 -8
  7. package/dist/cli/interactive.d.ts.map +1 -1
  8. package/dist/cli/interactive.js +2809 -192
  9. package/dist/cli/interactive.js.map +1 -1
  10. package/dist/cli/protocol.d.ts +1 -0
  11. package/dist/cli/protocol.d.ts.map +1 -1
  12. package/dist/cli/protocol.js.map +1 -1
  13. package/dist/cli/tui/claude-style.d.ts +157 -3
  14. package/dist/cli/tui/claude-style.d.ts.map +1 -1
  15. package/dist/cli/tui/claude-style.js +403 -16
  16. package/dist/cli/tui/claude-style.js.map +1 -1
  17. package/dist/cli/tui/clipboard.d.ts +27 -0
  18. package/dist/cli/tui/clipboard.d.ts.map +1 -0
  19. package/dist/cli/tui/clipboard.js +165 -0
  20. package/dist/cli/tui/clipboard.js.map +1 -0
  21. package/dist/cli/tui/composer-editor.d.ts +19 -0
  22. package/dist/cli/tui/composer-editor.d.ts.map +1 -0
  23. package/dist/cli/tui/composer-editor.js +99 -0
  24. package/dist/cli/tui/composer-editor.js.map +1 -0
  25. package/dist/cli/tui/composer-images.d.ts +7 -0
  26. package/dist/cli/tui/composer-images.d.ts.map +1 -0
  27. package/dist/cli/tui/composer-images.js +48 -0
  28. package/dist/cli/tui/composer-images.js.map +1 -0
  29. package/dist/cli/tui/conversation-export.d.ts +6 -0
  30. package/dist/cli/tui/conversation-export.d.ts.map +1 -0
  31. package/dist/cli/tui/conversation-export.js +76 -0
  32. package/dist/cli/tui/conversation-export.js.map +1 -0
  33. package/dist/cli/tui/external-editor.d.ts +21 -0
  34. package/dist/cli/tui/external-editor.d.ts.map +1 -0
  35. package/dist/cli/tui/external-editor.js +144 -0
  36. package/dist/cli/tui/external-editor.js.map +1 -0
  37. package/dist/cli/tui/file-picker.d.ts +33 -0
  38. package/dist/cli/tui/file-picker.d.ts.map +1 -0
  39. package/dist/cli/tui/file-picker.js +117 -0
  40. package/dist/cli/tui/file-picker.js.map +1 -0
  41. package/dist/cli/tui/git-diff.d.ts +14 -0
  42. package/dist/cli/tui/git-diff.d.ts.map +1 -0
  43. package/dist/cli/tui/git-diff.js +55 -0
  44. package/dist/cli/tui/git-diff.js.map +1 -0
  45. package/dist/cli/tui/keybindings.d.ts +14 -0
  46. package/dist/cli/tui/keybindings.d.ts.map +1 -0
  47. package/dist/cli/tui/keybindings.js +430 -0
  48. package/dist/cli/tui/keybindings.js.map +1 -0
  49. package/dist/cli/tui/permission-settings.d.ts +18 -0
  50. package/dist/cli/tui/permission-settings.d.ts.map +1 -0
  51. package/dist/cli/tui/permission-settings.js +109 -0
  52. package/dist/cli/tui/permission-settings.js.map +1 -0
  53. package/dist/cli/tui/slash-commands.d.ts +11 -0
  54. package/dist/cli/tui/slash-commands.d.ts.map +1 -0
  55. package/dist/cli/tui/slash-commands.js +188 -0
  56. package/dist/cli/tui/slash-commands.js.map +1 -0
  57. package/dist/cli/tui/terminal-suspend.d.ts +5 -0
  58. package/dist/cli/tui/terminal-suspend.d.ts.map +1 -0
  59. package/dist/cli/tui/terminal-suspend.js +9 -0
  60. package/dist/cli/tui/terminal-suspend.js.map +1 -0
  61. package/dist/cli/tui/workspace-directories.d.ts +3 -0
  62. package/dist/cli/tui/workspace-directories.d.ts.map +1 -0
  63. package/dist/cli/tui/workspace-directories.js +55 -0
  64. package/dist/cli/tui/workspace-directories.js.map +1 -0
  65. package/dist/cli.d.ts +26 -2
  66. package/dist/cli.d.ts.map +1 -1
  67. package/dist/cli.js +48 -5
  68. package/dist/cli.js.map +1 -1
  69. package/dist/compatibility/claude/compaction.d.ts +8 -0
  70. package/dist/compatibility/claude/compaction.d.ts.map +1 -1
  71. package/dist/compatibility/claude/compaction.js +45 -8
  72. package/dist/compatibility/claude/compaction.js.map +1 -1
  73. package/dist/compatibility/claude/fork.d.ts.map +1 -1
  74. package/dist/compatibility/claude/fork.js +24 -6
  75. package/dist/compatibility/claude/fork.js.map +1 -1
  76. package/dist/compatibility/claude/history.d.ts.map +1 -1
  77. package/dist/compatibility/claude/history.js +41 -1
  78. package/dist/compatibility/claude/history.js.map +1 -1
  79. package/dist/compatibility/claude/projection.d.ts +29 -1
  80. package/dist/compatibility/claude/projection.d.ts.map +1 -1
  81. package/dist/compatibility/claude/projection.js +132 -2
  82. package/dist/compatibility/claude/projection.js.map +1 -1
  83. package/dist/compatibility/claude/schema.d.ts.map +1 -1
  84. package/dist/compatibility/claude/schema.js +45 -8
  85. package/dist/compatibility/claude/schema.js.map +1 -1
  86. package/dist/compatibility/claude/translation.d.ts +7 -0
  87. package/dist/compatibility/claude/translation.d.ts.map +1 -1
  88. package/dist/compatibility/claude/translation.js +20 -0
  89. package/dist/compatibility/claude/translation.js.map +1 -1
  90. package/dist/core/runtime.d.ts +19 -0
  91. package/dist/core/runtime.d.ts.map +1 -1
  92. package/dist/core/runtime.js +30 -11
  93. package/dist/core/runtime.js.map +1 -1
  94. package/dist/extensions/claude-extensions.d.ts +12 -0
  95. package/dist/extensions/claude-extensions.d.ts.map +1 -1
  96. package/dist/extensions/claude-extensions.js +49 -0
  97. package/dist/extensions/claude-extensions.js.map +1 -1
  98. package/dist/hooks/claude-hook-tools.js +2 -2
  99. package/dist/hooks/claude-hook-tools.js.map +1 -1
  100. package/dist/persistence/claude-transcript-store.d.ts.map +1 -1
  101. package/dist/persistence/claude-transcript-store.js +12 -5
  102. package/dist/persistence/claude-transcript-store.js.map +1 -1
  103. package/dist/tools/claude-interactive-tools.d.ts +1 -0
  104. package/dist/tools/claude-interactive-tools.d.ts.map +1 -1
  105. package/dist/tools/claude-interactive-tools.js +12 -0
  106. package/dist/tools/claude-interactive-tools.js.map +1 -1
  107. package/dist/tools/local-tools.d.ts.map +1 -1
  108. package/dist/tools/local-tools.js +11 -0
  109. package/dist/tools/local-tools.js.map +1 -1
  110. package/package.json +1 -1
@@ -1,20 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { lstat, readdir, stat } from 'node:fs/promises';
2
+ import { appendFile, copyFile, link, lstat, mkdir, readdir, realpath, stat, unlink, } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
3
4
  import { basename, extname, isAbsolute, join, relative } from 'node:path';
4
5
  import { createClaudeCompactEntries, formatClaudeCompactSummary, getCumulativeDroppedTokens, } from '../compatibility/claude/compaction.js';
5
6
  import { isClaudeSessionId, resolveClaudePaths, resolveClaudeScheduledTaskFile, } from '../compatibility/claude/paths.js';
6
7
  import { downloadClaudeFileResources, } from '../compatibility/claude/file-resources.js';
7
8
  import { createClaudeNativeFork } from '../compatibility/claude/fork.js';
8
- import { selectClaudeTranscriptAtMessage } from '../compatibility/claude/history.js';
9
+ import { selectClaudeActiveTranscript, selectClaudeTranscriptAtMessage, } from '../compatibility/claude/history.js';
9
10
  import { ClaudeFileHistory } from '../compatibility/claude/file-history.js';
10
11
  import { getClaudePrLink } from '../compatibility/claude/pr-links.js';
11
- import { getClaudeAgentSetting, getClaudeLastPrompt, projectClaudeModelMessages, } from '../compatibility/claude/projection.js';
12
+ import { getClaudeAgentSetting, getClaudeLastPrompt, projectClaudeDisplayTranscript, projectClaudeModelMessages, } from '../compatibility/claude/projection.js';
12
13
  import { selectClaudeSchemaAdapter, } from '../compatibility/claude/schema.js';
13
14
  import { findUnresolvedClaudeToolCalls } from '../compatibility/claude/tool-links.js';
14
15
  import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeLastPromptEntry, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../compatibility/claude/translation.js';
15
16
  import { AgentRunCancelledError, AgentRuntime, } from '../core/runtime.js';
16
17
  import { usageCostUsd } from '../core/usage.js';
17
- import { ContextBudget } from '../core/context-budget.js';
18
+ import { ContextBudget, estimateModelRequestTokens, } from '../core/context-budget.js';
18
19
  import { injectFirstUserMessageContext, } from '../core/context.js';
19
20
  import { ClaudeHookToolCoordinator } from '../hooks/claude-hook-tools.js';
20
21
  import { ClaudeTranscriptStore, } from '../persistence/claude-transcript-store.js';
@@ -62,6 +63,8 @@ Your job is to predict what THEY would type - not what you think they should do.
62
63
 
63
64
  Reply with ONLY the suggestion, no quotes or explanation.
64
65
  Use 2-12 words. Do not ask a question, evaluate the prior response, introduce a new idea, or use a Claude voice. If the topic is unsafe or sensitive, reply with an empty string.`;
66
+ const SESSION_NAME_INSTRUCTION = `Generate a concise name for this coding session based on the conversation.
67
+ Use 2-5 short words in kebab-case. Reply with ONLY the name, without quotes, punctuation, or explanation.`;
65
68
  function validPromptSuggestion(value) {
66
69
  const suggestion = value.trim();
67
70
  if (!suggestion)
@@ -73,6 +76,12 @@ function validPromptSuggestion(value) {
73
76
  return null;
74
77
  return suggestion;
75
78
  }
79
+ function validSessionName(value) {
80
+ const name = value.trim().split(/\r?\n/u)[0]?.trim() ?? '';
81
+ return /^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+){1,4}$/u.test(name)
82
+ ? name.toLocaleLowerCase()
83
+ : null;
84
+ }
76
85
  export class ClaudeSessionService {
77
86
  options;
78
87
  schema;
@@ -82,9 +91,13 @@ export class ClaudeSessionService {
82
91
  worktreeManager;
83
92
  sessionCwds = new Map();
84
93
  hostedSubagents = new Set();
94
+ hostedSubagentsByRegistry = new WeakMap();
95
+ backgroundNotificationWrites = new Map();
85
96
  downloadedFileResourceSessions = new Set();
97
+ runtimeCwd;
86
98
  constructor(options) {
87
99
  this.options = options;
100
+ this.runtimeCwd = options.workspace?.cwd() ?? options.cwd;
88
101
  this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
89
102
  this.scheduledPrompts =
90
103
  options.tools && (options.scheduledToolNames?.length ?? 0) > 0
@@ -116,6 +129,8 @@ export class ClaudeSessionService {
116
129
  async close() {
117
130
  this.scheduledPrompts?.close();
118
131
  await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
132
+ await Promise.resolve();
133
+ await Promise.all([...this.backgroundNotificationWrites.values()]);
119
134
  this.hostedSubagents.clear();
120
135
  await this.workflowManager?.close();
121
136
  }
@@ -282,7 +297,7 @@ export class ClaudeSessionService {
282
297
  'Monitor',
283
298
  'PushNotification',
284
299
  ];
285
- return {
300
+ const hostedRegistry = {
286
301
  definitions: () => {
287
302
  const definitions = interactiveRegistry.definitions();
288
303
  return [...definitions].sort((left, right) => {
@@ -295,15 +310,140 @@ export class ClaudeSessionService {
295
310
  prepare: (call, context) => interactiveRegistry.prepare(call, context),
296
311
  execute: (call, context) => interactiveRegistry.execute(call, context),
297
312
  };
313
+ if (subagentExecutor) {
314
+ this.hostedSubagentsByRegistry.set(hostedRegistry, subagentExecutor);
315
+ }
316
+ return hostedRegistry;
298
317
  }
299
318
  async run(prompt, signal, sessionId = randomUUID(), name, images, documents) {
300
319
  this.worktreeManager?.bindSession(sessionId);
301
320
  return this.executeTurn(sessionId, prompt, false, signal, name, images, documents);
302
321
  }
322
+ async runShell(command, signal, sessionId = randomUUID(), name) {
323
+ this.worktreeManager?.bindSession(sessionId);
324
+ return this.executeTurn(sessionId, `! ${command}`, false, signal, name, [], [], undefined, command);
325
+ }
303
326
  async resume(sessionId, prompt, signal, name, images, documents, resumeSessionAt) {
304
327
  this.worktreeManager?.bindSession(sessionId);
305
328
  return this.executeTurn(sessionId, prompt, true, signal, name, images, documents, resumeSessionAt);
306
329
  }
330
+ async resumeShell(sessionId, command, signal, name, resumeSessionAt) {
331
+ this.worktreeManager?.bindSession(sessionId);
332
+ return this.executeTurn(sessionId, `! ${command}`, true, signal, name, [], [], resumeSessionAt, command);
333
+ }
334
+ async answerSideQuestion(sessionId, question, signal, onDelta, permissionMode = 'default') {
335
+ const prompt = question.trim();
336
+ if (!prompt)
337
+ throw new Error('Side question must not be empty');
338
+ const activeSessionId = await this.ensureLocalSession(sessionId, permissionMode);
339
+ await this.appendInputHistory(`/btw ${prompt}`, activeSessionId);
340
+ const provider = this.provider();
341
+ let entries = [];
342
+ if (sessionId) {
343
+ const loaded = this.options.sessionPersistence === false
344
+ ? await this.turnStore(activeSessionId).withLease((lease) => lease.load())
345
+ : {
346
+ status: 'completed',
347
+ value: await this.store(activeSessionId).loadReadOnly(),
348
+ };
349
+ if (loaded.status === 'conflict') {
350
+ throw new Error(`Claude side question conflict: ${loaded.reason}`);
351
+ }
352
+ if (loaded.value.entries.length === 0) {
353
+ throw new Error(`Claude session not found: ${activeSessionId}`);
354
+ }
355
+ entries = loaded.value.entries;
356
+ this.restoreWorktree(entries);
357
+ }
358
+ const assembledContext = await this.options.contextAssembler?.assemble({
359
+ cwd: this.activeCwd(),
360
+ });
361
+ const messages = [
362
+ ...(assembledContext?.systemMessages ?? []),
363
+ ...injectFirstUserMessageContext([
364
+ ...projectClaudeModelMessages(entries),
365
+ { role: 'user', content: prompt },
366
+ ], assembledContext?.firstUserMessageContext),
367
+ ];
368
+ const budget = this.contextBudget(provider);
369
+ if (budget)
370
+ budget.assertFits(budget.evaluate(messages));
371
+ let text = '';
372
+ let usage = { inputTokens: 0, outputTokens: 0 };
373
+ for await (const event of provider.complete({
374
+ messages,
375
+ ...(this.options.effort ? { effort: this.options.effort } : {}),
376
+ ...(signal ? { signal } : {}),
377
+ })) {
378
+ if (event.type === 'text-delta') {
379
+ text += event.delta;
380
+ onDelta?.(event.delta);
381
+ }
382
+ else if (event.type === 'usage') {
383
+ usage = event.usage;
384
+ }
385
+ else if (event.type === 'tool-call') {
386
+ throw new Error('Side questions cannot call tools; press f to fork');
387
+ }
388
+ }
389
+ const pricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
390
+ return {
391
+ sessionId: activeSessionId,
392
+ text,
393
+ usage,
394
+ ...(pricing ? { costUsd: usageCostUsd(usage, pricing) } : {}),
395
+ };
396
+ }
397
+ async forkSideQuestion(sessionId, question, signal) {
398
+ const prompt = question.trim();
399
+ if (!prompt)
400
+ throw new Error('Side question must not be empty');
401
+ const name = prompt
402
+ .toLowerCase()
403
+ .replace(/[^a-z0-9]+/gu, '-')
404
+ .replace(/^-+|-+$/gu, '')
405
+ .split('-')
406
+ .filter(Boolean)
407
+ .slice(0, 3)
408
+ .join('-')
409
+ .slice(0, 64) || 'side-question';
410
+ const registry = this.createHostedToolRegistry(sessionId);
411
+ const call = {
412
+ id: randomUUID(),
413
+ name: 'Agent',
414
+ input: {
415
+ description: prompt,
416
+ prompt,
417
+ subagent_type: 'general-purpose',
418
+ run_in_background: true,
419
+ name,
420
+ },
421
+ };
422
+ const context = {
423
+ cwd: this.activeCwd(),
424
+ ...(signal ? { signal } : {}),
425
+ };
426
+ const prepared = await registry.prepare(call, context);
427
+ const result = await registry.execute(prepared, context);
428
+ const agentId = result.nativeToolUseResult?.agentId;
429
+ if (result.isError || typeof agentId !== 'string') {
430
+ throw new Error(result.content || 'Could not fork side question');
431
+ }
432
+ await this.appendSystemLocalCommand(sessionId, 'btw', prompt, `⑂ forked ${name} (${agentId.slice(-4)})`);
433
+ const executor = this.hostedSubagentsByRegistry.get(registry);
434
+ if (executor) {
435
+ void executor
436
+ .notifications(true)
437
+ .then(({ messages }) => this.enqueueBackgroundNotifications(sessionId, messages))
438
+ .catch((error) => this.options.eventSink?.({
439
+ type: 'warning',
440
+ message: error instanceof Error
441
+ ? error.message
442
+ : `Could not persist background notification: ${String(error)}`,
443
+ }));
444
+ }
445
+ return { agentId, name };
446
+ }
307
447
  async promptSuggestion(sessionId, signal) {
308
448
  const provider = this.provider();
309
449
  const loaded = this.options.sessionPersistence === false
@@ -355,6 +495,28 @@ export class ClaudeSessionService {
355
495
  }
356
496
  return validPromptSuggestion(suggestion);
357
497
  }
498
+ async sessionNameSuggestion(sessionId, signal) {
499
+ const provider = this.provider();
500
+ const loaded = await this.turnStore(sessionId).withLease((lease) => lease.load());
501
+ if (loaded.status === 'conflict' || loaded.value.entries.length === 0) {
502
+ return null;
503
+ }
504
+ let suggestion = '';
505
+ for await (const event of provider.complete({
506
+ messages: [
507
+ ...projectClaudeModelMessages(loaded.value.entries),
508
+ { role: 'user', content: SESSION_NAME_INSTRUCTION },
509
+ ],
510
+ ...(this.options.effort ? { effort: this.options.effort } : {}),
511
+ ...(signal ? { signal } : {}),
512
+ })) {
513
+ if (event.type === 'text-delta')
514
+ suggestion += event.delta;
515
+ if (event.type === 'tool-call')
516
+ return null;
517
+ }
518
+ return validSessionName(suggestion);
519
+ }
358
520
  async sessions() {
359
521
  const paths = this.paths(randomUUID());
360
522
  let names;
@@ -456,6 +618,344 @@ export class ClaudeSessionService {
456
618
  throw error;
457
619
  }
458
620
  }
621
+ async transcript(sessionId, resumeSessionAt) {
622
+ try {
623
+ const recovery = await this.store(sessionId).loadReadOnly();
624
+ if (recovery.entries.length === 0) {
625
+ throw new Error(`Claude session not found: ${sessionId}`);
626
+ }
627
+ const entries = resumeSessionAt === undefined
628
+ ? recovery.entries
629
+ : selectClaudeTranscriptAtMessage(recovery.entries, resumeSessionAt);
630
+ return projectClaudeDisplayTranscript(entries);
631
+ }
632
+ catch (error) {
633
+ if (error.code === 'ENOENT') {
634
+ throw new Error(`Claude session not found: ${sessionId}`);
635
+ }
636
+ throw error;
637
+ }
638
+ }
639
+ async rename(sessionId, name) {
640
+ this.assertWritable();
641
+ const normalized = name.trim();
642
+ if (!normalized)
643
+ throw new Error('Session name must not be empty');
644
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
645
+ const snapshot = await lease.load();
646
+ if (snapshot.entries.length === 0) {
647
+ throw new Error(`Claude session not found: ${sessionId}`);
648
+ }
649
+ if (this.hasSessionName(snapshot.entries, normalized))
650
+ return;
651
+ const appendResult = await lease.appendMany(snapshot.tail, this.sessionNameEntries(sessionId, normalized));
652
+ if (appendResult.status === 'conflict') {
653
+ throw new Error(`Claude transcript rename conflict: ${appendResult.reason}`);
654
+ }
655
+ });
656
+ if (result.status === 'conflict') {
657
+ throw new Error(`Claude transcript rename conflict: ${result.reason}`);
658
+ }
659
+ }
660
+ async changeCwd(sessionId, requestedCwd) {
661
+ this.assertWritable();
662
+ const previousCwd = this.activeCwd();
663
+ const expandedCwd = requestedCwd === '~'
664
+ ? homedir()
665
+ : requestedCwd.startsWith('~/')
666
+ ? join(homedir(), requestedCwd.slice(2))
667
+ : requestedCwd;
668
+ const cwd = await realpath(isAbsolute(expandedCwd) ? expandedCwd : join(previousCwd, expandedCwd));
669
+ if (!(await stat(cwd)).isDirectory()) {
670
+ throw new Error(`Not a directory: ${requestedCwd}`);
671
+ }
672
+ if (sessionId && this.options.sessionPersistence !== false) {
673
+ const sourcePaths = resolveClaudePaths({
674
+ configDir: this.options.configRoot,
675
+ cwd: this.sessionCwds.get(sessionId) ?? previousCwd,
676
+ sessionId,
677
+ });
678
+ const targetPaths = resolveClaudePaths({
679
+ configDir: this.options.configRoot,
680
+ cwd,
681
+ sessionId,
682
+ });
683
+ if (sourcePaths.sessionFile !== targetPaths.sessionFile) {
684
+ try {
685
+ await lstat(targetPaths.sessionFile);
686
+ throw new Error(`Claude transcript already exists at relocation target: ${targetPaths.sessionFile}`);
687
+ }
688
+ catch (error) {
689
+ if (error.code !== 'ENOENT')
690
+ throw error;
691
+ }
692
+ const sourceStore = this.store(sessionId);
693
+ const relocated = await sourceStore.withLease(async (lease) => {
694
+ const snapshot = await lease.load();
695
+ if (snapshot.entries.length === 0) {
696
+ throw new Error(`Claude session not found: ${sessionId}`);
697
+ }
698
+ await mkdir(targetPaths.projectRoot, { recursive: true });
699
+ const relocationId = randomUUID();
700
+ const stagingFile = join(targetPaths.projectRoot, `.${sessionId}.${relocationId}.relocating`);
701
+ const stagingStore = new ClaudeTranscriptStore({
702
+ sessionFile: stagingFile,
703
+ lockFile: join(targetPaths.praxisRoot, 'locks', `${sessionId}.${relocationId}.lock`),
704
+ schema: this.schema,
705
+ });
706
+ let publishedIdentity;
707
+ try {
708
+ await copyFile(sourcePaths.sessionFile, stagingFile);
709
+ const staged = await stagingStore.withLease((stagingLease) => stagingLease.appendMany(snapshot.tail, [
710
+ {
711
+ type: 'relocated',
712
+ sessionId,
713
+ relocatedCwd: cwd,
714
+ },
715
+ ...this.cdCommandEntries(sessionId, cwd, this.logicalTailUuid(snapshot.tail)),
716
+ ]));
717
+ if (staged.status === 'conflict') {
718
+ throw new Error(`Claude transcript relocation conflict: ${staged.reason}`);
719
+ }
720
+ if (staged.value.status === 'conflict') {
721
+ throw new Error(`Claude transcript relocation conflict: ${staged.value.reason}`);
722
+ }
723
+ const stagingMetadata = await lstat(stagingFile);
724
+ await link(stagingFile, targetPaths.sessionFile);
725
+ publishedIdentity = {
726
+ dev: stagingMetadata.dev,
727
+ ino: stagingMetadata.ino,
728
+ };
729
+ await unlink(stagingFile);
730
+ await unlink(sourcePaths.sessionFile);
731
+ }
732
+ catch (error) {
733
+ await unlink(stagingFile).catch(() => undefined);
734
+ if (publishedIdentity) {
735
+ const targetMetadata = await lstat(targetPaths.sessionFile).catch(() => undefined);
736
+ if (targetMetadata?.dev === publishedIdentity.dev &&
737
+ targetMetadata.ino === publishedIdentity.ino) {
738
+ await unlink(targetPaths.sessionFile).catch(() => undefined);
739
+ }
740
+ }
741
+ throw error;
742
+ }
743
+ });
744
+ if (relocated.status === 'conflict') {
745
+ throw new Error(`Claude transcript relocation conflict: ${relocated.reason}`);
746
+ }
747
+ }
748
+ else {
749
+ await this.appendCdCommand(sessionId, cwd);
750
+ }
751
+ this.sessionCwds.set(sessionId, cwd);
752
+ this.runtimeCwd = cwd;
753
+ this.options.workspace?.setCwd(cwd);
754
+ return cwd;
755
+ }
756
+ this.runtimeCwd = cwd;
757
+ this.options.workspace?.setCwd(cwd);
758
+ if (sessionId)
759
+ this.sessionCwds.set(sessionId, cwd);
760
+ return cwd;
761
+ }
762
+ async recordCdUsage(sessionId) {
763
+ this.assertWritable();
764
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
765
+ const snapshot = await lease.load();
766
+ if (snapshot.entries.length === 0) {
767
+ throw new Error(`Claude session not found: ${sessionId}`);
768
+ }
769
+ const promptId = randomUUID();
770
+ const firstUuid = randomUUID();
771
+ const timestamp = new Date().toISOString();
772
+ const common = {
773
+ isSidechain: false,
774
+ promptId,
775
+ timestamp,
776
+ userType: 'external',
777
+ entrypoint: 'cli',
778
+ cwd: this.activeCwd(),
779
+ sessionId,
780
+ version: this.options.claudeVersion,
781
+ gitBranch: null,
782
+ };
783
+ const appendResult = await lease.appendMany(snapshot.tail, [
784
+ {
785
+ ...common,
786
+ parentUuid: this.logicalTailUuid(snapshot.tail),
787
+ type: 'user',
788
+ message: {
789
+ role: 'user',
790
+ content: '<command-name>/cd</command-name>\n <command-message>cd</command-message>\n <command-args></command-args>',
791
+ },
792
+ uuid: firstUuid,
793
+ },
794
+ {
795
+ ...common,
796
+ parentUuid: firstUuid,
797
+ type: 'user',
798
+ message: {
799
+ role: 'user',
800
+ content: '<local-command-stdout>Usage: /cd <path></local-command-stdout>',
801
+ },
802
+ uuid: randomUUID(),
803
+ },
804
+ ]);
805
+ if (appendResult.status === 'conflict') {
806
+ throw new Error(`Claude cd usage append conflict: ${appendResult.reason}`);
807
+ }
808
+ });
809
+ if (result.status === 'conflict') {
810
+ throw new Error(`Claude cd usage conflict: ${result.reason}`);
811
+ }
812
+ }
813
+ async recordBtwUsage(sessionId, permissionMode = 'default') {
814
+ const activeSessionId = await this.ensureLocalSession(sessionId, permissionMode);
815
+ await this.appendInputHistory('/btw', activeSessionId);
816
+ while (true) {
817
+ const result = await this.turnStore(activeSessionId).withLease(async (lease) => {
818
+ const snapshot = await lease.load();
819
+ if (snapshot.entries.length === 0) {
820
+ throw new Error(`Claude session not found: ${activeSessionId}`);
821
+ }
822
+ const entries = this.localCommandEntries(activeSessionId, this.activeCwd(), this.logicalTailUuid(snapshot.tail), 'btw', '', 'Usage: /btw <your question>');
823
+ const leafUuid = entries.at(-1)?.uuid;
824
+ if (typeof leafUuid !== 'string') {
825
+ throw new Error('Claude btw local command pair is incomplete');
826
+ }
827
+ const appended = await lease.appendMany(snapshot.tail, [
828
+ ...entries,
829
+ { type: 'last-prompt', leafUuid, sessionId: activeSessionId },
830
+ ]);
831
+ if (appended.status === 'conflict') {
832
+ throw new Error(`Claude local command append conflict: ${appended.reason}`);
833
+ }
834
+ });
835
+ if (result.status === 'completed')
836
+ return activeSessionId;
837
+ await new Promise((resolve) => setTimeout(resolve, 25));
838
+ }
839
+ }
840
+ async compact(sessionId, signal, selection) {
841
+ this.assertWritable();
842
+ const provider = this.provider();
843
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
844
+ const snapshot = await lease.load();
845
+ if (snapshot.entries.length === 0) {
846
+ throw new Error(`Claude session not found: ${sessionId}`);
847
+ }
848
+ const activeEntries = selectClaudeActiveTranscript(snapshot.entries);
849
+ const allMessages = projectClaudeModelMessages(activeEntries);
850
+ let selectedEntries = activeEntries;
851
+ let logicalParentUuid = this.lastMessageUuid(activeEntries);
852
+ let preservedEntries = [];
853
+ if (selection) {
854
+ const targetIndex = activeEntries.findIndex((entry) => entry.uuid === selection.messageId);
855
+ if (targetIndex < 0) {
856
+ throw new Error(`No rewind point found with message.uuid of: ${selection.messageId}`);
857
+ }
858
+ if (selection.direction === 'from') {
859
+ selectedEntries = activeEntries.slice(targetIndex);
860
+ preservedEntries = activeEntries.slice(0, targetIndex);
861
+ logicalParentUuid = this.lastMessageUuid(preservedEntries);
862
+ if (!logicalParentUuid)
863
+ logicalParentUuid = selection.messageId;
864
+ }
865
+ else {
866
+ selectedEntries = activeEntries.slice(0, targetIndex);
867
+ preservedEntries = activeEntries.slice(targetIndex);
868
+ const target = activeEntries[targetIndex];
869
+ logicalParentUuid =
870
+ typeof target?.parentUuid === 'string'
871
+ ? target.parentUuid
872
+ : this.lastMessageUuid(selectedEntries);
873
+ }
874
+ }
875
+ const selectedMessages = projectClaudeModelMessages(selectedEntries);
876
+ const messages = [
877
+ ...selectedMessages,
878
+ ...(selection?.context
879
+ ? [
880
+ {
881
+ role: 'user',
882
+ content: `Additional summarization context: ${selection.context}`,
883
+ },
884
+ ]
885
+ : []),
886
+ ];
887
+ if (!logicalParentUuid || selectedMessages.length === 0) {
888
+ throw new Error('Cannot compact an empty Claude transcript');
889
+ }
890
+ if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
891
+ throw new Error('Cannot compact a Claude session with unresolved tool calls');
892
+ }
893
+ this.options.eventSink?.({ type: 'state', state: 'compacting' });
894
+ const contextWindowTokens = this.contextBudget(provider)?.contextWindowTokens ??
895
+ provider.capabilities.contextWindowTokens ??
896
+ 200_000;
897
+ const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
898
+ messages,
899
+ targetTokens: Math.min(8192, Math.max(1, Math.floor(contextWindowTokens / 4))),
900
+ contextWindowTokens,
901
+ ...(signal ? { signal } : {}),
902
+ });
903
+ if (signal?.aborted)
904
+ throw new AgentRunCancelledError();
905
+ const preTokens = estimateModelRequestTokens(allMessages);
906
+ const summary = compacted.summary;
907
+ const preservedMessages = projectClaudeModelMessages(preservedEntries);
908
+ const boundaryUuid = randomUUID();
909
+ const summaryUuid = randomUUID();
910
+ const uuids = [boundaryUuid, summaryUuid];
911
+ const entries = createClaudeCompactEntries({
912
+ sessionId,
913
+ logicalParentUuid,
914
+ summary,
915
+ preTokens,
916
+ postTokens: estimateModelRequestTokens([
917
+ { role: 'user', content: formatClaudeCompactSummary(summary) },
918
+ ...preservedMessages,
919
+ ]),
920
+ previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
921
+ durationMs: compacted.durationMs,
922
+ cwd: this.activeCwd(),
923
+ claudeVersion: this.options.claudeVersion,
924
+ gitBranch: null,
925
+ trigger: 'manual',
926
+ ...(selection
927
+ ? {
928
+ summarizeMetadata: {
929
+ messagesSummarized: selectedMessages.length,
930
+ direction: selection.direction === 'to' ? 'up_to' : selection.direction,
931
+ },
932
+ preservedUuids: preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
933
+ }
934
+ : {}),
935
+ createUuid: () => uuids.shift() ?? randomUUID(),
936
+ });
937
+ const appendResult = await lease.appendMany(snapshot.tail, entries);
938
+ if (appendResult.status === 'conflict') {
939
+ throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
940
+ }
941
+ this.options.eventSink?.({
942
+ type: 'compact-boundary',
943
+ trigger: 'manual',
944
+ preTokens,
945
+ uuid: boundaryUuid,
946
+ });
947
+ return {
948
+ summary,
949
+ usage: compacted.usage,
950
+ preTokens,
951
+ ...(selection ? { messagesSummarized: selectedMessages.length } : {}),
952
+ };
953
+ });
954
+ if (result.status === 'conflict') {
955
+ throw new Error(`Claude transcript compact conflict: ${result.reason}`);
956
+ }
957
+ return result.value;
958
+ }
459
959
  async fork(parentSessionId, sessionId = randomUUID(), resumeSessionAt) {
460
960
  this.assertSessionPersistence();
461
961
  this.assertWritable();
@@ -496,13 +996,100 @@ export class ClaudeSessionService {
496
996
  throw new Error(`Claude file rewind conflict: ${result.reason}`);
497
997
  }
498
998
  }
499
- async executeTurn(sessionId, prompt, requireExisting, signal, name, images = [], documents = [], resumeSessionAt) {
999
+ async rewindPoints(sessionId) {
1000
+ const result = await this.turnStore(sessionId).withLease((lease) => lease.load());
1001
+ if (result.status === 'conflict') {
1002
+ throw new Error(`Claude transcript rewind conflict: ${result.reason}`);
1003
+ }
1004
+ if (result.value.entries.length === 0) {
1005
+ throw new Error(`Claude session not found: ${sessionId}`);
1006
+ }
1007
+ const active = selectClaudeActiveTranscript(result.value.entries);
1008
+ const snapshots = new Set(active
1009
+ .filter((entry) => entry.type === 'file-history-snapshot' &&
1010
+ typeof entry.messageId === 'string')
1011
+ .map((entry) => entry.messageId));
1012
+ const changes = new Map();
1013
+ for (const entry of active) {
1014
+ if (entry.type !== 'file-history-delta' ||
1015
+ typeof entry.snapshotMessageId !== 'string' ||
1016
+ typeof entry.trackingPath !== 'string') {
1017
+ continue;
1018
+ }
1019
+ const paths = changes.get(entry.snapshotMessageId) ?? new Set();
1020
+ paths.add(entry.trackingPath);
1021
+ changes.set(entry.snapshotMessageId, paths);
1022
+ }
1023
+ const points = [];
1024
+ let branchMessageId;
1025
+ for (const entry of active) {
1026
+ if ((entry.type === 'user' || entry.type === 'assistant') &&
1027
+ typeof entry.uuid === 'string') {
1028
+ const message = entry.message;
1029
+ if (typeof message === 'object' &&
1030
+ message !== null &&
1031
+ !Array.isArray(message)) {
1032
+ const role = message.role;
1033
+ const content = message.content;
1034
+ if (entry.type === 'user' &&
1035
+ role === 'user' &&
1036
+ typeof content === 'string' &&
1037
+ entry.isCompactSummary !== true &&
1038
+ entry.isMeta !== true &&
1039
+ typeof entry.sourceToolAssistantUUID !== 'string' &&
1040
+ !content.startsWith('<bash-input>') &&
1041
+ !content.startsWith('<local-command-') &&
1042
+ !content.startsWith('<command-name>')) {
1043
+ points.push({
1044
+ messageId: entry.uuid,
1045
+ prompt: content,
1046
+ ...(typeof entry.timestamp === 'string'
1047
+ ? { timestamp: entry.timestamp }
1048
+ : {}),
1049
+ ...(branchMessageId === undefined ? {} : { branchMessageId }),
1050
+ fileChanges: [...(changes.get(entry.uuid) ?? [])],
1051
+ fileRestoreAvailable: snapshots.has(entry.uuid),
1052
+ });
1053
+ }
1054
+ if (role === 'user' || role === 'assistant') {
1055
+ branchMessageId = entry.uuid;
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+ return points;
1061
+ }
1062
+ async setPermissionMode(sessionId, permissionMode) {
1063
+ this.assertSessionPersistence();
1064
+ this.assertWritable();
1065
+ const result = await this.store(sessionId).withLease(async (lease) => {
1066
+ const snapshot = await lease.load();
1067
+ if (snapshot.entries.length === 0) {
1068
+ throw new Error(`Claude session not found: ${sessionId}`);
1069
+ }
1070
+ this.options.interactiveTools?.restore(sessionId, snapshot.entries);
1071
+ const entry = {
1072
+ type: 'permission-mode',
1073
+ permissionMode,
1074
+ sessionId,
1075
+ };
1076
+ await this.append(lease, snapshot.tail, entry);
1077
+ await this.options.interactiveTools?.setMode(sessionId, permissionMode);
1078
+ });
1079
+ if (result.status === 'conflict') {
1080
+ throw new Error(`Claude permission mode update conflict: ${result.reason}`);
1081
+ }
1082
+ }
1083
+ async executeTurn(sessionId, prompt, requireExisting, signal, name, images = [], documents = [], resumeSessionAt, shellCommand) {
500
1084
  this.assertWritable();
501
1085
  if (prompt.length === 0 && images.length === 0 && documents.length === 0)
502
1086
  throw new Error('Prompt must not be empty');
503
1087
  if (name !== undefined && name.length === 0) {
504
1088
  throw new Error('Session name must not be empty');
505
1089
  }
1090
+ if (shellCommand !== undefined && shellCommand.trim().length === 0) {
1091
+ throw new Error('Shell command must not be empty');
1092
+ }
506
1093
  await this.ensureFileResources(sessionId, signal);
507
1094
  this.worktreeManager?.bindSession(sessionId);
508
1095
  if (this.options.initialWorktree) {
@@ -575,7 +1162,8 @@ export class ClaudeSessionService {
575
1162
  if (this.options.maxBudgetUsd !== undefined && !initialPricing) {
576
1163
  throw new Error(`Cannot enforce --max-budget-usd: no pricing is configured for model ${provider.model ?? 'praxis/provider'}`);
577
1164
  }
578
- let currentPromptId = null;
1165
+ const shellInputUuid = shellCommand === undefined ? null : randomUUID();
1166
+ let currentPromptId = shellInputUuid;
579
1167
  let lastAssistantUuid = null;
580
1168
  const fileHistory = this.options.fileCheckpointing &&
581
1169
  this.options.sessionPersistence !== false
@@ -1047,9 +1635,13 @@ export class ClaudeSessionService {
1047
1635
  ]
1048
1636
  : []),
1049
1637
  ];
1050
- const expansion = this.options.extensions
1051
- ? await this.options.extensions.expandPromptAsync(prompt, signal, toolResultDirectory)
1052
- : { userMessages: [prompt] };
1638
+ const expansion = shellCommand === undefined
1639
+ ? this.options.extensions
1640
+ ? await this.options.extensions.expandPromptAsync(prompt, signal, toolResultDirectory)
1641
+ : { userMessages: [prompt] }
1642
+ : {
1643
+ userMessages: [`<bash-input>${shellCommand}</bash-input>`],
1644
+ };
1053
1645
  const expandedMessages = expansion.messages ?? expansion.userMessages.map((text) => ({ text }));
1054
1646
  const attachmentIndex = expansion.messages
1055
1647
  ? expandedMessages.length - 1
@@ -1086,7 +1678,37 @@ export class ClaudeSessionService {
1086
1678
  ? { documents }
1087
1679
  : {}),
1088
1680
  }));
1681
+ const agentMentionMessages = shellCommand === undefined
1682
+ ? (this.options.extensions?.agentMentionMessages(prompt) ?? [])
1683
+ : [];
1684
+ const injectAgentMentionContext = (messages) => {
1685
+ if (agentMentionMessages.length === 0)
1686
+ return [...messages];
1687
+ let insertionIndex = messages.length;
1688
+ let foundPrompt = false;
1689
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1690
+ const message = messages[index];
1691
+ if (message?.role === 'user' &&
1692
+ typeof message.content === 'string' &&
1693
+ message.content.endsWith(prompt)) {
1694
+ insertionIndex = index;
1695
+ foundPrompt = true;
1696
+ break;
1697
+ }
1698
+ }
1699
+ if (!foundPrompt)
1700
+ return [...messages];
1701
+ return [
1702
+ ...messages.slice(0, insertionIndex),
1703
+ ...agentMentionMessages.map((content) => ({
1704
+ role: 'user',
1705
+ content,
1706
+ })),
1707
+ ...messages.slice(insertionIndex),
1708
+ ];
1709
+ };
1089
1710
  const injectDynamicContext = (messages) => injectFirstUserMessageContext(messages, assembledContext?.firstUserMessageContext);
1711
+ const injectTurnContext = (messages) => injectAgentMentionContext(injectDynamicContext(messages));
1090
1712
  let compactionAnchorUuid = this.lastMessageUuid(snapshot.entries);
1091
1713
  const compactIfNeeded = async (pendingMessages = [], preservedUserMessages = []) => {
1092
1714
  if (!budget)
@@ -1094,13 +1716,13 @@ export class ClaudeSessionService {
1094
1716
  const historyMessages = projectClaudeModelMessages(snapshot.entries);
1095
1717
  const predicted = budget.evaluate([
1096
1718
  ...contextMessages,
1097
- ...injectDynamicContext([...historyMessages, ...pendingMessages]),
1719
+ ...injectTurnContext([...historyMessages, ...pendingMessages]),
1098
1720
  ], definitions);
1099
1721
  if (!predicted.shouldCompact)
1100
1722
  return;
1101
1723
  const irreducibleMessages = [
1102
1724
  ...contextMessages,
1103
- ...injectDynamicContext([
1725
+ ...injectTurnContext([
1104
1726
  ...pendingMessages,
1105
1727
  ...preservedUserMessages.map((content) => ({
1106
1728
  role: 'user',
@@ -1146,7 +1768,7 @@ export class ClaudeSessionService {
1146
1768
  const boundaryUuid = randomUUID();
1147
1769
  const summaryUuid = randomUUID();
1148
1770
  const timestamp = new Date().toISOString();
1149
- const preTokens = budget.evaluate([...contextMessages, ...injectDynamicContext(historyMessages)], definitions).estimatedTokens;
1771
+ const preTokens = budget.evaluate([...contextMessages, ...injectTurnContext(historyMessages)], definitions).estimatedTokens;
1150
1772
  const compactEntries = (postTokens) => {
1151
1773
  const uuids = [boundaryUuid, summaryUuid];
1152
1774
  return createClaudeCompactEntries({
@@ -1187,13 +1809,10 @@ export class ClaudeSessionService {
1187
1809
  ...provisionalEntries,
1188
1810
  ...replayEntries,
1189
1811
  ]);
1190
- const afterHistory = budget.evaluate([...contextMessages, ...injectDynamicContext(compactedHistory)], definitions);
1812
+ const afterHistory = budget.evaluate([...contextMessages, ...injectTurnContext(compactedHistory)], definitions);
1191
1813
  const afterPending = budget.evaluate([
1192
1814
  ...contextMessages,
1193
- ...injectDynamicContext([
1194
- ...compactedHistory,
1195
- ...pendingMessages,
1196
- ]),
1815
+ ...injectTurnContext([...compactedHistory, ...pendingMessages]),
1197
1816
  ], definitions);
1198
1817
  budget.assertFits(afterPending);
1199
1818
  const entries = [
@@ -1224,25 +1843,26 @@ export class ClaudeSessionService {
1224
1843
  };
1225
1844
  await compactIfNeeded(pendingUserMessages);
1226
1845
  for (const [index, message] of expandedMessages.entries()) {
1846
+ if (shellCommand !== undefined)
1847
+ break;
1227
1848
  const messageImages = [
1228
1849
  ...(index === attachmentIndex ? images : []),
1229
1850
  ...(message.images ?? []),
1230
1851
  ];
1231
- const [userEntry] = translateProviderEvents([
1232
- messageImages.length > 0 ||
1233
- (index === attachmentIndex && documents.length > 0)
1234
- ? {
1235
- type: 'user-message',
1236
- text: message.text,
1237
- images: messageImages,
1238
- ...(index === attachmentIndex && documents.length > 0
1239
- ? { documents }
1240
- : {}),
1241
- }
1242
- : index === 0
1243
- ? { type: 'user-text', text: message.text }
1244
- : { type: 'user-text-block', text: message.text },
1245
- ], this.translationContext(sessionId, snapshot));
1852
+ const persistenceEvent = messageImages.length > 0 ||
1853
+ (index === attachmentIndex && documents.length > 0)
1854
+ ? {
1855
+ type: 'user-message',
1856
+ text: message.text,
1857
+ images: messageImages,
1858
+ ...(index === attachmentIndex && documents.length > 0
1859
+ ? { documents }
1860
+ : {}),
1861
+ }
1862
+ : index === 0
1863
+ ? { type: 'user-text', text: message.text }
1864
+ : { type: 'user-text-block', text: message.text };
1865
+ const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, snapshot));
1246
1866
  if (!userEntry)
1247
1867
  throw new Error('Could not translate user prompt');
1248
1868
  if (currentPromptId === null && typeof userEntry.uuid === 'string') {
@@ -1275,18 +1895,90 @@ export class ClaudeSessionService {
1275
1895
  throw new Error(`UserPromptSubmit hook error: ${outcome.blockedReason}`);
1276
1896
  }
1277
1897
  }
1898
+ let shellUsage = { inputTokens: 0, outputTokens: 0 };
1899
+ if (shellCommand !== undefined) {
1900
+ const call = {
1901
+ id: `shell_${randomUUID().replaceAll('-', '')}`,
1902
+ name: 'Bash',
1903
+ input: { command: shellCommand },
1904
+ };
1905
+ this.options.eventSink?.({
1906
+ type: 'shell-command',
1907
+ callId: call.id,
1908
+ command: shellCommand,
1909
+ });
1910
+ let shellResult;
1911
+ try {
1912
+ shellResult = await runtime.executeDirectToolCall(call, {
1913
+ cwd: this.activeCwd(),
1914
+ toolResultDirectory,
1915
+ messages: projectClaudeModelMessages(snapshot.entries),
1916
+ observer: {
1917
+ assistantCompleted: async () => undefined,
1918
+ toolCompleted: async () => undefined,
1919
+ followUpUserMessagesCompleted: observer.followUpUserMessagesCompleted,
1920
+ },
1921
+ ...(this.options.approveTool
1922
+ ? { approveTool: this.options.approveTool }
1923
+ : {}),
1924
+ ...(signal ? { signal } : {}),
1925
+ });
1926
+ }
1927
+ catch (error) {
1928
+ this.options.eventSink?.({
1929
+ type: 'shell-cancelled',
1930
+ callId: call.id,
1931
+ });
1932
+ throw error;
1933
+ }
1934
+ shellUsage = shellResult.usage ?? shellUsage;
1935
+ const stdout = shellResult.processOutput?.stdout ??
1936
+ (shellResult.isError ? '' : shellResult.content);
1937
+ const stderr = shellResult.processOutput?.stderr ??
1938
+ (shellResult.isError ? shellResult.content : '');
1939
+ const shellUuids = [shellInputUuid ?? randomUUID(), randomUUID()];
1940
+ const [inputEntry, outputEntry] = translateProviderEvents([
1941
+ { type: 'bash-input', command: shellCommand },
1942
+ { type: 'bash-output', stdout, stderr },
1943
+ ], {
1944
+ ...this.translationContext(sessionId, snapshot),
1945
+ createUuid: () => shellUuids.shift() ?? randomUUID(),
1946
+ });
1947
+ if (!inputEntry || !outputEntry) {
1948
+ throw new Error('Could not translate shell command result');
1949
+ }
1950
+ const shellAppend = await lease.appendMany(snapshot.tail, [
1951
+ inputEntry,
1952
+ outputEntry,
1953
+ ]);
1954
+ if (shellAppend.status === 'conflict') {
1955
+ throw new Error(`Claude transcript append conflict: ${shellAppend.reason}`);
1956
+ }
1957
+ snapshot = {
1958
+ entries: [...snapshot.entries, inputEntry, outputEntry],
1959
+ tail: shellAppend.tail,
1960
+ };
1961
+ currentTurnUserMessages.push(`<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`);
1962
+ this.options.eventSink?.({
1963
+ type: 'shell-result',
1964
+ callId: call.id,
1965
+ stdout,
1966
+ stderr,
1967
+ isError: shellResult.isError,
1968
+ });
1969
+ }
1278
1970
  if (budget) {
1279
1971
  await compactIfNeeded([], currentTurnUserMessages ?? []);
1280
1972
  budget.assertFits(budget.evaluate([
1281
1973
  ...contextMessages,
1282
- ...injectDynamicContext(projectClaudeModelMessages(snapshot.entries)),
1974
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
1283
1975
  ], definitions));
1284
1976
  }
1285
1977
  let stopHookActive = false;
1286
1978
  const runtimeRequest = {
1287
1979
  messages: [
1288
1980
  ...contextMessages,
1289
- ...injectDynamicContext(projectClaudeModelMessages(snapshot.entries)),
1981
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
1290
1982
  ],
1291
1983
  cwd: this.activeCwd(),
1292
1984
  toolResultDirectory,
@@ -1301,7 +1993,7 @@ export class ClaudeSessionService {
1301
1993
  await compactIfNeeded([], currentTurnUserMessages ?? []);
1302
1994
  return [
1303
1995
  ...contextMessages,
1304
- ...injectDynamicContext(projectClaudeModelMessages(snapshot.entries)),
1996
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
1305
1997
  ];
1306
1998
  },
1307
1999
  ...(this.options.hooks ||
@@ -1373,7 +2065,7 @@ export class ClaudeSessionService {
1373
2065
  lastPrompt: prompt,
1374
2066
  leafUuid: finalLeafUuid,
1375
2067
  }));
1376
- const totalUsage = mergeUsage(mergeUsage(recoveryUsage, compactionUsage), result.usage);
2068
+ const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
1377
2069
  return {
1378
2070
  sessionId,
1379
2071
  text: structuredCapture && structuredCapture.calls === 1
@@ -1604,7 +2296,7 @@ export class ClaudeSessionService {
1604
2296
  : pathFromCwd;
1605
2297
  }
1606
2298
  activeCwd() {
1607
- return this.options.workspace?.cwd() ?? this.options.cwd;
2299
+ return this.options.workspace?.cwd() ?? this.runtimeCwd;
1608
2300
  }
1609
2301
  restoreWorktree(entries) {
1610
2302
  const latest = [...entries]
@@ -1620,10 +2312,218 @@ export class ClaudeSessionService {
1620
2312
  paths(sessionId) {
1621
2313
  return resolveClaudePaths({
1622
2314
  configDir: this.options.configRoot,
1623
- cwd: this.sessionCwds.get(sessionId) ?? this.options.cwd,
2315
+ cwd: this.sessionCwds.get(sessionId) ?? this.activeCwd(),
1624
2316
  sessionId,
1625
2317
  });
1626
2318
  }
2319
+ async appendCdCommand(sessionId, cwd) {
2320
+ const result = await this.store(sessionId).withLease(async (lease) => {
2321
+ const snapshot = await lease.load();
2322
+ if (snapshot.entries.length === 0) {
2323
+ throw new Error(`Claude session not found: ${sessionId}`);
2324
+ }
2325
+ const appendResult = await lease.appendMany(snapshot.tail, this.cdCommandEntries(sessionId, cwd, this.logicalTailUuid(snapshot.tail)));
2326
+ if (appendResult.status === 'conflict') {
2327
+ throw new Error(`Claude local command append conflict: ${appendResult.reason}`);
2328
+ }
2329
+ });
2330
+ if (result.status === 'conflict') {
2331
+ throw new Error(`Claude local command conflict: ${result.reason}`);
2332
+ }
2333
+ }
2334
+ async appendSystemLocalCommand(sessionId, command, args, output) {
2335
+ while (true) {
2336
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
2337
+ const snapshot = await lease.load();
2338
+ if (snapshot.entries.length === 0) {
2339
+ throw new Error(`Claude session not found: ${sessionId}`);
2340
+ }
2341
+ const appendResult = await lease.appendMany(snapshot.tail, this.localCommandEntries(sessionId, this.activeCwd(), this.logicalTailUuid(snapshot.tail), command, args, output));
2342
+ if (appendResult.status === 'conflict') {
2343
+ throw new Error(`Claude local command append conflict: ${appendResult.reason}`);
2344
+ }
2345
+ });
2346
+ if (result.status === 'completed')
2347
+ return;
2348
+ await new Promise((resolve) => setTimeout(resolve, 25));
2349
+ }
2350
+ }
2351
+ async ensureLocalSession(sessionId, permissionMode) {
2352
+ if (sessionId)
2353
+ return sessionId;
2354
+ this.assertWritable();
2355
+ const createdSessionId = randomUUID();
2356
+ const store = this.turnStore(createdSessionId);
2357
+ const created = await store.create([
2358
+ { type: 'mode', mode: 'normal', sessionId: createdSessionId },
2359
+ {
2360
+ type: 'permission-mode',
2361
+ permissionMode,
2362
+ sessionId: createdSessionId,
2363
+ },
2364
+ ]);
2365
+ if (created.status === 'conflict') {
2366
+ throw new Error(`Session ID ${createdSessionId} is already in use`);
2367
+ }
2368
+ this.sessionCwds.set(createdSessionId, this.activeCwd());
2369
+ this.options.interactiveTools?.restore(createdSessionId, [
2370
+ { type: 'mode', mode: 'normal', sessionId: createdSessionId },
2371
+ {
2372
+ type: 'permission-mode',
2373
+ permissionMode,
2374
+ sessionId: createdSessionId,
2375
+ },
2376
+ ]);
2377
+ return createdSessionId;
2378
+ }
2379
+ async appendInputHistory(display, sessionId) {
2380
+ await mkdir(this.options.configRoot, { recursive: true });
2381
+ await appendFile(join(this.options.configRoot, 'history.jsonl'), `${JSON.stringify({
2382
+ display,
2383
+ pastedContents: {},
2384
+ timestamp: Date.now(),
2385
+ project: this.activeCwd(),
2386
+ sessionId,
2387
+ })}\n`, 'utf8');
2388
+ }
2389
+ async appendBackgroundNotification(sessionId, content) {
2390
+ while (true) {
2391
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
2392
+ const snapshot = await lease.load();
2393
+ if (snapshot.entries.length === 0) {
2394
+ throw new Error(`Claude session not found: ${sessionId}`);
2395
+ }
2396
+ const timestamp = new Date().toISOString();
2397
+ const entries = [
2398
+ {
2399
+ type: 'queue-operation',
2400
+ operation: 'enqueue',
2401
+ timestamp,
2402
+ sessionId,
2403
+ content,
2404
+ },
2405
+ {
2406
+ type: 'queue-operation',
2407
+ operation: 'dequeue',
2408
+ timestamp,
2409
+ sessionId,
2410
+ },
2411
+ {
2412
+ parentUuid: this.logicalTailUuid(snapshot.tail),
2413
+ isSidechain: false,
2414
+ promptId: randomUUID(),
2415
+ type: 'user',
2416
+ message: { role: 'user', content },
2417
+ uuid: randomUUID(),
2418
+ timestamp,
2419
+ permissionMode: 'default',
2420
+ origin: { kind: 'task-notification' },
2421
+ promptSource: 'system',
2422
+ userType: 'external',
2423
+ entrypoint: 'cli',
2424
+ cwd: this.activeCwd(),
2425
+ sessionId,
2426
+ version: this.options.claudeVersion,
2427
+ gitBranch: null,
2428
+ },
2429
+ ];
2430
+ const appended = await lease.appendMany(snapshot.tail, entries);
2431
+ if (appended.status === 'conflict') {
2432
+ throw new Error(`Claude background notification append conflict: ${appended.reason}`);
2433
+ }
2434
+ });
2435
+ if (result.status === 'completed')
2436
+ return;
2437
+ await new Promise((resolve) => setTimeout(resolve, 25));
2438
+ }
2439
+ }
2440
+ enqueueBackgroundNotifications(sessionId, messages) {
2441
+ const previous = this.backgroundNotificationWrites.get(sessionId);
2442
+ const queued = (previous ?? Promise.resolve()).then(async () => {
2443
+ for (const message of messages) {
2444
+ await this.appendBackgroundNotification(sessionId, message);
2445
+ }
2446
+ });
2447
+ this.backgroundNotificationWrites.set(sessionId, queued);
2448
+ const cleanup = () => {
2449
+ if (this.backgroundNotificationWrites.get(sessionId) === queued)
2450
+ this.backgroundNotificationWrites.delete(sessionId);
2451
+ };
2452
+ void queued.then(cleanup, cleanup);
2453
+ return queued;
2454
+ }
2455
+ cdCommandEntries(sessionId, cwd, parentUuid) {
2456
+ const timestamp = new Date().toISOString();
2457
+ const common = {
2458
+ isSidechain: false,
2459
+ timestamp,
2460
+ userType: 'external',
2461
+ entrypoint: 'cli',
2462
+ cwd,
2463
+ sessionId,
2464
+ version: this.options.claudeVersion,
2465
+ gitBranch: null,
2466
+ };
2467
+ const localCommands = this.localCommandEntries(sessionId, cwd, parentUuid, 'cd', cwd, `Moved to ${cwd}`, timestamp);
2468
+ const secondUuid = localCommands[1]?.uuid;
2469
+ if (typeof secondUuid !== 'string') {
2470
+ throw new Error('Claude cd local command pair is incomplete');
2471
+ }
2472
+ return [
2473
+ ...localCommands,
2474
+ {
2475
+ ...common,
2476
+ parentUuid: secondUuid,
2477
+ promptId: randomUUID(),
2478
+ type: 'user',
2479
+ message: {
2480
+ role: 'user',
2481
+ content: `<system-reminder>\nThe session's working directory has changed to ${cwd} (via /cd). The environment block at the start of this conversation still names the previous directory — that information is stale. All tool calls and relative paths now resolve from ${cwd}.\n</system-reminder>`,
2482
+ },
2483
+ isMeta: true,
2484
+ uuid: randomUUID(),
2485
+ },
2486
+ ];
2487
+ }
2488
+ localCommandEntries(sessionId, cwd, parentUuid, command, args, output, timestamp = new Date().toISOString()) {
2489
+ const firstUuid = randomUUID();
2490
+ return [
2491
+ {
2492
+ parentUuid,
2493
+ isSidechain: false,
2494
+ type: 'system',
2495
+ subtype: 'local_command',
2496
+ content: `<command-name>/${command}</command-name>\n <command-message>${command}</command-message>\n <command-args>${args}</command-args>`,
2497
+ level: 'info',
2498
+ timestamp,
2499
+ uuid: firstUuid,
2500
+ isMeta: false,
2501
+ userType: 'external',
2502
+ entrypoint: 'cli',
2503
+ cwd,
2504
+ sessionId,
2505
+ version: this.options.claudeVersion,
2506
+ gitBranch: null,
2507
+ },
2508
+ {
2509
+ parentUuid: firstUuid,
2510
+ isSidechain: false,
2511
+ type: 'system',
2512
+ subtype: 'local_command',
2513
+ content: `<local-command-stdout>${output}</local-command-stdout>`,
2514
+ level: 'info',
2515
+ timestamp,
2516
+ uuid: randomUUID(),
2517
+ isMeta: false,
2518
+ userType: 'external',
2519
+ entrypoint: 'cli',
2520
+ cwd,
2521
+ sessionId,
2522
+ version: this.options.claudeVersion,
2523
+ gitBranch: null,
2524
+ },
2525
+ ];
2526
+ }
1627
2527
  store(sessionId) {
1628
2528
  const paths = this.paths(sessionId);
1629
2529
  return new ClaudeTranscriptStore({