praxis-agent 0.2.0 → 0.3.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 +18 -4
  2. package/dist/application/session-service.d.ts +34 -0
  3. package/dist/application/session-service.d.ts.map +1 -1
  4. package/dist/application/session-service.js +646 -38
  5. package/dist/application/session-service.js.map +1 -1
  6. package/dist/cli/interactive.d.ts +68 -8
  7. package/dist/cli/interactive.d.ts.map +1 -1
  8. package/dist/cli/interactive.js +2617 -206
  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 +142 -3
  14. package/dist/cli/tui/claude-style.d.ts.map +1 -1
  15. package/dist/cli/tui/claude-style.js +377 -16
  16. package/dist/cli/tui/claude-style.js.map +1 -1
  17. package/dist/cli/tui/clipboard.d.ts +24 -0
  18. package/dist/cli/tui/clipboard.d.ts.map +1 -0
  19. package/dist/cli/tui/clipboard.js +153 -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 +183 -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 +23 -2
  66. package/dist/cli.d.ts.map +1 -1
  67. package/dist/cli.js +45 -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 +34 -7
  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 +4 -3
  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 { 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;
@@ -83,8 +92,10 @@ export class ClaudeSessionService {
83
92
  sessionCwds = new Map();
84
93
  hostedSubagents = new Set();
85
94
  downloadedFileResourceSessions = new Set();
95
+ runtimeCwd;
86
96
  constructor(options) {
87
97
  this.options = options;
98
+ this.runtimeCwd = options.workspace?.cwd() ?? options.cwd;
88
99
  this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
89
100
  this.scheduledPrompts =
90
101
  options.tools && (options.scheduledToolNames?.length ?? 0) > 0
@@ -300,10 +311,18 @@ export class ClaudeSessionService {
300
311
  this.worktreeManager?.bindSession(sessionId);
301
312
  return this.executeTurn(sessionId, prompt, false, signal, name, images, documents);
302
313
  }
314
+ async runShell(command, signal, sessionId = randomUUID(), name) {
315
+ this.worktreeManager?.bindSession(sessionId);
316
+ return this.executeTurn(sessionId, `! ${command}`, false, signal, name, [], [], undefined, command);
317
+ }
303
318
  async resume(sessionId, prompt, signal, name, images, documents, resumeSessionAt) {
304
319
  this.worktreeManager?.bindSession(sessionId);
305
320
  return this.executeTurn(sessionId, prompt, true, signal, name, images, documents, resumeSessionAt);
306
321
  }
322
+ async resumeShell(sessionId, command, signal, name, resumeSessionAt) {
323
+ this.worktreeManager?.bindSession(sessionId);
324
+ return this.executeTurn(sessionId, `! ${command}`, true, signal, name, [], [], resumeSessionAt, command);
325
+ }
307
326
  async promptSuggestion(sessionId, signal) {
308
327
  const provider = this.provider();
309
328
  const loaded = this.options.sessionPersistence === false
@@ -355,6 +374,28 @@ export class ClaudeSessionService {
355
374
  }
356
375
  return validPromptSuggestion(suggestion);
357
376
  }
377
+ async sessionNameSuggestion(sessionId, signal) {
378
+ const provider = this.provider();
379
+ const loaded = await this.turnStore(sessionId).withLease((lease) => lease.load());
380
+ if (loaded.status === 'conflict' || loaded.value.entries.length === 0) {
381
+ return null;
382
+ }
383
+ let suggestion = '';
384
+ for await (const event of provider.complete({
385
+ messages: [
386
+ ...projectClaudeModelMessages(loaded.value.entries),
387
+ { role: 'user', content: SESSION_NAME_INSTRUCTION },
388
+ ],
389
+ ...(this.options.effort ? { effort: this.options.effort } : {}),
390
+ ...(signal ? { signal } : {}),
391
+ })) {
392
+ if (event.type === 'text-delta')
393
+ suggestion += event.delta;
394
+ if (event.type === 'tool-call')
395
+ return null;
396
+ }
397
+ return validSessionName(suggestion);
398
+ }
358
399
  async sessions() {
359
400
  const paths = this.paths(randomUUID());
360
401
  let names;
@@ -456,6 +497,317 @@ export class ClaudeSessionService {
456
497
  throw error;
457
498
  }
458
499
  }
500
+ async transcript(sessionId, resumeSessionAt) {
501
+ try {
502
+ const recovery = await this.store(sessionId).loadReadOnly();
503
+ if (recovery.entries.length === 0) {
504
+ throw new Error(`Claude session not found: ${sessionId}`);
505
+ }
506
+ const entries = resumeSessionAt === undefined
507
+ ? recovery.entries
508
+ : selectClaudeTranscriptAtMessage(recovery.entries, resumeSessionAt);
509
+ return projectClaudeDisplayTranscript(entries);
510
+ }
511
+ catch (error) {
512
+ if (error.code === 'ENOENT') {
513
+ throw new Error(`Claude session not found: ${sessionId}`);
514
+ }
515
+ throw error;
516
+ }
517
+ }
518
+ async rename(sessionId, name) {
519
+ this.assertWritable();
520
+ const normalized = name.trim();
521
+ if (!normalized)
522
+ throw new Error('Session name must not be empty');
523
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
524
+ const snapshot = await lease.load();
525
+ if (snapshot.entries.length === 0) {
526
+ throw new Error(`Claude session not found: ${sessionId}`);
527
+ }
528
+ if (this.hasSessionName(snapshot.entries, normalized))
529
+ return;
530
+ const appendResult = await lease.appendMany(snapshot.tail, this.sessionNameEntries(sessionId, normalized));
531
+ if (appendResult.status === 'conflict') {
532
+ throw new Error(`Claude transcript rename conflict: ${appendResult.reason}`);
533
+ }
534
+ });
535
+ if (result.status === 'conflict') {
536
+ throw new Error(`Claude transcript rename conflict: ${result.reason}`);
537
+ }
538
+ }
539
+ async changeCwd(sessionId, requestedCwd) {
540
+ this.assertWritable();
541
+ const previousCwd = this.activeCwd();
542
+ const expandedCwd = requestedCwd === '~'
543
+ ? homedir()
544
+ : requestedCwd.startsWith('~/')
545
+ ? join(homedir(), requestedCwd.slice(2))
546
+ : requestedCwd;
547
+ const cwd = await realpath(isAbsolute(expandedCwd) ? expandedCwd : join(previousCwd, expandedCwd));
548
+ if (!(await stat(cwd)).isDirectory()) {
549
+ throw new Error(`Not a directory: ${requestedCwd}`);
550
+ }
551
+ if (sessionId && this.options.sessionPersistence !== false) {
552
+ const sourcePaths = resolveClaudePaths({
553
+ configDir: this.options.configRoot,
554
+ cwd: this.sessionCwds.get(sessionId) ?? previousCwd,
555
+ sessionId,
556
+ });
557
+ const targetPaths = resolveClaudePaths({
558
+ configDir: this.options.configRoot,
559
+ cwd,
560
+ sessionId,
561
+ });
562
+ if (sourcePaths.sessionFile !== targetPaths.sessionFile) {
563
+ try {
564
+ await lstat(targetPaths.sessionFile);
565
+ throw new Error(`Claude transcript already exists at relocation target: ${targetPaths.sessionFile}`);
566
+ }
567
+ catch (error) {
568
+ if (error.code !== 'ENOENT')
569
+ throw error;
570
+ }
571
+ const sourceStore = this.store(sessionId);
572
+ const relocated = await sourceStore.withLease(async (lease) => {
573
+ const snapshot = await lease.load();
574
+ if (snapshot.entries.length === 0) {
575
+ throw new Error(`Claude session not found: ${sessionId}`);
576
+ }
577
+ await mkdir(targetPaths.projectRoot, { recursive: true });
578
+ const relocationId = randomUUID();
579
+ const stagingFile = join(targetPaths.projectRoot, `.${sessionId}.${relocationId}.relocating`);
580
+ const stagingStore = new ClaudeTranscriptStore({
581
+ sessionFile: stagingFile,
582
+ lockFile: join(targetPaths.praxisRoot, 'locks', `${sessionId}.${relocationId}.lock`),
583
+ schema: this.schema,
584
+ });
585
+ let publishedIdentity;
586
+ try {
587
+ await copyFile(sourcePaths.sessionFile, stagingFile);
588
+ const staged = await stagingStore.withLease((stagingLease) => stagingLease.appendMany(snapshot.tail, [
589
+ {
590
+ type: 'relocated',
591
+ sessionId,
592
+ relocatedCwd: cwd,
593
+ },
594
+ ...this.cdCommandEntries(sessionId, cwd, this.logicalTailUuid(snapshot.tail)),
595
+ ]));
596
+ if (staged.status === 'conflict') {
597
+ throw new Error(`Claude transcript relocation conflict: ${staged.reason}`);
598
+ }
599
+ if (staged.value.status === 'conflict') {
600
+ throw new Error(`Claude transcript relocation conflict: ${staged.value.reason}`);
601
+ }
602
+ const stagingMetadata = await lstat(stagingFile);
603
+ await link(stagingFile, targetPaths.sessionFile);
604
+ publishedIdentity = {
605
+ dev: stagingMetadata.dev,
606
+ ino: stagingMetadata.ino,
607
+ };
608
+ await unlink(stagingFile);
609
+ await unlink(sourcePaths.sessionFile);
610
+ }
611
+ catch (error) {
612
+ await unlink(stagingFile).catch(() => undefined);
613
+ if (publishedIdentity) {
614
+ const targetMetadata = await lstat(targetPaths.sessionFile).catch(() => undefined);
615
+ if (targetMetadata?.dev === publishedIdentity.dev &&
616
+ targetMetadata.ino === publishedIdentity.ino) {
617
+ await unlink(targetPaths.sessionFile).catch(() => undefined);
618
+ }
619
+ }
620
+ throw error;
621
+ }
622
+ });
623
+ if (relocated.status === 'conflict') {
624
+ throw new Error(`Claude transcript relocation conflict: ${relocated.reason}`);
625
+ }
626
+ }
627
+ else {
628
+ await this.appendCdCommand(sessionId, cwd);
629
+ }
630
+ this.sessionCwds.set(sessionId, cwd);
631
+ this.runtimeCwd = cwd;
632
+ this.options.workspace?.setCwd(cwd);
633
+ return cwd;
634
+ }
635
+ this.runtimeCwd = cwd;
636
+ this.options.workspace?.setCwd(cwd);
637
+ if (sessionId)
638
+ this.sessionCwds.set(sessionId, cwd);
639
+ return cwd;
640
+ }
641
+ async recordCdUsage(sessionId) {
642
+ this.assertWritable();
643
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
644
+ const snapshot = await lease.load();
645
+ if (snapshot.entries.length === 0) {
646
+ throw new Error(`Claude session not found: ${sessionId}`);
647
+ }
648
+ const promptId = randomUUID();
649
+ const firstUuid = randomUUID();
650
+ const timestamp = new Date().toISOString();
651
+ const common = {
652
+ isSidechain: false,
653
+ promptId,
654
+ timestamp,
655
+ userType: 'external',
656
+ entrypoint: 'cli',
657
+ cwd: this.activeCwd(),
658
+ sessionId,
659
+ version: this.options.claudeVersion,
660
+ gitBranch: null,
661
+ };
662
+ const appendResult = await lease.appendMany(snapshot.tail, [
663
+ {
664
+ ...common,
665
+ parentUuid: this.logicalTailUuid(snapshot.tail),
666
+ type: 'user',
667
+ message: {
668
+ role: 'user',
669
+ content: '<command-name>/cd</command-name>\n <command-message>cd</command-message>\n <command-args></command-args>',
670
+ },
671
+ uuid: firstUuid,
672
+ },
673
+ {
674
+ ...common,
675
+ parentUuid: firstUuid,
676
+ type: 'user',
677
+ message: {
678
+ role: 'user',
679
+ content: '<local-command-stdout>Usage: /cd <path></local-command-stdout>',
680
+ },
681
+ uuid: randomUUID(),
682
+ },
683
+ ]);
684
+ if (appendResult.status === 'conflict') {
685
+ throw new Error(`Claude cd usage append conflict: ${appendResult.reason}`);
686
+ }
687
+ });
688
+ if (result.status === 'conflict') {
689
+ throw new Error(`Claude cd usage conflict: ${result.reason}`);
690
+ }
691
+ }
692
+ async compact(sessionId, signal, selection) {
693
+ this.assertWritable();
694
+ const provider = this.provider();
695
+ const result = await this.turnStore(sessionId).withLease(async (lease) => {
696
+ const snapshot = await lease.load();
697
+ if (snapshot.entries.length === 0) {
698
+ throw new Error(`Claude session not found: ${sessionId}`);
699
+ }
700
+ const activeEntries = selectClaudeActiveTranscript(snapshot.entries);
701
+ const allMessages = projectClaudeModelMessages(activeEntries);
702
+ let selectedEntries = activeEntries;
703
+ let logicalParentUuid = this.lastMessageUuid(activeEntries);
704
+ let preservedEntries = [];
705
+ if (selection) {
706
+ const targetIndex = activeEntries.findIndex((entry) => entry.uuid === selection.messageId);
707
+ if (targetIndex < 0) {
708
+ throw new Error(`No rewind point found with message.uuid of: ${selection.messageId}`);
709
+ }
710
+ if (selection.direction === 'from') {
711
+ selectedEntries = activeEntries.slice(targetIndex);
712
+ preservedEntries = activeEntries.slice(0, targetIndex);
713
+ logicalParentUuid = this.lastMessageUuid(preservedEntries);
714
+ if (!logicalParentUuid)
715
+ logicalParentUuid = selection.messageId;
716
+ }
717
+ else {
718
+ selectedEntries = activeEntries.slice(0, targetIndex);
719
+ preservedEntries = activeEntries.slice(targetIndex);
720
+ const target = activeEntries[targetIndex];
721
+ logicalParentUuid =
722
+ typeof target?.parentUuid === 'string'
723
+ ? target.parentUuid
724
+ : this.lastMessageUuid(selectedEntries);
725
+ }
726
+ }
727
+ const selectedMessages = projectClaudeModelMessages(selectedEntries);
728
+ const messages = [
729
+ ...selectedMessages,
730
+ ...(selection?.context
731
+ ? [
732
+ {
733
+ role: 'user',
734
+ content: `Additional summarization context: ${selection.context}`,
735
+ },
736
+ ]
737
+ : []),
738
+ ];
739
+ if (!logicalParentUuid || selectedMessages.length === 0) {
740
+ throw new Error('Cannot compact an empty Claude transcript');
741
+ }
742
+ if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
743
+ throw new Error('Cannot compact a Claude session with unresolved tool calls');
744
+ }
745
+ this.options.eventSink?.({ type: 'state', state: 'compacting' });
746
+ const contextWindowTokens = this.contextBudget(provider)?.contextWindowTokens ??
747
+ provider.capabilities.contextWindowTokens ??
748
+ 200_000;
749
+ const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
750
+ messages,
751
+ targetTokens: Math.min(8192, Math.max(1, Math.floor(contextWindowTokens / 4))),
752
+ contextWindowTokens,
753
+ ...(signal ? { signal } : {}),
754
+ });
755
+ if (signal?.aborted)
756
+ throw new AgentRunCancelledError();
757
+ const preTokens = estimateModelRequestTokens(allMessages);
758
+ const summary = compacted.summary;
759
+ const preservedMessages = projectClaudeModelMessages(preservedEntries);
760
+ const boundaryUuid = randomUUID();
761
+ const summaryUuid = randomUUID();
762
+ const uuids = [boundaryUuid, summaryUuid];
763
+ const entries = createClaudeCompactEntries({
764
+ sessionId,
765
+ logicalParentUuid,
766
+ summary,
767
+ preTokens,
768
+ postTokens: estimateModelRequestTokens([
769
+ { role: 'user', content: formatClaudeCompactSummary(summary) },
770
+ ...preservedMessages,
771
+ ]),
772
+ previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
773
+ durationMs: compacted.durationMs,
774
+ cwd: this.activeCwd(),
775
+ claudeVersion: this.options.claudeVersion,
776
+ gitBranch: null,
777
+ trigger: 'manual',
778
+ ...(selection
779
+ ? {
780
+ summarizeMetadata: {
781
+ messagesSummarized: selectedMessages.length,
782
+ direction: selection.direction === 'to' ? 'up_to' : selection.direction,
783
+ },
784
+ preservedUuids: preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
785
+ }
786
+ : {}),
787
+ createUuid: () => uuids.shift() ?? randomUUID(),
788
+ });
789
+ const appendResult = await lease.appendMany(snapshot.tail, entries);
790
+ if (appendResult.status === 'conflict') {
791
+ throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
792
+ }
793
+ this.options.eventSink?.({
794
+ type: 'compact-boundary',
795
+ trigger: 'manual',
796
+ preTokens,
797
+ uuid: boundaryUuid,
798
+ });
799
+ return {
800
+ summary,
801
+ usage: compacted.usage,
802
+ preTokens,
803
+ ...(selection ? { messagesSummarized: selectedMessages.length } : {}),
804
+ };
805
+ });
806
+ if (result.status === 'conflict') {
807
+ throw new Error(`Claude transcript compact conflict: ${result.reason}`);
808
+ }
809
+ return result.value;
810
+ }
459
811
  async fork(parentSessionId, sessionId = randomUUID(), resumeSessionAt) {
460
812
  this.assertSessionPersistence();
461
813
  this.assertWritable();
@@ -496,13 +848,100 @@ export class ClaudeSessionService {
496
848
  throw new Error(`Claude file rewind conflict: ${result.reason}`);
497
849
  }
498
850
  }
