praxis-agent 0.46.1 → 0.46.2

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.
package/README.md CHANGED
@@ -212,9 +212,13 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
212
212
  installing it, rejects concurrent updates, and can roll back after an
213
213
  interruption or crash.
214
214
 
215
- Detailed feature status and executable evidence live in the
216
- [parity matrix](https://github.com/Forest-Isle/Praxis/blob/main/docs/PARITY_MATRIX.md),
217
- not in this entry-point README.
215
+ Current qualification status and executable evidence live in the
216
+ [Native Fixture Contracts](https://github.com/Forest-Isle/Praxis/blob/main/docs/NATIVE_FIXTURE_CONTRACTS.md)
217
+ and its machine-readable
218
+ [fixture manifest](https://github.com/Forest-Isle/Praxis/blob/main/test/fixtures/manifest.json).
219
+ The [parity matrix](https://github.com/Forest-Isle/Praxis/blob/main/docs/PARITY_MATRIX.md)
220
+ and [roadmap](https://github.com/Forest-Isle/Praxis/blob/main/docs/ROADMAP.md) are
221
+ historical clean-room records.
218
222
 
219
223
  ## Native data plane
220
224
 
@@ -234,17 +238,17 @@ only; they do not change Praxis data ownership.
234
238
 
235
239
  ## Documentation
236
240
 
237
- | Need | Document |
238
- | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
239
- | Install and run the first session | [Getting Started](https://github.com/Forest-Isle/Praxis/blob/main/docs/GETTING_STARTED.md) |
240
- | Common commands and environment variables | [CLI Reference](https://github.com/Forest-Isle/Praxis/blob/main/docs/CLI_REFERENCE.md) |
241
- | Find all user and maintainer documentation | [Documentation Index](https://github.com/Forest-Isle/Praxis/blob/main/docs/README.md) |
242
- | Understand module and data-flow boundaries | [Architecture](https://github.com/Forest-Isle/Praxis/blob/main/docs/ARCHITECTURE.md) |
243
- | Review security assumptions | [Threat Model](https://github.com/Forest-Isle/Praxis/blob/main/docs/THREAT_MODEL.md) |
244
- | Check Claude Code parity | [Parity Matrix](https://github.com/Forest-Isle/Praxis/blob/main/docs/PARITY_MATRIX.md) |
245
- | Review interactive TUI design and evidence | [Quiet Operator Spec](https://github.com/Forest-Isle/Praxis/blob/main/docs/TUI_REDESIGN_SPEC.md) |
246
- | Build, test, and contribute | [Contributing](https://github.com/Forest-Isle/Praxis/blob/main/CONTRIBUTING.md) |
247
- | Verify release and supply-chain controls | [Release Contract](https://github.com/Forest-Isle/Praxis/blob/main/docs/RELEASE.md) |
241
+ | Need | Document |
242
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
243
+ | Install and run the first session | [Getting Started](https://github.com/Forest-Isle/Praxis/blob/main/docs/GETTING_STARTED.md) |
244
+ | Common commands and environment variables | [CLI Reference](https://github.com/Forest-Isle/Praxis/blob/main/docs/CLI_REFERENCE.md) |
245
+ | Find all user and maintainer documentation | [Documentation Index](https://github.com/Forest-Isle/Praxis/blob/main/docs/README.md) |
246
+ | Understand module and data-flow boundaries | [Architecture](https://github.com/Forest-Isle/Praxis/blob/main/docs/ARCHITECTURE.md) |
247
+ | Review security assumptions | [Threat Model](https://github.com/Forest-Isle/Praxis/blob/main/docs/THREAT_MODEL.md) |
248
+ | Qualify native behavior and evidence | [Native Fixture Contracts](https://github.com/Forest-Isle/Praxis/blob/main/docs/NATIVE_FIXTURE_CONTRACTS.md) |
249
+ | Review interactive TUI design and evidence | [Quiet Operator Spec](https://github.com/Forest-Isle/Praxis/blob/main/docs/TUI_REDESIGN_SPEC.md) |
250
+ | Build, test, and contribute | [Contributing](https://github.com/Forest-Isle/Praxis/blob/main/CONTRIBUTING.md) |
251
+ | Verify release and supply-chain controls | [Release Contract](https://github.com/Forest-Isle/Praxis/blob/main/docs/RELEASE.md) |
248
252
 
249
253
  ## Project boundary
250
254
 
@@ -286,10 +290,13 @@ injected regression protection, plus Quiet Operator input echo `<50 ms` and
286
290
  normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
287
291
  `npm run check` also enforces the corresponding source dependency direction.
288
292
  `npm run test:coverage` measures all production code under `src/**` with V8 and
289
- enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines.
290
- `npm run test:core-completion` runs the 56-story #402 audit and reports
291
- implemented, qualified, blocked, deferred, and out-of-scope states separately;
292
- it never treats missing live prerequisites as a pass.
293
+ enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
294
+ and rejects any production runtime module with zero covered statements (while allowing
295
+ type-only modules). `npm run test:fixtures` executes the 67-behavior native contract; 59 behaviors
296
+ are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
297
+ performs the structural check and is part of `npm run check`.
298
+ `npm run test:core-completion` is retained as a compatibility alias for
299
+ `npm run test:fixtures`.
293
300
 
294
301
  Contributions use Conventional Commit pull-request titles and the protected
295
302
  squash-merge workflow. Read
@@ -123,6 +123,11 @@ export interface SessionRunResult {
123
123
  costUsd?: number;
124
124
  modelUsage?: Readonly<Record<string, ModelUsage>>;
125
125
  }
126
+ export interface CwdInspection {
127
+ readonly canonicalTarget: string;
128
+ readonly canonicalCurrentCwd: string;
129
+ readonly sameDirectory: boolean;
130
+ }
126
131
  export interface SideQuestionResult {
127
132
  sessionId: string;
128
133
  text: string;
@@ -290,7 +295,8 @@ export declare class ClaudeSessionService {
290
295
  metadata(sessionId: string): Promise<ClaudeSessionMetadata>;
291
296
  rename(sessionId: string, name: string): Promise<void>;
292
297
  tag(sessionId: string, tag: string): Promise<void>;
293
- changeCwd(sessionId: string | undefined, requestedCwd: string): Promise<string>;
298
+ changeCwd(sessionId: string | undefined, requestedCwd: string, expectedCanonicalTarget?: string): Promise<string>;
299
+ inspectCwd(requestedCwd: string): Promise<CwdInspection>;
294
300
  recordCdUsage(sessionId: string): Promise<void>;
295
301
  approveRecentlyDenied(sessionId: string, display: string): Promise<void>;
296
302
  retryRecentlyDenied(sessionId: string, display: string, signal?: AbortSignal): Promise<SessionRunResult>;
@@ -1806,17 +1806,23 @@ export class ClaudeSessionService {
1806
1806
  const normalized = tag.trim();
1807
1807
  await this.appendNativeCommand(sessionId, `/tag ${normalized}`);
1808
1808
  }
1809
- async changeCwd(sessionId, requestedCwd) {
1809
+ async changeCwd(sessionId, requestedCwd, expectedCanonicalTarget) {
1810
1810
  this.assertWritable();
1811
1811
  const previousCwd = this.activeCwd();
1812
- const expandedCwd = requestedCwd === '~'
1813
- ? homedir()
1814
- : requestedCwd.startsWith('~/')
1815
- ? join(homedir(), requestedCwd.slice(2))
1816
- : requestedCwd;
1817
- const cwd = await realpath(isAbsolute(expandedCwd) ? expandedCwd : join(previousCwd, expandedCwd));
1818
- if (!(await stat(cwd)).isDirectory()) {
1819
- throw new Error(`Not a directory: ${requestedCwd}`);
1812
+ let inspection;
1813
+ try {
1814
+ inspection = await this.inspectCwd(requestedCwd);
1815
+ }
1816
+ catch (error) {
1817
+ if (error instanceof Error &&
1818
+ error.message.includes(' is not a directory.'))
1819
+ throw new Error(`Not a directory: ${requestedCwd}; ${error.message}`);
1820
+ throw error;
1821
+ }
1822
+ const cwd = inspection.canonicalTarget;
1823
+ if (expectedCanonicalTarget !== undefined &&
1824
+ cwd !== expectedCanonicalTarget) {
1825
+ throw new Error(`Directory changed after approval: expected ${expectedCanonicalTarget}, resolved to ${cwd}. Review the target and try again.`);
1820
1826
  }
1821
1827
  if (sessionId && this.options.sessionPersistence !== false) {
1822
1828
  const sourcePaths = this.pathsForCwd(sessionId, this.sessionCwds.get(sessionId) ?? previousCwd);
@@ -1896,6 +1902,33 @@ export class ClaudeSessionService {
1896
1902
  }
1897
1903
  return cwd;
1898
1904
  }
1905
+ async inspectCwd(requestedCwd) {
1906
+ const current = await realpath(this.activeCwd());
1907
+ const expanded = requestedCwd === '~'
1908
+ ? homedir()
1909
+ : requestedCwd.startsWith('~/')
1910
+ ? join(homedir(), requestedCwd.slice(2))
1911
+ : requestedCwd;
1912
+ const resolved = isAbsolute(expanded) ? expanded : join(current, expanded);
1913
+ let target;
1914
+ try {
1915
+ target = await realpath(resolved);
1916
+ }
1917
+ catch (error) {
1918
+ if (error.code === 'ENOENT')
1919
+ throw new Error(`Could not find a directory at ${resolved}.`);
1920
+ throw error;
1921
+ }
1922
+ const info = await stat(target);
1923
+ if (!info.isDirectory()) {
1924
+ throw new Error(`${target} is not a directory. Did you mean ${dirname(target)}?`);
1925
+ }
1926
+ return {
1927
+ canonicalTarget: target,
1928
+ canonicalCurrentCwd: current,
1929
+ sameDirectory: target === current,
1930
+ };
1931
+ }
1899
1932
  async recordCdUsage(sessionId) {
1900
1933
  this.assertWritable();
1901
1934
  const transcript = new NativeSessionTranscript({
@@ -1,4 +1,4 @@
1
- import type { ForkResult, ManualCompactResult, ManualCompactSelection, RewindPoint, SessionForkCheckpoint, SessionRunResult, SessionSummary, SideQuestionForkResult, SideQuestionResult } from '../application/session-service.js';
1
+ import type { CwdInspection, ForkResult, ManualCompactResult, ManualCompactSelection, RewindPoint, SessionForkCheckpoint, SessionRunResult, SessionSummary, SideQuestionForkResult, SideQuestionResult } from '../application/session-service.js';
2
2
  import type { ClaudeSessionCostSnapshot } from '../application/session-cost-tracker.js';
3
3
  import type { ModelImage, ModelToolCall, PermissionApproval, PermissionDecision, RuntimeEventSink } from '../core/runtime.js';
4
4
  import type { ActiveTurnInputCommandResult } from '../core/active-turn-input.js';
@@ -46,7 +46,8 @@ interface InteractiveSessionCommands {
46
46
  compact?(sessionId: string, signal?: AbortSignal, selection?: ManualCompactSelection): Promise<ManualCompactResult>;
47
47
  rewindFiles?(sessionId: string, userMessageId: string): Promise<void>;
48
48
  rewindPoints?(sessionId: string): Promise<RewindPoint[]>;
49
- changeCwd?(sessionId: string | undefined, cwd: string): Promise<string>;
49
+ changeCwd?(sessionId: string | undefined, cwd: string, expectedCanonicalTarget?: string): Promise<string>;
50
+ inspectCwd?(cwd: string): Promise<CwdInspection>;
50
51
  notify?(sessionId: string | undefined, message: string, notificationType: string, title?: string): void;
51
52
  recordCdUsage?(sessionId: string): Promise<void>;
52
53
  approveRecentlyDenied?(sessionId: string, display: string): Promise<void>;
@@ -610,6 +610,35 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
610
610
  const planApprovalRef = useRef(null);
611
611
  const [planApprovalSelection, setPlanApprovalSelection] = useState(0);
612
612
  const [planApprovalFeedbackMode, setPlanApprovalFeedbackMode] = useState(false);
613
+ const [cdTrust, setCdTrust] = useState(null);
614
+ const [cdTrustSelection, setCdTrustSelection] = useState(0);
615
+ const cdTrustRef = useRef(null);
616
+ const trustedCwdsBySessionRef = useRef(new Map());
617
+ const trustedCwdSessionRef = useRef(sessionId);
618
+ useEffect(() => {
619
+ const previousSessionId = trustedCwdSessionRef.current;
620
+ if (previousSessionId === sessionId)
621
+ return;
622
+ if (previousSessionId === null && sessionId !== null) {
623
+ const pendingSessionTrust = trustedCwdsBySessionRef.current.get(null);
624
+ if (pendingSessionTrust)
625
+ trustedCwdsBySessionRef.current.set(sessionId, pendingSessionTrust);
626
+ trustedCwdsBySessionRef.current.delete(null);
627
+ }
628
+ else if (sessionId === null) {
629
+ trustedCwdsBySessionRef.current.delete(null);
630
+ }
631
+ trustedCwdSessionRef.current = sessionId;
632
+ }, [sessionId]);
633
+ const trustedCwds = () => {
634
+ const key = sessionIdRef.current;
635
+ const existing = trustedCwdsBySessionRef.current.get(key);
636
+ if (existing)
637
+ return existing;
638
+ const created = new Set();
639
+ trustedCwdsBySessionRef.current.set(key, created);
640
+ return created;
641
+ };
613
642
  const serviceRef = useRef(null);
614
643
  const serviceCreationRef = useRef(undefined);
615
644
  const onCleanupRef = useRef(onCleanup);
@@ -868,6 +897,13 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
868
897
  answer: input,
869
898
  })
870
899
  : null, [question, input]);
900
+ const cdTrustSurface = useMemo(() => cdTrust
901
+ ? projectTuiDecisionSurface({
902
+ kind: 'cd-trust',
903
+ canonicalPath: cdTrust.canonicalPath,
904
+ selectedIndex: cdTrustSelection,
905
+ })
906
+ : null, [cdTrust, cdTrustSelection]);
871
907
  const elicitationSurface = useMemo(() => elicitation === null
872
908
  ? null
873
909
  : projectTuiElicitationSurface({
@@ -1129,16 +1165,18 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1129
1165
  }
1130
1166
  : planApproval !== null && planDecisionSurface !== null
1131
1167
  ? { priority: planDecisionSurface }
1132
- : question !== null && questionDecisionSurface !== null
1133
- ? { priority: questionDecisionSurface }
1134
- : elicitation !== null && elicitationSurface !== null
1135
- ? {
1136
- priority: {
1137
- kind: 'elicitation',
1138
- surface: elicitationSurface,
1139
- },
1140
- }
1141
- : {}),
1168
+ : cdTrust !== null && cdTrustSurface !== null
1169
+ ? { priority: cdTrustSurface }
1170
+ : question !== null && questionDecisionSurface !== null
1171
+ ? { priority: questionDecisionSurface }
1172
+ : elicitation !== null && elicitationSurface !== null
1173
+ ? {
1174
+ priority: {
1175
+ kind: 'elicitation',
1176
+ surface: elicitationSurface,
1177
+ },
1178
+ }
1179
+ : {}),
1142
1180
  ...(secondarySurface === undefined
1143
1181
  ? {}
1144
1182
  : { secondary: secondarySurface }),
@@ -1156,6 +1194,8 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1156
1194
  permission,
1157
1195
  permissionPrioritySurface,
1158
1196
  planApproval,
1197
+ cdTrust,
1198
+ cdTrustSurface,
1159
1199
  question,
1160
1200
  planDecisionSurface,
1161
1201
  questionDecisionSurface,
@@ -2739,23 +2779,58 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2739
2779
  onTurnChange?.(loading);
2740
2780
  void loading.finally(() => onTurnChange?.(null));
2741
2781
  };
2742
- const changeWorkingDirectory = (requestedCwd) => {
2782
+ const changeWorkingDirectory = (requestedCwd, authorization = {
2783
+ kind: 'unapproved',
2784
+ }) => {
2743
2785
  const changing = (async () => {
2744
- setBusy(true);
2745
- setStatus('changing directory');
2746
2786
  try {
2747
2787
  const commands = await service();
2748
2788
  if (!commands.changeCwd) {
2749
2789
  throw new Error('This interactive service cannot change working directories.');
2750
2790
  }
2751
- const cwd = await commands.changeCwd(sessionId ?? undefined, requestedCwd);
2791
+ if (!commands.inspectCwd) {
2792
+ throw new Error('This interactive service cannot inspect or change working directories.');
2793
+ }
2794
+ const inspection = await commands.inspectCwd(requestedCwd);
2795
+ if (authorization.kind === 'approved' &&
2796
+ inspection.canonicalTarget !== authorization.canonicalTarget) {
2797
+ throw new Error(`Directory changed after approval: expected ${authorization.canonicalTarget}, resolved to ${inspection.canonicalTarget}. Review the target and try again.`);
2798
+ }
2799
+ const activeSessionTrust = trustedCwds();
2800
+ activeSessionTrust.add(inspection.canonicalCurrentCwd);
2801
+ if (inspection.sameDirectory) {
2802
+ append({
2803
+ kind: 'local-result',
2804
+ text: `Already in ${inspection.canonicalTarget}.`,
2805
+ });
2806
+ return;
2807
+ }
2808
+ if (authorization.kind === 'unapproved' &&
2809
+ !activeSessionTrust.has(inspection.canonicalTarget)) {
2810
+ const pending = {
2811
+ canonicalPath: inspection.canonicalTarget,
2812
+ };
2813
+ cdTrustRef.current = pending;
2814
+ setCdTrust(pending);
2815
+ return;
2816
+ }
2817
+ setBusy(true);
2818
+ setStatus('changing directory');
2819
+ const cwd = await commands.changeCwd(sessionId ?? undefined, inspection.canonicalTarget, inspection.canonicalTarget);
2820
+ activeSessionTrust.add(cwd);
2752
2821
  runtimeCwdRef.current = cwd;
2753
2822
  setRuntimeCwd(cwd);
2754
2823
  await retireService();
2755
2824
  append({ kind: 'local-result', text: `Moved to ${cwd}` });
2756
2825
  }
2757
2826
  catch (error) {
2758
- warn(error);
2827
+ const message = error instanceof Error ? error.message : String(error);
2828
+ if (authorization.kind === 'approved')
2829
+ trustedCwds().delete(authorization.canonicalTarget);
2830
+ if (/^(Could not find a directory at .*\.|.* is not a directory\. Did you mean .+\?)$/u.test(message))
2831
+ append({ kind: 'local-result', text: message });
2832
+ else
2833
+ warn(error);
2759
2834
  }
2760
2835
  finally {
2761
2836
  setBusy(false);
@@ -3688,6 +3763,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3688
3763
  case 'command-palette':
3689
3764
  setCommandPaletteOpen(false);
3690
3765
  break;
3766
+ case 'cd-trust':
3767
+ cdTrustRef.current = null;
3768
+ setCdTrust(null);
3769
+ setCdTrustSelection(0);
3770
+ break;
3691
3771
  default: {
3692
3772
  const unhandledTarget = effect.target;
3693
3773
  return unhandledTarget;
@@ -3738,7 +3818,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3738
3818
  : ['Select']
3739
3819
  : commandPaletteVisible || filePickerVisible
3740
3820
  ? ['Autocomplete', 'Chat']
3741
- : permission || planApproval
3821
+ : permission || planApproval || cdTrust
3742
3822
  ? ['Confirmation']
3743
3823
  : selectingSession
3744
3824
  ? ['Select']
@@ -3809,6 +3889,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3809
3889
  pendingPrefix: hasPendingPrefix,
3810
3890
  permission: Boolean(permission),
3811
3891
  planApproval: Boolean(planApproval),
3892
+ cdTrust: Boolean(cdTrust),
3812
3893
  question: Boolean(question),
3813
3894
  elicitation: elicitation
3814
3895
  ? elicitationUrlWaiting
@@ -4063,6 +4144,35 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
4063
4144
  }
4064
4145
  return;
4065
4146
  }
4147
+ if (cdTrust) {
4148
+ const resolveCdTrust = (selectedIndex) => {
4149
+ const pending = cdTrustRef.current;
4150
+ cdTrustRef.current = null;
4151
+ setCdTrust(null);
4152
+ setCdTrustSelection(0);
4153
+ if (!pending || selectedIndex !== 1)
4154
+ return;
4155
+ changeWorkingDirectory(pending.canonicalPath, {
4156
+ kind: 'approved',
4157
+ canonicalTarget: pending.canonicalPath,
4158
+ });
4159
+ };
4160
+ if (key.upArrow) {
4161
+ setCdTrustSelection((current) => Math.max(0, current - 1));
4162
+ }
4163
+ else if (key.downArrow) {
4164
+ setCdTrustSelection((current) => Math.min(1, current + 1));
4165
+ }
4166
+ else if (lower === 'y')
4167
+ resolveCdTrust(1);
4168
+ else if (lower === 'n')
4169
+ resolveCdTrust(0);
4170
+ else if (/^[12]$/u.test(value))
4171
+ resolveCdTrust(Number(value) - 1);
4172
+ else if (key.return)
4173
+ resolveCdTrust(cdTrustSelection);
4174
+ return;
4175
+ }
4066
4176
  if (planApproval) {
4067
4177
  const elevatedMode = runtimeSettingsRef.current
4068
4178
  .useAutoModeDuringPlan
@@ -32,6 +32,10 @@ export type TuiDecisionSurfaceInput = {
32
32
  readonly questions: readonly ClaudeQuestion[];
33
33
  readonly questionIndex: number;
34
34
  readonly answer: string;
35
+ } | {
36
+ readonly kind: 'cd-trust';
37
+ readonly canonicalPath: string;
38
+ readonly selectedIndex: number;
35
39
  };
36
40
  export interface TuiPlanApprovalSurfaceModel {
37
41
  readonly kind: 'plan-approval';
@@ -66,6 +70,19 @@ export interface TuiQuestionSurfaceModel {
66
70
  readonly cancellation: TuiDecisionSurfaceCancellation;
67
71
  readonly emptyState?: string;
68
72
  }
69
- export type TuiDecisionSurfaceModel = TuiPlanApprovalSurfaceModel | TuiQuestionSurfaceModel;
73
+ export interface TuiCdTrustSurfaceModel {
74
+ readonly kind: 'cd-trust';
75
+ readonly heading: 'Moving to a new directory:';
76
+ readonly canonicalPath: string;
77
+ readonly explanation: "This session hasn't worked here before. Is this a directory you created or one you trust?";
78
+ readonly scope: 'Praxis can read, edit, and execute files in this directory.';
79
+ readonly securityGuide: 'Security guide: https://code.claude.com/docs/en/security';
80
+ readonly options: readonly TuiDecisionSurfaceOption[];
81
+ readonly selectedIndex: number;
82
+ readonly range: TuiDecisionSurfaceRange;
83
+ readonly actions: readonly TuiDecisionSurfaceAction[];
84
+ readonly cancellation: TuiDecisionSurfaceCancellation;
85
+ }
86
+ export type TuiDecisionSurfaceModel = TuiPlanApprovalSurfaceModel | TuiQuestionSurfaceModel | TuiCdTrustSurfaceModel;
70
87
  export declare function projectTuiDecisionSurface(input: TuiDecisionSurfaceInput): TuiDecisionSurfaceModel;
71
88
  //# sourceMappingURL=decision-surface-model.d.ts.map
@@ -100,6 +100,32 @@ export function projectTuiDecisionSurface(input) {
100
100
  cancellation,
101
101
  };
102
102
  }
103
+ if (input.kind === 'cd-trust') {
104
+ const selectedIndex = normalizeIndex(input.selectedIndex, 2);
105
+ const options = projectedOptions([{ label: 'No, stay put' }, { label: 'Yes, move here' }], selectedIndex);
106
+ return {
107
+ kind: 'cd-trust',
108
+ heading: 'Moving to a new directory:',
109
+ canonicalPath: input.canonicalPath,
110
+ explanation: "This session hasn't worked here before. Is this a directory you created or one you trust?",
111
+ scope: 'Praxis can read, edit, and execute files in this directory.',
112
+ securityGuide: 'Security guide: https://code.claude.com/docs/en/security',
113
+ options,
114
+ selectedIndex,
115
+ range: range(options.length),
116
+ actions: [
117
+ {
118
+ visualLabel: 'Enter to confirm',
119
+ screenReaderLabel: 'Enter to confirm',
120
+ },
121
+ { screenReaderLabel: 'Use up and down arrows to change selection' },
122
+ { screenReaderLabel: 'Press 1 or 2 to choose directly' },
123
+ { screenReaderLabel: 'Press y to move here' },
124
+ { screenReaderLabel: 'Press n to stay put' },
125
+ ],
126
+ cancellation,
127
+ };
128
+ }
103
129
  const count = input.questions.length;
104
130
  const questionIndex = normalizeIndex(input.questionIndex, count);
105
131
  if (count === 0)
@@ -31,8 +31,11 @@ function PlanSurface({ model, screenReader, }) {
31
31
  function QuestionSurface({ model, screenReader, }) {
32
32
  return (_jsxs(DialogFrame, { title: model.heading, screenReader: screenReader, children: [_jsx(Text, { children: model.progress }), model.emptyState ? _jsx(Text, { dimColor: true, children: model.emptyState }) : null, model.options.map((option) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [option.index, ". ", option.label, option.description ? ` — ${option.description}` : ''] }), option.preview ? _jsx(Text, { dimColor: true, children: option.preview }) : null] }, `${option.index}-${option.label}`))), _jsxs(Text, { children: [screenReader ? 'Current answer: ' : '› ', model.answer || (screenReader ? '(empty)' : '')] }), _jsx(Footer, { ...model, screenReader: screenReader })] }));
33
33
  }
34
+ function CdTrustSurface({ model, screenReader, }) {
35
+ return (_jsxs(DialogFrame, { title: model.heading, screenReader: screenReader, children: [_jsx(Text, { children: model.canonicalPath }), _jsx(Text, { dimColor: true, children: model.explanation }), _jsx(Text, { dimColor: true, children: model.scope }), _jsx(Text, { dimColor: true, children: model.securityGuide }), model.options.map((option) => (_jsx(Option, { option: option, screenReader: screenReader }, option.index))), _jsx(Footer, { ...model, screenReader: screenReader })] }));
36
+ }
34
37
  export function DecisionSurface({ model, width, screenReader, }) {
35
- const content = model.kind === 'plan-approval' ? (_jsx(PlanSurface, { model: model, screenReader: screenReader })) : (_jsx(QuestionSurface, { model: model, screenReader: screenReader }));
38
+ const content = model.kind === 'plan-approval' ? (_jsx(PlanSurface, { model: model, screenReader: screenReader })) : model.kind === 'question' ? (_jsx(QuestionSurface, { model: model, screenReader: screenReader })) : (_jsx(CdTrustSurface, { model: model, screenReader: screenReader }));
36
39
  const normalizedWidth = Number.isFinite(width)
37
40
  ? Math.min(100, Math.max(1, Math.trunc(width)))
38
41
  : 100;
@@ -1,2 +1,12 @@
1
- export declare function openTuiUrl(url: string): Promise<void>;
1
+ type OpenUrlOptions = {
2
+ timeout: number;
3
+ shell: false;
4
+ };
5
+ type OpenUrlExecutor = (command: string, args: string[], options: OpenUrlOptions, callback: (error?: Error | null) => void) => unknown;
6
+ type OpenUrlDependencies = {
7
+ platform?: NodeJS.Platform;
8
+ execFile?: OpenUrlExecutor;
9
+ };
10
+ export declare function openTuiUrl(url: string, dependencies?: OpenUrlDependencies): Promise<void>;
11
+ export {};
2
12
  //# sourceMappingURL=open-url.d.ts.map
@@ -1,13 +1,33 @@
1
1
  import { execFile } from 'node:child_process';
2
- export async function openTuiUrl(url) {
3
- const command = process.platform === 'darwin'
2
+ const defaultExecFile = (command, args, options, callback) => execFile(command, args, options, callback);
3
+ export async function openTuiUrl(url, dependencies = {}) {
4
+ let parsedUrl;
5
+ try {
6
+ parsedUrl = new URL(url);
7
+ }
8
+ catch {
9
+ throw new Error('TUI URL must be an absolute HTTP(S) URL');
10
+ }
11
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:')
12
+ throw new Error('TUI URL must be an absolute HTTP(S) URL');
13
+ const canonicalUrl = parsedUrl.href;
14
+ const platform = dependencies.platform ?? process.platform;
15
+ const command = platform === 'darwin'
4
16
  ? 'open'
5
- : process.platform === 'win32'
6
- ? 'cmd'
17
+ : platform === 'win32'
18
+ ? 'rundll32.exe'
7
19
  : 'xdg-open';
8
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
9
- await new Promise((resolvePromise) => {
10
- execFile(command, args, { timeout: 10_000 }, () => resolvePromise());
20
+ const args = platform === 'win32'
21
+ ? ['url.dll,FileProtocolHandler', canonicalUrl]
22
+ : [canonicalUrl];
23
+ const executor = dependencies.execFile ?? defaultExecFile;
24
+ await new Promise((resolvePromise, rejectPromise) => {
25
+ try {
26
+ executor(command, args, { timeout: 10_000, shell: false }, (error) => error == null ? resolvePromise() : rejectPromise(error));
27
+ }
28
+ catch (error) {
29
+ rejectPromise(error);
30
+ }
11
31
  });
12
32
  }
13
33
  //# sourceMappingURL=open-url.js.map
@@ -176,6 +176,17 @@ function decision(surface, density, sr) {
176
176
  explicitFooter('quiet:plan:footer', '↑/↓ select Enter confirm Esc cancel', 'Use up and down arrows to select. Press Enter to confirm. Press Escape to cancel.'),
177
177
  ];
178
178
  }
179
+ if (surface.kind === 'cd-trust') {
180
+ out.push(row('quiet:cd-trust:path', surface.canonicalPath, 'body'));
181
+ out.push(row('quiet:cd-trust:explanation', surface.explanation, 'muted'));
182
+ out.push(row('quiet:cd-trust:scope', surface.scope, 'muted'));
183
+ out.push(row('quiet:cd-trust:security-guide', surface.securityGuide, 'muted'));
184
+ out.push(...optionsRows(surface.options, 'quiet:cd-trust:option', density, sr));
185
+ return [
186
+ ...out,
187
+ explicitFooter('quiet:cd-trust:footer', '↑/↓ select Enter confirm Esc cancel', 'Use up and down arrows to select. Press Enter to confirm. Press Escape to cancel.'),
188
+ ];
189
+ }
179
190
  if (surface.progress)
180
191
  out.push(row('quiet:question:progress', surface.progress, 'muted'));
181
192
  if (surface.question)
@@ -291,7 +302,9 @@ export function projectQuietChoiceRows(surface, options) {
291
302
  row('quiet:exit:heading', 'Exit Praxis?', 'heading'),
292
303
  explicitFooter('quiet:exit:footer', 'Enter confirm Esc cancel', 'Press Enter to confirm. Press Escape to cancel.'),
293
304
  ];
294
- if (surface.kind === 'plan-approval' || surface.kind === 'question')
305
+ if (surface.kind === 'plan-approval' ||
306
+ surface.kind === 'question' ||
307
+ surface.kind === 'cd-trust')
295
308
  return decision(surface, options.density, sr);
296
309
  if (surface.kind === 'session-picker' ||
297
310
  surface.kind === 'command-palette' ||
@@ -1,8 +1,16 @@
1
1
  import type { SandboxDependencyCheck } from '@anthropic-ai/sandbox-runtime';
2
2
  import type { JsonResource } from '../../core/resources.js';
3
+ import { type ClaudeSandboxPlatform } from '../../sandbox/claude-sandbox-runtime.js';
3
4
  import { type ClaudeSandboxSettings } from '../../sandbox/claude-sandbox-settings.js';
4
5
  export type TuiSandboxMode = 'auto-allow' | 'regular' | 'disabled';
5
6
  export type TuiSandboxTab = 'mode' | 'dependencies' | 'overrides' | 'config';
7
+ export interface TuiSandboxRuntime {
8
+ initialize(settings: ClaudeSandboxSettings): Promise<void>;
9
+ unavailableReason(settings: ClaudeSandboxSettings): string | undefined;
10
+ platformName(): ClaudeSandboxPlatform;
11
+ dependencyCheck(settings: ClaudeSandboxSettings): SandboxDependencyCheck;
12
+ isSupportedPlatform(): boolean;
13
+ }
6
14
  export interface TuiSandboxSnapshot {
7
15
  settings: ClaudeSandboxSettings;
8
16
  dependencies: SandboxDependencyCheck;
@@ -22,11 +30,12 @@ export interface TuiSandboxStore {
22
30
  }>;
23
31
  }
24
32
  export declare function linuxGlobPatternWarnings(resources: readonly JsonResource[], platform: 'macos' | 'linux' | 'windows' | 'wsl'): string[];
25
- export declare function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories, environment, }: {
33
+ export declare function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories, environment, runtime, }: {
26
34
  configRoot: string;
27
35
  cwd: string;
28
36
  homeDirectory: string;
29
37
  additionalDirectories?: readonly string[];
30
38
  environment?: NodeJS.ProcessEnv;
39
+ runtime?: TuiSandboxRuntime;
31
40
  }): TuiSandboxStore;
32
41
  //# sourceMappingURL=sandbox-settings.d.ts.map
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join, relative } from 'node:path';
3
3
  import { writeFileAtomically } from '../../platform/atomic-write.js';
4
4
  import { loadNativeSharedResources } from '../../persistence/native-resources.js';
5
- import { claudeSandboxRuntime } from '../../sandbox/claude-sandbox-runtime.js';
5
+ import { claudeSandboxRuntime, } from '../../sandbox/claude-sandbox-runtime.js';
6
6
  import { nativeSandboxTempDirectory, loadClaudeSandboxSettings, } from '../../sandbox/claude-sandbox-settings.js';
7
7
  function isRecord(value) {
8
8
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -75,7 +75,7 @@ export function linuxGlobPatternWarnings(resources, platform) {
75
75
  }
76
76
  return [...new Set(warnings)];
77
77
  }
78
- export function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories = [], environment = process.env, }) {
78
+ export function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories = [], environment = process.env, runtime = claudeSandboxRuntime, }) {
79
79
  const localSettingsPath = join(cwd, '.praxis', 'settings.local.json');
80
80
  const load = async () => {
81
81
  const resources = (await loadNativeSharedResources({ root: configRoot, cwd })).settings;
@@ -87,13 +87,13 @@ export function createTuiSandboxStore({ configRoot, cwd, homeDirectory, addition
87
87
  additionalDirectories,
88
88
  tempDirectory: nativeSandboxTempDirectory(environment),
89
89
  });
90
- await claudeSandboxRuntime.initialize(settings);
91
- const unavailableReason = claudeSandboxRuntime.unavailableReason(settings);
92
- const platform = claudeSandboxRuntime.platformName();
90
+ await runtime.initialize(settings);
91
+ const unavailableReason = runtime.unavailableReason(settings);
92
+ const platform = runtime.platformName();
93
93
  return {
94
94
  settings,
95
- dependencies: claudeSandboxRuntime.dependencyCheck(settings),
96
- supported: claudeSandboxRuntime.isSupportedPlatform(),
95
+ dependencies: runtime.dependencyCheck(settings),
96
+ supported: runtime.isSupportedPlatform(),
97
97
  platform,
98
98
  globPatternWarnings: linuxGlobPatternWarnings(resources, platform),
99
99
  ...(unavailableReason ? { unavailableReason } : {}),
@@ -7,6 +7,7 @@ export interface TuiFocusProjectionInput {
7
7
  readonly pendingPrefix: boolean;
8
8
  readonly permission: boolean;
9
9
  readonly planApproval: boolean;
10
+ readonly cdTrust?: boolean;
10
11
  readonly question: boolean;
11
12
  readonly elicitation: 'plain' | 'url-waiting' | 'expanded-options' | false;
12
13
  readonly selectingSession: boolean;
@@ -19,6 +19,12 @@ export function projectTuiFocusStack(input) {
19
19
  layer: { kind: 'cancelable', target: 'plan-approval' },
20
20
  };
21
21
  }
22
+ else if (input.cdTrust) {
23
+ higher = {
24
+ id: 'cd-trust',
25
+ layer: { kind: 'cancelable', target: 'cd-trust' },
26
+ };
27
+ }
22
28
  else if (input.question) {
23
29
  higher = {
24
30
  id: 'question',
@@ -1,7 +1,7 @@
1
1
  import { type ComposerKeyProjection } from './composer-key-router.js';
2
2
  import type { ComposerEditorState } from './composer-editor.js';
3
3
  export type TuiScrollIntent = 'page-older' | 'page-newer' | 'half-page-older' | 'half-page-newer' | 'line-older' | 'line-newer' | 'none';
4
- export type TuiCancellationTarget = 'permission' | 'plan-approval' | 'question' | 'elicitation' | 'elicitation-url-waiting' | 'elicitation-options' | 'file-picker' | 'command-palette';
4
+ export type TuiCancellationTarget = 'permission' | 'plan-approval' | 'cd-trust' | 'question' | 'elicitation' | 'elicitation-url-waiting' | 'elicitation-options' | 'file-picker' | 'command-palette';
5
5
  export type TuiInteractionLayer = {
6
6
  readonly kind: 'none';
7
7
  } | {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { type ForkResult, type ManualCompactResult, type ManualCompactSelection, type RewindPoint, type SessionForkCheckpoint, type SessionInspection, type SessionRunResult, type SessionSummary, type SideQuestionForkResult, type SideQuestionResult } from './application/session-service.js';
2
+ import { type CwdInspection, type ForkResult, type ManualCompactResult, type ManualCompactSelection, type RewindPoint, type SessionForkCheckpoint, type SessionInspection, type SessionRunResult, type SessionSummary, type SideQuestionForkResult, type SideQuestionResult } from './application/session-service.js';
3
3
  import type { TeamLeadOperations } from './application/team-lead-operations.js';
4
4
  import type { ClaudeSessionCostSnapshot } from './application/session-cost-tracker.js';
5
5
  import { type AgentColorName, type AgentColorSelection } from './core/agent-color.js';
@@ -43,7 +43,8 @@ interface SessionCommands {
43
43
  setPermissionMode?(sessionId: string, mode: ClaudePermissionMode): Promise<void>;
44
44
  rewindFiles?(sessionId: string, userMessageId: string): Promise<void>;
45
45
  rewindPoints?(sessionId: string): Promise<RewindPoint[]>;
46
- changeCwd?(sessionId: string | undefined, cwd: string): Promise<string>;
46
+ changeCwd?(sessionId: string | undefined, cwd: string, expectedCanonicalTarget?: string): Promise<string>;
47
+ inspectCwd?(cwd: string): Promise<CwdInspection>;
47
48
  notify?(sessionId: string | undefined, message: string, notificationType: string, title?: string): void;
48
49
  recordCdUsage?(sessionId: string): Promise<void>;
49
50
  approveRecentlyDenied?(sessionId: string, display: string): Promise<void>;
@@ -2049,7 +2049,8 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
2049
2049
  setPermissionMode: (sessionId, permissionMode) => service.setPermissionMode(sessionId, permissionMode),
2050
2050
  rewindFiles: (sessionId, userMessageId) => service.rewindFiles(sessionId, userMessageId),
2051
2051
  rewindPoints: (sessionId) => service.rewindPoints(sessionId),
2052
- changeCwd: (sessionId, cwd) => service.changeCwd(sessionId, cwd),
2052
+ changeCwd: (sessionId, cwd, expectedCanonicalTarget) => service.changeCwd(sessionId, cwd, expectedCanonicalTarget),
2053
+ inspectCwd: (cwd) => service.inspectCwd(cwd),
2053
2054
  notify: (sessionId, message, notificationType, title) => service.notifyDetached(sessionId, message, notificationType, title),
2054
2055
  recordCdUsage: (sessionId) => service.recordCdUsage(sessionId),
2055
2056
  approveRecentlyDenied: (sessionId, display) => service.approveRecentlyDenied(sessionId, display),
@@ -7,8 +7,4 @@
7
7
  export type NativeTranscriptEntry = Record<string, unknown> & {
8
8
  type: string;
9
9
  };
10
- export declare function isNativeForkableEntryType(type: string): boolean;
11
- /** Copy only projection metadata; persisted native events are copied by
12
- * NativeSessionTranscript.forkTo and retain their event identity semantics. */
13
- export declare function copyNativeEntryWithSessionId(entry: NativeTranscriptEntry, sessionId: string): NativeTranscriptEntry;
14
10
  //# sourceMappingURL=schema.d.ts.map
@@ -1,24 +1,2 @@
1
- const FORKABLE_ENTRY_TYPES = new Set([
2
- 'agent-color',
3
- 'agent-name',
4
- 'agent-setting',
5
- 'ai-title',
6
- 'assistant',
7
- 'attachment',
8
- 'custom-title',
9
- 'last-prompt',
10
- 'mode',
11
- 'permission-mode',
12
- 'pr-link',
13
- 'system',
14
- 'user',
15
- ]);
16
- export function isNativeForkableEntryType(type) {
17
- return FORKABLE_ENTRY_TYPES.has(type);
18
- }
19
- /** Copy only projection metadata; persisted native events are copied by
20
- * NativeSessionTranscript.forkTo and retain their event identity semantics. */
21
- export function copyNativeEntryWithSessionId(entry, sessionId) {
22
- return { ...entry, sessionId };
23
- }
1
+ export {};
24
2
  //# sourceMappingURL=schema.js.map
@@ -304,11 +304,12 @@ function validate(input, name) {
304
304
  for (const member of input.roster) {
305
305
  if (!member || typeof member !== 'object' || Array.isArray(member))
306
306
  throw new Error('Invalid Team member');
307
- objectInput(member, [
308
- 'name',
309
- 'agentType',
310
- 'access',
311
- ]);
307
+ const value = member;
308
+ objectInput(value, ['name', 'agentType', 'access']);
309
+ stringValue(value, 'name');
310
+ stringValue(value, 'agentType');
311
+ if (value.access !== 'read-only' && value.access !== 'write')
312
+ throw new Error('Invalid Team member access');
312
313
  }
313
314
  for (const task of input.tasks) {
314
315
  if (!task || typeof task !== 'object' || Array.isArray(task))
@@ -321,6 +322,12 @@ function validate(input, name) {
321
322
  'blockedBy',
322
323
  'claims',
323
324
  ]);
325
+ stringValue(value, 'id');
326
+ stringValue(value, 'description');
327
+ stringValue(value, 'assignee');
328
+ if (!Array.isArray(value.blockedBy) ||
329
+ value.blockedBy.some((entry) => typeof entry !== 'string' || entry.trim() === ''))
330
+ throw new Error('Invalid Team blockedBy');
324
331
  if (!value.claims ||
325
332
  typeof value.claims !== 'object' ||
326
333
  Array.isArray(value.claims))
@@ -332,6 +339,18 @@ function validate(input, name) {
332
339
  'migrations',
333
340
  'mergeTargets',
334
341
  ]);
342
+ for (const key of [
343
+ 'files',
344
+ 'publicContracts',
345
+ 'generatedArtifacts',
346
+ 'migrations',
347
+ 'mergeTargets',
348
+ ]) {
349
+ const entries = value.claims[key];
350
+ if (!Array.isArray(entries) ||
351
+ entries.some((entry) => typeof entry !== 'string' || entry.trim() === ''))
352
+ throw new Error(`Invalid Team claim list: ${key}`);
353
+ }
335
354
  }
336
355
  return input;
337
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.46.1",
3
+ "version": "0.46.2",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.build.json",
32
- "check": "npm run format:check && npm run lint && npm run test:docs && npm run verify:release-automation && npm run verify:ci-coverage && npm run check:boundaries && npm run typecheck && npm run build:native && npm run build && npm test",
32
+ "check": "npm run format:check && npm run lint && npm run test:docs && npm run verify:release-automation && npm run verify:ci-coverage && npm run verify:fixture-contracts && npm run check:boundaries && npm run typecheck && npm run build:native && npm run build && npm test",
33
33
  "check:boundaries": "node scripts/check-boundaries.mjs",
34
34
  "dev": "tsx src/cli.ts",
35
35
  "format": "prettier --write .",
@@ -44,11 +44,11 @@
44
44
  "release:artifacts": "node scripts/build-release-artifacts.mjs",
45
45
  "release:verify": "node scripts/verify-release-ref.mjs",
46
46
  "test": "vitest run",
47
- "test:coverage": "vitest run --coverage",
47
+ "test:coverage": "vitest run --coverage && node scripts/verify-nonzero-runtime-coverage.mjs",
48
48
  "test:docs": "node scripts/verify-docs.mjs",
49
49
  "test:tui:pty": "npm run build && node scripts/verify-fullscreen-rendering.mjs && node scripts/verify-interactive-ansi.mjs",
50
50
  "test:self-update": "npm run build && node scripts/verify-self-update-contract.mjs",
51
- "test:core-completion": "npm run build && node scripts/verify-core-completion-audit.mjs",
51
+ "test:core-completion": "npm run test:fixtures",
52
52
  "test:performance": "npm run build && node scripts/verify-projection-scaling.mjs && node scripts/verify-projection-regression.mjs && node scripts/verify-quiet-frame-performance.mjs",
53
53
  "test:performance:projection": "npm run build && node scripts/verify-projection-scaling.mjs",
54
54
  "test:performance:projection-regression": "npm run build && node scripts/verify-projection-regression.mjs",
@@ -56,9 +56,12 @@
56
56
  "test:prompt-suggestions": "npm run build && node scripts/verify-prompt-suggestions.mjs",
57
57
  "test:mcp-oauth-serve": "npm run build && node scripts/verify-mcp-oauth-serve.mjs",
58
58
  "test:package": "npm run build && node scripts/verify-native-release-package.mjs",
59
+ "test:security": "npm audit --omit=dev",
59
60
  "typecheck": "tsc --noEmit",
60
61
  "verify:release-automation": "node scripts/verify-release-automation.mjs",
61
- "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs"
62
+ "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
63
+ "verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
64
+ "test:fixtures": "node scripts/run-fixture-contracts.mjs"
62
65
  },
63
66
  "engines": {
64
67
  "node": ">=24"
@@ -1,9 +0,0 @@
1
- import { type NativeTranscriptEntry } from './schema.js';
2
- export interface ClaudeNativeForkOptions {
3
- source: readonly NativeTranscriptEntry[];
4
- sourceSessionId: string;
5
- sessionId: string;
6
- resumeSessionAt?: string;
7
- }
8
- export declare function createClaudeNativeFork({ source, sourceSessionId, sessionId, resumeSessionAt, }: ClaudeNativeForkOptions): NativeTranscriptEntry[];
9
- //# sourceMappingURL=fork.d.ts.map
@@ -1,260 +0,0 @@
1
- import { getClaudeContentBlocks } from './tool-links.js';
2
- import { getClaudePreservedMessageUuids } from './compaction.js';
3
- import { selectClaudeActiveTranscript, selectClaudeTranscriptAtMessage, } from './history.js';
4
- import { copyNativeEntryWithSessionId, isNativeForkableEntryType, } from './schema.js';
5
- const TRANSIENT_ENTRY_TYPES = new Set([
6
- 'atis-latch',
7
- 'file-history-delta',
8
- 'file-history-snapshot',
9
- 'queue-operation',
10
- 'relocated',
11
- ]);
12
- function advancesLogicalTail(entry) {
13
- if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
14
- return false;
15
- }
16
- if (entry.type !== 'attachment')
17
- return true;
18
- const attachment = entry.attachment;
19
- if (typeof attachment !== 'object' || attachment === null)
20
- return true;
21
- const attachmentType = attachment.type;
22
- return (attachmentType !== 'hook_success' &&
23
- attachmentType !== 'hook_error' &&
24
- attachmentType !== 'hook_additional_context');
25
- }
26
- function validateNativeHistory(entries) {
27
- const entriesByUuid = new Map();
28
- const toolCalls = new Map();
29
- for (const entry of entries) {
30
- if (typeof entry.uuid !== 'string')
31
- continue;
32
- if (entriesByUuid.has(entry.uuid)) {
33
- throw new Error(`Claude fork source has duplicate UUID ${entry.uuid}`);
34
- }
35
- entriesByUuid.set(entry.uuid, entry);
36
- if (entry.type !== 'assistant')
37
- continue;
38
- for (const block of getClaudeContentBlocks(entry)) {
39
- if (block.type !== 'tool_use')
40
- continue;
41
- const id = block.id;
42
- if (typeof id !== 'string')
43
- continue;
44
- if (toolCalls.has(id)) {
45
- throw new Error(`Claude fork source has duplicate tool_use ${id}`);
46
- }
47
- toolCalls.set(id, entry.uuid);
48
- }
49
- }
50
- const childParentUuids = new Set();
51
- const externalParentUuids = new Set();
52
- let externalParentReferenceCount = 0;
53
- const completedToolCalls = new Set();
54
- let ordinaryRootCount = 0;
55
- let compactBoundary;
56
- let logicalTailUuid;
57
- for (const entry of entries) {
58
- const isCompactSummary = entry.isCompactSummary === true;
59
- if (compactBoundary && !isCompactSummary) {
60
- throw new Error('Claude compact boundary has no adjacent summary');
61
- }
62
- if (isCompactSummary) {
63
- if (!compactBoundary || entry.parentUuid !== compactBoundary.uuid) {
64
- throw new Error('Claude compact summary has no matching boundary');
65
- }
66
- compactBoundary = undefined;
67
- }
68
- else if (entry.type === 'system' &&
69
- entry.subtype === 'compact_boundary') {
70
- const metadata = entry.compactMetadata;
71
- const preservedSegment = typeof metadata === 'object' && metadata !== null
72
- ? metadata.preservedSegment
73
- : undefined;
74
- const preservedUuids = getClaudePreservedMessageUuids(entry);
75
- const segment = typeof preservedSegment === 'object' && preservedSegment !== null
76
- ? preservedSegment
77
- : {};
78
- const segmentMatchesLogicalTail = segment.headUuid === logicalTailUuid &&
79
- segment.tailUuid === logicalTailUuid;
80
- const segmentMatchesPreserved = segment.headUuid === preservedUuids[0] &&
81
- segment.tailUuid === preservedUuids[preservedUuids.length - 1];
82
- if (typeof preservedSegment !== 'object' ||
83
- preservedSegment === null ||
84
- entry.logicalParentUuid !== logicalTailUuid ||
85
- (!segmentMatchesLogicalTail && !segmentMatchesPreserved)) {
86
- throw new Error('Claude compact boundary has invalid logical parent');
87
- }
88
- compactBoundary = entry;
89
- }
90
- if (typeof entry.uuid === 'string') {
91
- if (entry.parentUuid === null &&
92
- !(entry.type === 'system' && entry.subtype === 'compact_boundary')) {
93
- ordinaryRootCount += 1;
94
- }
95
- else if (typeof entry.parentUuid === 'string') {
96
- if (entriesByUuid.has(entry.parentUuid)) {
97
- childParentUuids.add(entry.parentUuid);
98
- }
99
- else {
100
- externalParentUuids.add(entry.parentUuid);
101
- externalParentReferenceCount += 1;
102
- }
103
- }
104
- }
105
- if (typeof entry.uuid === 'string' && advancesLogicalTail(entry)) {
106
- logicalTailUuid = entry.uuid;
107
- }
108
- for (const block of getClaudeContentBlocks(entry)) {
109
- if (entry.type !== 'user' || block.type !== 'tool_result')
110
- continue;
111
- const id = block.tool_use_id;
112
- if (typeof id !== 'string' ||
113
- typeof entry.sourceToolAssistantUUID !== 'string' ||
114
- toolCalls.get(id) !== entry.sourceToolAssistantUUID) {
115
- throw new Error('Claude fork source has an unmatched tool_result');
116
- }
117
- if (completedToolCalls.has(id)) {
118
- throw new Error(`Claude fork source has duplicate tool_result ${id}`);
119
- }
120
- completedToolCalls.add(id);
121
- }
122
- }
123
- if (compactBoundary) {
124
- throw new Error('Claude compact boundary has no adjacent summary');
125
- }
126
- const omittedSeedUserParent = externalParentUuids.size === 1 ? [...externalParentUuids][0] : undefined;
127
- const firstEntry = entries[0];
128
- const isOmittedSeedUserChain = externalParentUuids.size === 1 &&
129
- ordinaryRootCount === 0 &&
130
- externalParentReferenceCount === 1 &&
131
- firstEntry?.type === 'assistant' &&
132
- firstEntry?.parentUuid === omittedSeedUserParent;
133
- if ((externalParentUuids.size > 0 && !isOmittedSeedUserChain) ||
134
- ordinaryRootCount > 1) {
135
- throw new Error('Claude fork source has a dangling parentUuid');
136
- }
137
- const states = new Map();
138
- for (const startUuid of entriesByUuid.keys()) {
139
- if (states.get(startUuid) === 'visited')
140
- continue;
141
- const path = [];
142
- let uuid = startUuid;
143
- while (uuid !== undefined && entriesByUuid.has(uuid)) {
144
- const state = states.get(uuid);
145
- if (state === 'visiting') {
146
- throw new Error('Claude fork source parentUuid graph has a cycle');
147
- }
148
- if (state === 'visited')
149
- break;
150
- states.set(uuid, 'visiting');
151
- path.push(uuid);
152
- const parentUuid = entriesByUuid.get(uuid)?.parentUuid;
153
- uuid = typeof parentUuid === 'string' ? parentUuid : undefined;
154
- }
155
- for (const pathUuid of path)
156
- states.set(pathUuid, 'visited');
157
- }
158
- for (let index = entries.length - 1; index >= 0; index -= 1) {
159
- const uuid = entries[index]?.uuid;
160
- if (typeof uuid === 'string' && !childParentUuids.has(uuid))
161
- return uuid;
162
- }
163
- return undefined;
164
- }
165
- export function createClaudeNativeFork({ source, sourceSessionId, sessionId, resumeSessionAt, }) {
166
- const agentColors = [];
167
- const titles = [];
168
- const modes = [];
169
- const permissionModes = [];
170
- const prLinks = [];
171
- const history = [];
172
- const nativeHistory = [];
173
- let lastPrompt;
174
- let nativeLastPrompt;
175
- const hasSelectiveSummary = source.some((entry) => {
176
- if (entry.isCompactSummary !== true)
177
- return false;
178
- const metadata = entry.summarizeMetadata;
179
- return (typeof metadata === 'object' &&
180
- metadata !== null &&
181
- (metadata.direction === 'from' ||
182
- metadata.direction === 'up_to'));
183
- });
184
- const hasCompactHistory = source.some((entry) => entry.isCompactSummary === true ||
185
- (entry.type === 'system' && entry.subtype === 'compact_boundary'));
186
- const activeSource = resumeSessionAt === undefined
187
- ? hasCompactHistory || hasSelectiveSummary
188
- ? selectClaudeActiveTranscript(source)
189
- : source
190
- : selectClaudeTranscriptAtMessage(source, resumeSessionAt);
191
- for (const entry of activeSource) {
192
- if (TRANSIENT_ENTRY_TYPES.has(entry.type)) {
193
- if ((entry.type === 'queue-operation' &&
194
- entry.sessionId !== sourceSessionId) ||
195
- (entry.type !== 'queue-operation' &&
196
- entry.sessionId !== undefined &&
197
- entry.sessionId !== sourceSessionId)) {
198
- throw new Error('Claude fork source entry has the wrong sessionId');
199
- }
200
- continue;
201
- }
202
- if (entry.isSidechain === true) {
203
- if (!isNativeForkableEntryType(entry.type)) {
204
- throw new Error(`Claude transcript entry type ${entry.type} is not forkable by Praxis`);
205
- }
206
- if (typeof entry.sessionId !== 'string' || entry.sessionId.length === 0) {
207
- throw new Error('Claude fork entry has no sessionId');
208
- }
209
- continue;
210
- }
211
- if (!isNativeForkableEntryType(entry.type)) {
212
- throw new Error(`Claude transcript entry type ${entry.type} is not forkable by Praxis`);
213
- }
214
- if (typeof entry.sessionId !== 'string' || entry.sessionId.length === 0) {
215
- throw new Error('Claude fork entry has no sessionId');
216
- }
217
- if (entry.sessionId !== sourceSessionId) {
218
- throw new Error('Claude fork source entry has the wrong sessionId');
219
- }
220
- const copied = copyNativeEntryWithSessionId(entry, sessionId);
221
- if (entry.type === 'agent-color')
222
- agentColors.push(copied);
223
- else if (entry.type === 'ai-title')
224
- titles.push(copied);
225
- else if (entry.type === 'mode')
226
- modes.push(copied);
227
- else if (entry.type === 'permission-mode')
228
- permissionModes.push(copied);
229
- else if (entry.type === 'pr-link')
230
- prLinks.push(copied);
231
- else if (entry.type === 'last-prompt') {
232
- lastPrompt = copied;
233
- nativeLastPrompt = entry;
234
- }
235
- else {
236
- history.push(copied);
237
- nativeHistory.push(entry);
238
- }
239
- }
240
- if (!nativeHistory.some((entry) => typeof entry.uuid === 'string')) {
241
- throw new Error('Claude session has no native history to fork');
242
- }
243
- const logicalTailUuid = validateNativeHistory(nativeHistory);
244
- if (nativeLastPrompt &&
245
- (typeof nativeLastPrompt.leafUuid !== 'string' ||
246
- nativeLastPrompt.leafUuid !== logicalTailUuid)) {
247
- nativeLastPrompt = undefined;
248
- lastPrompt = undefined;
249
- }
250
- return [
251
- ...agentColors.slice(-1),
252
- ...titles.slice(-1),
253
- ...modes.slice(-1),
254
- ...permissionModes.slice(-1),
255
- ...prLinks.slice(-1),
256
- ...history,
257
- ...(lastPrompt ? [lastPrompt] : []),
258
- ];
259
- }
260
- //# sourceMappingURL=fork.js.map