499
- async executeTurn(sessionId, prompt, requireExisting, signal, name, images = [], documents = [], resumeSessionAt) {
851
+ async rewindPoints(sessionId) {
852
+ const result = await this.turnStore(sessionId).withLease((lease) => lease.load());
853
+ if (result.status === 'conflict') {
854
+ throw new Error(`Claude transcript rewind conflict: ${result.reason}`);
855
+ }
856
+ if (result.value.entries.length === 0) {
857
+ throw new Error(`Claude session not found: ${sessionId}`);
858
+ }
859
+ const active = selectClaudeActiveTranscript(result.value.entries);
860
+ const snapshots = new Set(active
861
+ .filter((entry) => entry.type === 'file-history-snapshot' &&
862
+ typeof entry.messageId === 'string')
863
+ .map((entry) => entry.messageId));
864
+ const changes = new Map();
865
+ for (const entry of active) {
866
+ if (entry.type !== 'file-history-delta' ||
867
+ typeof entry.snapshotMessageId !== 'string' ||
868
+ typeof entry.trackingPath !== 'string') {
869
+ continue;
870
+ }
871
+ const paths = changes.get(entry.snapshotMessageId) ?? new Set();
872
+ paths.add(entry.trackingPath);
873
+ changes.set(entry.snapshotMessageId, paths);
874
+ }
875
+ const points = [];
876
+ let branchMessageId;
877
+ for (const entry of active) {
878
+ if ((entry.type === 'user' || entry.type === 'assistant') &&
879
+ typeof entry.uuid === 'string') {
880
+ const message = entry.message;
881
+ if (typeof message === 'object' &&
882
+ message !== null &&
883
+ !Array.isArray(message)) {
884
+ const role = message.role;
885
+ const content = message.content;
886
+ if (entry.type === 'user' &&
887
+ role === 'user' &&
888
+ typeof content === 'string' &&
889
+ entry.isCompactSummary !== true &&
890
+ entry.isMeta !== true &&
891
+ typeof entry.sourceToolAssistantUUID !== 'string' &&
892
+ !content.startsWith('<bash-input>') &&
893
+ !content.startsWith('<local-command-') &&
894
+ !content.startsWith('<command-name>')) {
895
+ points.push({
896
+ messageId: entry.uuid,
897
+ prompt: content,
898
+ ...(typeof entry.timestamp === 'string'
899
+ ? { timestamp: entry.timestamp }
900
+ : {}),
901
+ ...(branchMessageId === undefined ? {} : { branchMessageId }),
902
+ fileChanges: [...(changes.get(entry.uuid) ?? [])],
903
+ fileRestoreAvailable: snapshots.has(entry.uuid),
904
+ });
905
+ }
906
+ if (role === 'user' || role === 'assistant') {
907
+ branchMessageId = entry.uuid;
908
+ }
909
+ }
910
+ }
911
+ }
912
+ return points;
913
+ }
914
+ async setPermissionMode(sessionId, permissionMode) {
915
+ this.assertSessionPersistence();
916
+ this.assertWritable();
917
+ const result = await this.store(sessionId).withLease(async (lease) => {
918
+ const snapshot = await lease.load();
919
+ if (snapshot.entries.length === 0) {
920
+ throw new Error(`Claude session not found: ${sessionId}`);
921
+ }
922
+ this.options.interactiveTools?.restore(sessionId, snapshot.entries);
923
+ const entry = {
924
+ type: 'permission-mode',
925
+ permissionMode,
926
+ sessionId,
927
+ };
928
+ await this.append(lease, snapshot.tail, entry);
929
+ await this.options.interactiveTools?.setMode(sessionId, permissionMode);
930
+ });
931
+ if (result.status === 'conflict') {
932
+ throw new Error(`Claude permission mode update conflict: ${result.reason}`);
933
+ }
934
+ }
935
+ async executeTurn(sessionId, prompt, requireExisting, signal, name, images = [], documents = [], resumeSessionAt, shellCommand) {
500
936
  this.assertWritable();
501
937
  if (prompt.length === 0 && images.length === 0 && documents.length === 0)
502
938
  throw new Error('Prompt must not be empty');
503
939
  if (name !== undefined && name.length === 0) {
504
940
  throw new Error('Session name must not be empty');
505
941
  }
942
+ if (shellCommand !== undefined && shellCommand.trim().length === 0) {
943
+ throw new Error('Shell command must not be empty');
944
+ }
506
945
  await this.ensureFileResources(sessionId, signal);
507
946
  this.worktreeManager?.bindSession(sessionId);
508
947
  if (this.options.initialWorktree) {
@@ -575,7 +1014,8 @@ export class ClaudeSessionService {
575
1014
  if (this.options.maxBudgetUsd !== undefined && !initialPricing) {
576
1015
  throw new Error(`Cannot enforce --max-budget-usd: no pricing is configured for model ${provider.model ?? 'praxis/provider'}`);
577
1016
  }
578
- let currentPromptId = null;
1017
+ const shellInputUuid = shellCommand === undefined ? null : randomUUID();
1018
+ let currentPromptId = shellInputUuid;
579
1019
  let lastAssistantUuid = null;
580
1020
  const fileHistory = this.options.fileCheckpointing &&
581
1021
  this.options.sessionPersistence !== false
@@ -1047,9 +1487,13 @@ export class ClaudeSessionService {
1047
1487
  ]
1048
1488
  : []),
1049
1489
  ];
1050
- const expansion = this.options.extensions
1051
- ? await this.options.extensions.expandPromptAsync(prompt, signal, toolResultDirectory)
1052
- : { userMessages: [prompt] };
1490
+ const expansion = shellCommand === undefined
1491
+ ? this.options.extensions
1492
+ ? await this.options.extensions.expandPromptAsync(prompt, signal, toolResultDirectory)
1493
+ : { userMessages: [prompt] }
1494
+ : {
1495
+ userMessages: [`<bash-input>${shellCommand}</bash-input>`],
1496
+ };
1053
1497
  const expandedMessages = expansion.messages ?? expansion.userMessages.map((text) => ({ text }));
1054
1498
  const attachmentIndex = expansion.messages
1055
1499
  ? expandedMessages.length - 1
@@ -1086,7 +1530,37 @@ export class ClaudeSessionService {
1086
1530
  ? { documents }
1087
1531
  : {}),
1088
1532
  }));
1533
+ const agentMentionMessages = shellCommand === undefined
1534
+ ? (this.options.extensions?.agentMentionMessages(prompt) ?? [])
1535
+ : [];
1536
+ const injectAgentMentionContext = (messages) => {
1537
+ if (agentMentionMessages.length === 0)
1538
+ return [...messages];
1539
+ let insertionIndex = messages.length;
1540
+ let foundPrompt = false;
1541
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1542
+ const message = messages[index];
1543
+ if (message?.role === 'user' &&
1544
+ typeof message.content === 'string' &&
1545
+ message.content.endsWith(prompt)) {
1546
+ insertionIndex = index;
1547
+ foundPrompt = true;
1548
+ break;
1549
+ }
1550
+ }
1551
+ if (!foundPrompt)
1552
+ return [...messages];
1553
+ return [
1554
+ ...messages.slice(0, insertionIndex),
1555
+ ...agentMentionMessages.map((content) => ({
1556
+ role: 'user',
1557
+ content,
1558
+ })),
1559
+ ...messages.slice(insertionIndex),
1560
+ ];
1561
+ };
1089
1562
  const injectDynamicContext = (messages) => injectFirstUserMessageContext(messages, assembledContext?.firstUserMessageContext);
1563
+ const injectTurnContext = (messages) => injectAgentMentionContext(injectDynamicContext(messages));
1090
1564
  let compactionAnchorUuid = this.lastMessageUuid(snapshot.entries);
1091
1565
  const compactIfNeeded = async (pendingMessages = [], preservedUserMessages = []) => {
1092
1566
  if (!budget)
@@ -1094,13 +1568,13 @@ export class ClaudeSessionService {
1094
1568
  const historyMessages = projectClaudeModelMessages(snapshot.entries);
1095
1569
  const predicted = budget.evaluate([
1096
1570
  ...contextMessages,
1097
- ...injectDynamicContext([...historyMessages, ...pendingMessages]),
1571
+ ...injectTurnContext([...historyMessages, ...pendingMessages]),
1098
1572
  ], definitions);
1099
1573
  if (!predicted.shouldCompact)
1100
1574
  return;
1101
1575
  const irreducibleMessages = [
1102
1576
  ...contextMessages,
1103
- ...injectDynamicContext([
1577
+ ...injectTurnContext([
1104
1578
  ...pendingMessages,
1105
1579
  ...preservedUserMessages.map((content) => ({
1106
1580
  role: 'user',
@@ -1146,7 +1620,7 @@ export class ClaudeSessionService {
1146
1620
  const boundaryUuid = randomUUID();
1147
1621
  const summaryUuid = randomUUID();
1148
1622
  const timestamp = new Date().toISOString();
1149
- const preTokens = budget.evaluate([...contextMessages, ...injectDynamicContext(historyMessages)], definitions).estimatedTokens;
1623
+ const preTokens = budget.evaluate([...contextMessages, ...injectTurnContext(historyMessages)], definitions).estimatedTokens;
1150
1624
  const compactEntries = (postTokens) => {
1151
1625
  const uuids = [boundaryUuid, summaryUuid];
1152
1626
  return createClaudeCompactEntries({
@@ -1187,13 +1661,10 @@ export class ClaudeSessionService {
1187
1661
  ...provisionalEntries,
1188
1662
  ...replayEntries,
1189
1663
  ]);
1190
- const afterHistory = budget.evaluate([...contextMessages, ...injectDynamicContext(compactedHistory)], definitions);
1664
+ const afterHistory = budget.evaluate([...contextMessages, ...injectTurnContext(compactedHistory)], definitions);
1191
1665
  const afterPending = budget.evaluate([
1192
1666
  ...contextMessages,
1193
- ...injectDynamicContext([
1194
- ...compactedHistory,
1195
- ...pendingMessages,
1196
- ]),
1667
+ ...injectTurnContext([...compactedHistory, ...pendingMessages]),
1197
1668
  ], definitions);
1198
1669
  budget.assertFits(afterPending);
1199
1670
  const entries = [
@@ -1224,25 +1695,26 @@ export class ClaudeSessionService {
1224
1695
  };
1225
1696
  await compactIfNeeded(pendingUserMessages);
1226
1697
  for (const [index, message] of expandedMessages.entries()) {
1698
+ if (shellCommand !== undefined)
1699
+ break;
1227
1700
  const messageImages = [
1228
1701
  ...(index === attachmentIndex ? images : []),
1229
1702
  ...(message.images ?? []),
1230
1703
  ];
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));
1704
+ const persistenceEvent = messageImages.length > 0 ||
1705
+ (index === attachmentIndex && documents.length > 0)
1706
+ ? {
1707
+ type: 'user-message',
1708
+ text: message.text,
1709
+ images: messageImages,
1710
+ ...(index === attachmentIndex && documents.length > 0
1711
+ ? { documents }
1712
+ : {}),
1713
+ }
1714
+ : index === 0
1715
+ ? { type: 'user-text', text: message.text }
1716
+ : { type: 'user-text-block', text: message.text };
1717
+ const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, snapshot));
1246
1718
  if (!userEntry)
1247
1719
  throw new Error('Could not translate user prompt');
1248
1720
  if (currentPromptId === null && typeof userEntry.uuid === 'string') {
@@ -1275,18 +1747,90 @@ export class ClaudeSessionService {
1275
1747
  throw new Error(`UserPromptSubmit hook error: ${outcome.blockedReason}`);
1276
1748
  }
1277
1749
  }
1750
+ let shellUsage = { inputTokens: 0, outputTokens: 0 };
1751
+ if (shellCommand !== undefined) {
1752
+ const call = {
1753
+ id: `shell_${randomUUID().replaceAll('-', '')}`,
1754
+ name: 'Bash',
1755
+ input: { command: shellCommand },
1756
+ };
1757
+ this.options.eventSink?.({
1758
+ type: 'shell-command',
1759
+ callId: call.id,
1760
+ command: shellCommand,
1761
+ });
1762
+ let shellResult;
1763
+ try {
1764
+ shellResult = await runtime.executeDirectToolCall(call, {
1765
+ cwd: this.activeCwd(),
1766
+ toolResultDirectory,
1767
+ messages: projectClaudeModelMessages(snapshot.entries),
1768
+ observer: {
1769
+ assistantCompleted: async () => undefined,
1770
+ toolCompleted: async () => undefined,
1771
+ followUpUserMessagesCompleted: observer.followUpUserMessagesCompleted,
1772
+ },
1773
+ ...(this.options.approveTool
1774
+ ? { approveTool: this.options.approveTool }
1775
+ : {}),
1776
+ ...(signal ? { signal } : {}),
1777
+ });
1778
+ }
1779
+ catch (error) {
1780
+ this.options.eventSink?.({
1781
+ type: 'shell-cancelled',
1782
+ callId: call.id,
1783
+ });
1784
+ throw error;
1785
+ }
1786
+ shellUsage = shellResult.usage ?? shellUsage;
1787
+ const stdout = shellResult.processOutput?.stdout ??
1788
+ (shellResult.isError ? '' : shellResult.content);
1789
+ const stderr = shellResult.processOutput?.stderr ??
1790
+ (shellResult.isError ? shellResult.content : '');
1791
+ const shellUuids = [shellInputUuid ?? randomUUID(), randomUUID()];
1792
+ const [inputEntry, outputEntry] = translateProviderEvents([
1793
+ { type: 'bash-input', command: shellCommand },
1794
+ { type: 'bash-output', stdout, stderr },
1795
+ ], {
1796
+ ...this.translationContext(sessionId, snapshot),
1797
+ createUuid: () => shellUuids.shift() ?? randomUUID(),
1798
+ });
1799
+ if (!inputEntry || !outputEntry) {
1800
+ throw new Error('Could not translate shell command result');
1801
+ }
1802
+ const shellAppend = await lease.appendMany(snapshot.tail, [
1803
+ inputEntry,
1804
+ outputEntry,
1805
+ ]);
1806
+ if (shellAppend.status === 'conflict') {
1807
+ throw new Error(`Claude transcript append conflict: ${shellAppend.reason}`);
1808
+ }
1809
+ snapshot = {
1810
+ entries: [...snapshot.entries, inputEntry, outputEntry],
1811
+ tail: shellAppend.tail,
1812
+ };
1813
+ currentTurnUserMessages.push(`<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`);
1814
+ this.options.eventSink?.({
1815
+ type: 'shell-result',
1816
+ callId: call.id,
1817
+ stdout,
1818
+ stderr,
1819
+ isError: shellResult.isError,
1820
+ });
1821
+ }
1278
1822
  if (budget) {
1279
1823
  await compactIfNeeded([], currentTurnUserMessages ?? []);
1280
1824
  budget.assertFits(budget.evaluate([
1281
1825
  ...contextMessages,
1282
- ...injectDynamicContext(projectClaudeModelMessages(snapshot.entries)),
1826
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
1283
1827
  ], definitions));
1284
1828
  }
1285
1829
  let stopHookActive = false;
1286
1830
  const runtimeRequest = {
1287
1831
  messages: [
1288
1832
  ...contextMessages,
1289
- ...injectDynamicContext(projectClaudeModelMessages(snapshot.entries)),
1833
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
1290
1834
  ],
1291
1835
  cwd: this.activeCwd(),
1292
1836
  toolResultDirectory,
@@ -1301,7 +1845,7 @@ export class ClaudeSessionService {
1301
1845
  await compactIfNeeded([], currentTurnUserMessages ?? []);
1302
1846
  return [
1303
1847
  ...contextMessages,
1304
- ...injectDynamicContext(projectClaudeModelMessages(snapshot.entries)),
1848
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
1305
1849
  ];
1306
1850
  },
1307
1851
  ...(this.options.hooks ||
@@ -1373,7 +1917,7 @@ export class ClaudeSessionService {
1373
1917
  lastPrompt: prompt,
1374
1918
  leafUuid: finalLeafUuid,
1375
1919
  }));
1376
- const totalUsage = mergeUsage(mergeUsage(recoveryUsage, compactionUsage), result.usage);
1920
+ const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
1377
1921
  return {
1378
1922
  sessionId,
1379
1923
  text: structuredCapture && structuredCapture.calls === 1
@@ -1604,7 +2148,7 @@ export class ClaudeSessionService {
1604
2148
  : pathFromCwd;
1605
2149
  }
1606
2150
  activeCwd() {
1607
- return this.options.workspace?.cwd() ?? this.options.cwd;
2151
+ return this.options.workspace?.cwd() ?? this.runtimeCwd;
1608
2152
  }
1609
2153
  restoreWorktree(entries) {
1610
2154
  const latest = [...entries]
@@ -1620,10 +2164,74 @@ export class ClaudeSessionService {
1620
2164
  paths(sessionId) {
1621
2165
  return resolveClaudePaths({
1622
2166
  configDir: this.options.configRoot,
1623
- cwd: this.sessionCwds.get(sessionId) ?? this.options.cwd,
2167
+ cwd: this.sessionCwds.get(sessionId) ?? this.activeCwd(),
1624
2168
  sessionId,
1625
2169
  });
1626
2170
  }
2171
+ async appendCdCommand(sessionId, cwd) {
2172
+ const result = await this.store(sessionId).withLease(async (lease) => {
2173
+ const snapshot = await lease.load();
2174
+ if (snapshot.entries.length === 0) {
2175
+ throw new Error(`Claude session not found: ${sessionId}`);
2176
+ }
2177
+ const appendResult = await lease.appendMany(snapshot.tail, this.cdCommandEntries(sessionId, cwd, this.logicalTailUuid(snapshot.tail)));
2178
+ if (appendResult.status === 'conflict') {
2179
+ throw new Error(`Claude local command append conflict: ${appendResult.reason}`);
2180
+ }
2181
+ });
2182
+ if (result.status === 'conflict') {
2183
+ throw new Error(`Claude local command conflict: ${result.reason}`);
2184
+ }
2185
+ }
2186
+ cdCommandEntries(sessionId, cwd, parentUuid) {
2187
+ const firstUuid = randomUUID();
2188
+ const secondUuid = randomUUID();
2189
+ const timestamp = new Date().toISOString();
2190
+ const common = {
2191
+ isSidechain: false,
2192
+ timestamp,
2193
+ userType: 'external',
2194
+ entrypoint: 'cli',
2195
+ cwd,
2196
+ sessionId,
2197
+ version: this.options.claudeVersion,
2198
+ gitBranch: null,
2199
+ };
2200
+ return [
2201
+ {
2202
+ ...common,
2203
+ type: 'system',
2204
+ subtype: 'local_command',
2205
+ isMeta: false,
2206
+ level: 'info',
2207
+ parentUuid,
2208
+ uuid: firstUuid,
2209
+ content: `<command-name>/cd</command-name>\n <command-message>cd</command-message>\n <command-args>${cwd}</command-args>`,
2210
+ },
2211
+ {
2212
+ ...common,
2213
+ type: 'system',
2214
+ subtype: 'local_command',
2215
+ isMeta: false,
2216
+ level: 'info',
2217
+ parentUuid: firstUuid,
2218
+ uuid: secondUuid,
2219
+ content: `<local-command-stdout>Moved to ${cwd}</local-command-stdout>`,
2220
+ },
2221
+ {
2222
+ ...common,
2223
+ parentUuid: secondUuid,
2224
+ promptId: randomUUID(),
2225
+ type: 'user',
2226
+ message: {
2227
+ role: 'user',
2228
+ 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>`,
2229
+ },
2230
+ isMeta: true,
2231
+ uuid: randomUUID(),
2232
+ },
2233
+ ];
2234
+ }
1627
2235
  store(sessionId) {
1628
2236
  const paths = this.paths(sessionId);
1629
2237
  return new ClaudeTranscriptStore({