praxis-agent 0.32.0 → 0.33.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.
@@ -0,0 +1,180 @@
1
+ export const CLAUDE_DURABLE_METADATA_TYPES = [
2
+ 'custom-title',
3
+ 'ai-title',
4
+ 'tag',
5
+ 'agent-name',
6
+ 'agent-color',
7
+ 'agent-setting',
8
+ 'permission-mode',
9
+ 'mode',
10
+ 'worktree-state',
11
+ 'pr-link',
12
+ 'last-prompt',
13
+ ];
14
+ const DURABLE_METADATA_TYPE_SET = new Set(CLAUDE_DURABLE_METADATA_TYPES);
15
+ export function isClaudeDurableMetadataType(type) {
16
+ return DURABLE_METADATA_TYPE_SET.has(type);
17
+ }
18
+ function nonEmptyString(value) {
19
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
20
+ }
21
+ function record(value) {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
23
+ ? value
24
+ : undefined;
25
+ }
26
+ /**
27
+ * Reduces Claude's append-only metadata records. Each metadata type is
28
+ * independently last-wins, while a user-authored custom title always takes
29
+ * precedence over an AI title regardless of append order.
30
+ *
31
+ * Unknown records remain untouched in the transcript but never become
32
+ * authoritative session state here.
33
+ */
34
+ export function reduceClaudeSessionMetadata(entries, sessionId) {
35
+ const latest = new Map();
36
+ for (const entry of entries) {
37
+ if (!isClaudeDurableMetadataType(entry.type))
38
+ continue;
39
+ if (sessionId !== undefined &&
40
+ typeof entry.sessionId === 'string' &&
41
+ entry.sessionId !== sessionId) {
42
+ continue;
43
+ }
44
+ latest.set(entry.type, entry);
45
+ }
46
+ const customTitle = nonEmptyString(latest.get('custom-title')?.customTitle);
47
+ const aiTitle = nonEmptyString(latest.get('ai-title')?.aiTitle);
48
+ const tag = nonEmptyString(latest.get('tag')?.tag);
49
+ const agentName = nonEmptyString(latest.get('agent-name')?.agentName);
50
+ const agentColor = nonEmptyString(latest.get('agent-color')?.agentColor);
51
+ const agentSetting = nonEmptyString(latest.get('agent-setting')?.agentSetting);
52
+ const permissionMode = nonEmptyString(latest.get('permission-mode')?.permissionMode);
53
+ const mode = nonEmptyString(latest.get('mode')?.mode);
54
+ const worktreeSession = record(latest.get('worktree-state')?.worktreeSession);
55
+ const pr = latest.get('pr-link');
56
+ const prNumber = pr?.prNumber;
57
+ const prUrl = nonEmptyString(pr?.prUrl);
58
+ const prRepository = nonEmptyString(pr?.prRepository);
59
+ const prLink = Number.isSafeInteger(prNumber) && Number(prNumber) > 0
60
+ ? {
61
+ prNumber: Number(prNumber),
62
+ ...(prUrl === undefined ? {} : { prUrl }),
63
+ ...(prRepository === undefined ? {} : { prRepository }),
64
+ }
65
+ : undefined;
66
+ const lastPromptEntry = latest.get('last-prompt');
67
+ const lastPrompt = nonEmptyString(lastPromptEntry?.lastPrompt);
68
+ const lastPromptLeafUuid = nonEmptyString(lastPromptEntry?.leafUuid);
69
+ return {
70
+ ...(customTitle === undefined ? {} : { customTitle }),
71
+ ...(aiTitle === undefined ? {} : { aiTitle }),
72
+ ...(customTitle !== undefined
73
+ ? { title: customTitle, titleSource: 'custom-title' }
74
+ : aiTitle !== undefined
75
+ ? { title: aiTitle, titleSource: 'ai-title' }
76
+ : {}),
77
+ ...(tag === undefined ? {} : { tag }),
78
+ ...(agentName === undefined ? {} : { agentName }),
79
+ ...(agentColor === undefined ? {} : { agentColor }),
80
+ ...(agentSetting === undefined ? {} : { agentSetting }),
81
+ ...(permissionMode === undefined ? {} : { permissionMode }),
82
+ ...(mode === undefined ? {} : { mode }),
83
+ ...(worktreeSession === undefined ? {} : { worktreeSession }),
84
+ ...(prLink === undefined ? {} : { prLink }),
85
+ ...(lastPrompt === undefined ? {} : { lastPrompt }),
86
+ ...(lastPromptLeafUuid === undefined ? {} : { lastPromptLeafUuid }),
87
+ };
88
+ }
89
+ /** Returns one current record per durable metadata type for a tail snapshot. */
90
+ export function createClaudeDurableMetadataSnapshot(entries, sessionId, options = {}) {
91
+ const latest = new Map();
92
+ for (const entry of entries) {
93
+ if (!isClaudeDurableMetadataType(entry.type))
94
+ continue;
95
+ if (typeof entry.sessionId === 'string' && entry.sessionId !== sessionId) {
96
+ continue;
97
+ }
98
+ latest.set(entry.type, entry);
99
+ }
100
+ // A later AI title must never obscure a user rename in a bounded tail.
101
+ if (latest.has('custom-title'))
102
+ latest.delete('ai-title');
103
+ const lastPrompt = latest.get('last-prompt');
104
+ if (lastPrompt !== undefined) {
105
+ const committedLeafUuid = nonEmptyString(lastPrompt.leafUuid);
106
+ const committedLeaf = entries.find((entry) => entry.isSidechain !== true &&
107
+ typeof entry.uuid === 'string' &&
108
+ entry.uuid === committedLeafUuid &&
109
+ (entry.type === 'assistant' ||
110
+ (entry.type === 'system' &&
111
+ entry.subtype === 'local_command' &&
112
+ typeof entry.content === 'string' &&
113
+ entry.content.startsWith('<local-command-stdout>'))));
114
+ const trustedCommittedLeafUuid = committedLeaf?.uuid ??
115
+ (options.trustLastPromptReference ? committedLeafUuid : undefined);
116
+ if (trustedCommittedLeafUuid === undefined) {
117
+ latest.delete('last-prompt');
118
+ }
119
+ else {
120
+ // Metadata-only re-appends never promote a newer assistant: it may be
121
+ // an abandoned or failed branch. A completed local command is itself a
122
+ // committed observable result, so it may safely advance the hint when
123
+ // it descends from the last committed turn.
124
+ const byUuid = new Map(entries.flatMap((entry) => entry.isSidechain !== true && typeof entry.uuid === 'string'
125
+ ? [[entry.uuid, entry]]
126
+ : []));
127
+ const localCommandLeaf = [...entries].reverse().find((entry) => {
128
+ if (entry.isSidechain === true ||
129
+ entry.type !== 'system' ||
130
+ entry.subtype !== 'local_command' ||
131
+ typeof entry.uuid !== 'string' ||
132
+ typeof entry.content !== 'string' ||
133
+ !entry.content.startsWith('<local-command-stdout>')) {
134
+ return false;
135
+ }
136
+ let parentUuid = typeof entry.parentUuid === 'string' ? entry.parentUuid : null;
137
+ const seen = new Set();
138
+ while (parentUuid !== null && !seen.has(parentUuid)) {
139
+ if (parentUuid === trustedCommittedLeafUuid)
140
+ return true;
141
+ seen.add(parentUuid);
142
+ const parent = byUuid.get(parentUuid);
143
+ if (parent?.type !== 'system' || parent.subtype !== 'local_command') {
144
+ return false;
145
+ }
146
+ parentUuid =
147
+ typeof parent?.parentUuid === 'string' ? parent.parentUuid : null;
148
+ }
149
+ return false;
150
+ });
151
+ if (localCommandLeaf === undefined) {
152
+ latest.set('last-prompt', lastPrompt);
153
+ }
154
+ else {
155
+ const snapshot = {
156
+ ...lastPrompt,
157
+ leafUuid: localCommandLeaf.uuid,
158
+ };
159
+ delete snapshot.lastPrompt;
160
+ latest.set('last-prompt', snapshot);
161
+ }
162
+ }
163
+ }
164
+ return CLAUDE_DURABLE_METADATA_TYPES.flatMap((type) => {
165
+ const entry = latest.get(type);
166
+ return entry === undefined ? [] : [{ ...entry, sessionId }];
167
+ });
168
+ }
169
+ /** Keeps the last known full baseline while overlaying metadata visible in a
170
+ * newer bounded head/tail observation. Structural entries are included only
171
+ * to validate or safely advance the committed last-prompt hint. */
172
+ export function mergeClaudeDurableMetadataSnapshot(baseline, observed, sessionId) {
173
+ return createClaudeDurableMetadataSnapshot([...baseline, ...observed], sessionId, { trustLastPromptReference: true });
174
+ }
175
+ export function createClaudeTagEntry(sessionId, tag) {
176
+ if (tag.length === 0)
177
+ throw new Error('Session tag must not be empty');
178
+ return { type: 'tag', tag, sessionId };
179
+ }
180
+ //# sourceMappingURL=session-metadata.js.map
@@ -0,0 +1,39 @@
1
+ import type { ClaudeSchemaAdapter, ClaudeTranscriptEntry } from '../compatibility/claude/schema.js';
2
+ import type { TranscriptParseIssue } from './claude-transcript-store.js';
3
+ import type { TranscriptTail } from './claude-transcript-store.js';
4
+ export declare const CLAUDE_SESSION_INDEX_HEAD_BYTES: number;
5
+ export declare const CLAUDE_SESSION_INDEX_TAIL_BYTES: number;
6
+ export declare const CLAUDE_SESSION_INDEX_CONCURRENCY = 32;
7
+ export interface ClaudeSessionIndex {
8
+ entries: ClaudeTranscriptEntry[];
9
+ tailEntries: ClaudeTranscriptEntry[];
10
+ issue: TranscriptParseIssue | null;
11
+ byteLength: number;
12
+ newlineTerminated: boolean;
13
+ updatedAt: string;
14
+ tail: TranscriptTail;
15
+ }
16
+ export interface ClaudeSessionIndexRequest {
17
+ sessionId: string;
18
+ path: string;
19
+ }
20
+ export type ClaudeSessionIndexResult = ClaudeSessionIndexRequest & ({
21
+ index: ClaudeSessionIndex;
22
+ error?: never;
23
+ } | {
24
+ index?: never;
25
+ error: unknown;
26
+ });
27
+ export declare class ClaudeSessionIndexCandidateError extends Error {
28
+ readonly name = "ClaudeSessionIndexCandidateError";
29
+ }
30
+ /**
31
+ * Reads only bounded head/tail windows for session discovery. A partial final
32
+ * line is treated as an in-progress append and ignored. Full JSONL and graph
33
+ * validation remains the responsibility of inspect/resume.
34
+ */
35
+ export declare function readClaudeSessionIndex(path: string, schema: ClaudeSchemaAdapter): Promise<ClaudeSessionIndex>;
36
+ /** Reads a discovery set with a fixed worker pool so large projects cannot
37
+ * exhaust the process file-descriptor limit. Results preserve input order. */
38
+ export declare function readClaudeSessionIndexes(requests: readonly ClaudeSessionIndexRequest[], schema: ClaudeSchemaAdapter, concurrency?: number): Promise<ClaudeSessionIndexResult[]>;
39
+ //# sourceMappingURL=claude-session-index.d.ts.map
@@ -0,0 +1,175 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import { TextDecoder } from 'node:util';
4
+ import { isClaudeDurableLastPromptSnapshot } from '../compatibility/claude/history.js';
5
+ export const CLAUDE_SESSION_INDEX_HEAD_BYTES = 64 * 1024;
6
+ export const CLAUDE_SESSION_INDEX_TAIL_BYTES = 128 * 1024;
7
+ export const CLAUDE_SESSION_INDEX_CONCURRENCY = 32;
8
+ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
9
+ function logicalTailUuid(entries) {
10
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
11
+ const entry = entries[index];
12
+ if (!entry)
13
+ continue;
14
+ if (entry.type === 'last-prompt' &&
15
+ isClaudeDurableLastPromptSnapshot(entries, index)) {
16
+ continue;
17
+ }
18
+ if (typeof entry.uuid === 'string')
19
+ return entry.uuid;
20
+ if (typeof entry.leafUuid === 'string')
21
+ return entry.leafUuid;
22
+ }
23
+ return null;
24
+ }
25
+ export class ClaudeSessionIndexCandidateError extends Error {
26
+ name = 'ClaudeSessionIndexCandidateError';
27
+ }
28
+ function completeLines(source, absoluteStart, discardLeadingPartial, discardTrailingPartial, lineNumberStart, tail) {
29
+ let start = 0;
30
+ if (discardLeadingPartial) {
31
+ const newline = source.indexOf(0x0a);
32
+ if (newline < 0)
33
+ return [];
34
+ start = newline + 1;
35
+ }
36
+ let end = source.length;
37
+ if (discardTrailingPartial && source.at(-1) !== 0x0a) {
38
+ const newline = source.lastIndexOf(0x0a);
39
+ if (newline < start)
40
+ return [];
41
+ end = newline + 1;
42
+ }
43
+ const lines = [];
44
+ let lineStart = start;
45
+ for (let index = start; index < end; index += 1) {
46
+ if (source[index] !== 0x0a)
47
+ continue;
48
+ if (index > lineStart) {
49
+ lines.push({
50
+ source: source.subarray(lineStart, index),
51
+ byteOffset: absoluteStart + lineStart,
52
+ lineNumber: lineNumberStart === null ? null : lineNumberStart + lines.length,
53
+ tail,
54
+ });
55
+ }
56
+ lineStart = index + 1;
57
+ }
58
+ if (!discardTrailingPartial && lineStart < end) {
59
+ lines.push({
60
+ source: source.subarray(lineStart, end),
61
+ byteOffset: absoluteStart + lineStart,
62
+ lineNumber: lineNumberStart === null ? null : lineNumberStart + lines.length,
63
+ tail,
64
+ });
65
+ }
66
+ return lines;
67
+ }
68
+ async function readWindow(handle, length, position) {
69
+ const buffer = Buffer.alloc(length);
70
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
71
+ return buffer.subarray(0, bytesRead);
72
+ }
73
+ /**
74
+ * Reads only bounded head/tail windows for session discovery. A partial final
75
+ * line is treated as an in-progress append and ignored. Full JSONL and graph
76
+ * validation remains the responsibility of inspect/resume.
77
+ */
78
+ export async function readClaudeSessionIndex(path, schema) {
79
+ const pathMetadata = await lstat(path);
80
+ if (!pathMetadata.isFile())
81
+ throw new ClaudeSessionIndexCandidateError(`Expected a regular file: ${path}`);
82
+ const handle = await open(path, 'r');
83
+ try {
84
+ const metadata = await handle.stat();
85
+ const byteLength = metadata.size;
86
+ const wholeFileThreshold = CLAUDE_SESSION_INDEX_HEAD_BYTES + CLAUDE_SESSION_INDEX_TAIL_BYTES;
87
+ let lines;
88
+ let newlineTerminated = true;
89
+ if (byteLength <= wholeFileThreshold) {
90
+ const source = await readWindow(handle, byteLength, 0);
91
+ newlineTerminated = source.length === 0 || source.at(-1) === 0x0a;
92
+ lines = completeLines(source, 0, false, !newlineTerminated, 1, true);
93
+ }
94
+ else {
95
+ const head = await readWindow(handle, CLAUDE_SESSION_INDEX_HEAD_BYTES, 0);
96
+ const tailStart = byteLength - CLAUDE_SESSION_INDEX_TAIL_BYTES;
97
+ const tail = await readWindow(handle, CLAUDE_SESSION_INDEX_TAIL_BYTES, tailStart);
98
+ newlineTerminated = tail.length === 0 || tail.at(-1) === 0x0a;
99
+ lines = [
100
+ ...completeLines(head, 0, false, true, 1, false),
101
+ ...completeLines(tail, tailStart, true, !newlineTerminated, null, true),
102
+ ];
103
+ }
104
+ const entries = [];
105
+ const tailEntries = [];
106
+ let issue = null;
107
+ for (const line of lines) {
108
+ try {
109
+ const entry = schema.parse(UTF8_DECODER.decode(line.source));
110
+ entries.push(entry);
111
+ if (line.tail)
112
+ tailEntries.push(entry);
113
+ }
114
+ catch (error) {
115
+ issue = {
116
+ lineNumber: line.lineNumber,
117
+ byteOffset: line.byteOffset,
118
+ message: error instanceof Error ? error.message : String(error),
119
+ };
120
+ break;
121
+ }
122
+ }
123
+ return {
124
+ entries,
125
+ tailEntries,
126
+ issue,
127
+ byteLength,
128
+ newlineTerminated,
129
+ updatedAt: metadata.mtime.toISOString(),
130
+ tail: {
131
+ byteLength,
132
+ lastLineHash: lines.at(-1) === undefined
133
+ ? null
134
+ : createHash('sha256')
135
+ .update(lines.at(-1)?.source ?? Buffer.alloc(0))
136
+ .digest('hex'),
137
+ lastUuid: logicalTailUuid(entries),
138
+ newlineTerminated,
139
+ },
140
+ };
141
+ }
142
+ finally {
143
+ await handle.close();
144
+ }
145
+ }
146
+ /** Reads a discovery set with a fixed worker pool so large projects cannot
147
+ * exhaust the process file-descriptor limit. Results preserve input order. */
148
+ export async function readClaudeSessionIndexes(requests, schema, concurrency = CLAUDE_SESSION_INDEX_CONCURRENCY) {
149
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
150
+ throw new Error('Claude session index concurrency must be a positive integer');
151
+ }
152
+ const results = new Array(requests.length);
153
+ let nextIndex = 0;
154
+ const worker = async () => {
155
+ while (nextIndex < requests.length) {
156
+ const index = nextIndex;
157
+ nextIndex += 1;
158
+ const request = requests[index];
159
+ if (request === undefined)
160
+ continue;
161
+ try {
162
+ results[index] = {
163
+ ...request,
164
+ index: await readClaudeSessionIndex(request.path, schema),
165
+ };
166
+ }
167
+ catch (error) {
168
+ results[index] = { ...request, error };
169
+ }
170
+ }
171
+ };
172
+ await Promise.all(Array.from({ length: Math.min(concurrency, requests.length) }, async () => worker()));
173
+ return results;
174
+ }
175
+ //# sourceMappingURL=claude-session-index.js.map
@@ -1,17 +1,18 @@
1
1
  import type { ClaudeSchemaAdapter, ClaudeTranscriptEntry } from '../compatibility/claude/schema.js';
2
+ import { type ClaudeSessionIndex } from './claude-session-index.js';
2
3
  export interface TranscriptTail {
3
4
  byteLength: number;
4
5
  lastLineHash: string | null;
5
6
  lastUuid: string | null;
6
7
  newlineTerminated: boolean;
7
- branchParentUuid?: string;
8
+ branchParentUuid?: string | null;
8
9
  }
9
10
  export interface TranscriptSnapshot {
10
11
  entries: ClaudeTranscriptEntry[];
11
12
  tail: TranscriptTail;
12
13
  }
13
14
  export interface TranscriptParseIssue {
14
- lineNumber: number;
15
+ lineNumber: number | null;
15
16
  byteOffset: number;
16
17
  message: string;
17
18
  }
@@ -46,8 +47,10 @@ export type TranscriptReserveResult = {
46
47
  };
47
48
  export interface ClaudeTranscriptLease {
48
49
  load(): Promise<TranscriptSnapshot>;
50
+ loadIndex?(): Promise<ClaudeSessionIndex>;
49
51
  append(expectedTail: TranscriptTail, entry: ClaudeTranscriptEntry): Promise<TranscriptAppendResult>;
50
52
  appendMany(expectedTail: TranscriptTail, entries: readonly ClaudeTranscriptEntry[]): Promise<TranscriptAppendResult>;
53
+ appendMetadataSnapshot(expectedTail: TranscriptTail, entries: readonly ClaudeTranscriptEntry[]): Promise<TranscriptAppendResult>;
51
54
  }
52
55
  export type TranscriptLeaseResult<T> = {
53
56
  status: 'completed';
@@ -80,6 +83,7 @@ export declare class ClaudeTranscriptStore {
80
83
  append(expectedTail: TranscriptTail, entry: ClaudeTranscriptEntry): Promise<TranscriptAppendResult>;
81
84
  private appendUnderLease;
82
85
  private appendManyUnderLease;
86
+ private appendMetadataSnapshotUnderLease;
83
87
  private serializeNewSidechain;
84
88
  }
85
89
  //# sourceMappingURL=claude-transcript-store.d.ts.map
@@ -2,8 +2,11 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, open, readFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  import { TextDecoder } from 'node:util';
5
+ import { isClaudeDurableLastPromptSnapshot } from '../compatibility/claude/history.js';
6
+ import { isClaudeDurableMetadataType } from '../compatibility/claude/session-metadata.js';
5
7
  import { getClaudeContentBlocks, indexClaudeToolLinks, recoverClaudeToolResultLinks, } from '../compatibility/claude/tool-links.js';
6
8
  import { ExclusiveFileLease } from '../platform/exclusive-file-lease.js';
9
+ import { readClaudeSessionIndex, } from './claude-session-index.js';
7
10
  export class ClaudeTranscriptParseError extends Error {
8
11
  lineNumber;
9
12
  byteOffset;
@@ -19,6 +22,7 @@ const NON_TAIL_ENTRY_TYPES = new Set([
19
22
  'agent-color',
20
23
  'agent-name',
21
24
  'agent-setting',
25
+ 'ai-title',
22
26
  'custom-title',
23
27
  'file-history-delta',
24
28
  'file-history-snapshot',
@@ -26,6 +30,8 @@ const NON_TAIL_ENTRY_TYPES = new Set([
26
30
  'pr-link',
27
31
  'queue-operation',
28
32
  'relocated',
33
+ 'mode',
34
+ 'tag',
29
35
  'worktree-state',
30
36
  ]);
31
37
  export function classifyTranscriptAppend(written, previousByteLength, encodedLine) {
@@ -63,7 +69,9 @@ function tailsMatch(left, right) {
63
69
  left.newlineTerminated === right.newlineTerminated);
64
70
  }
65
71
  function tailLogicalUuid(tail) {
66
- return tail.branchParentUuid ?? tail.lastUuid;
72
+ return 'branchParentUuid' in tail
73
+ ? (tail.branchParentUuid ?? null)
74
+ : tail.lastUuid;
67
75
  }
68
76
  function getEntryUuid(entry) {
69
77
  if (typeof entry.uuid === 'string')
@@ -77,6 +85,10 @@ function findLogicalTailUuid(entries) {
77
85
  const entry = entries[index];
78
86
  if (!entry)
79
87
  continue;
88
+ if (entry.type === 'last-prompt' &&
89
+ isClaudeDurableLastPromptSnapshot(entries, index)) {
90
+ continue;
91
+ }
80
92
  const uuid = getEntryUuid(entry);
81
93
  if (uuid)
82
94
  return uuid;
@@ -173,7 +185,9 @@ export class ClaudeTranscriptStore {
173
185
  message: error instanceof Error ? error.message : String(error),
174
186
  };
175
187
  if (!recover) {
176
- throw new ClaudeTranscriptParseError(issue.lineNumber, issue.byteOffset, { cause: error });
188
+ throw new ClaudeTranscriptParseError(index + 1, byteOffset, {
189
+ cause: error,
190
+ });
177
191
  }
178
192
  break;
179
193
  }
@@ -266,8 +280,10 @@ export class ClaudeTranscriptStore {
266
280
  try {
267
281
  const value = await operation({
268
282
  load: () => this.load(),
283
+ loadIndex: () => readClaudeSessionIndex(this.sessionFile, this.schema),
269
284
  append: (expectedTail, entry) => this.appendUnderLease(expectedTail, entry),
270
285
  appendMany: (expectedTail, entries) => this.appendManyUnderLease(expectedTail, entries),
286
+ appendMetadataSnapshot: (expectedTail, entries) => this.appendMetadataSnapshotUnderLease(expectedTail, entries),
271
287
  });
272
288
  return { status: 'completed', value };
273
289
  }
@@ -314,7 +330,11 @@ export class ClaudeTranscriptStore {
314
330
  }
315
331
  }
316
332
  else if (entry.type === 'last-prompt') {
317
- if (entry.leafUuid !== logicalTailUuid) {
333
+ const isDurableSnapshot = history.some((candidate) => candidate.type === 'last-prompt' &&
334
+ candidate.sessionId === entry.sessionId &&
335
+ candidate.leafUuid === entry.leafUuid &&
336
+ candidate.lastPrompt === entry.lastPrompt);
337
+ if (entry.leafUuid !== logicalTailUuid && !isDurableSnapshot) {
318
338
  staleLastPromptLeaf = true;
319
339
  continue;
320
340
  }
@@ -378,6 +398,61 @@ export class ClaudeTranscriptStore {
378
398
  : { ...tail, branchParentUuid },
379
399
  };
380
400
  }
401
+ async appendMetadataSnapshotUnderLease(expectedTail, entries) {
402
+ if (entries.length === 0) {
403
+ throw new Error('Cannot append an empty Claude metadata snapshot');
404
+ }
405
+ if (entries.some((entry) => !isClaudeDurableMetadataType(entry.type))) {
406
+ throw new Error('Claude metadata snapshot contains a structural entry');
407
+ }
408
+ if (new Set(entries.map((entry) => entry.type)).size !== entries.length ||
409
+ new Set(entries.map((entry) => entry.sessionId)).size !== 1 ||
410
+ typeof entries[0]?.sessionId !== 'string') {
411
+ throw new Error('Claude metadata snapshot must contain one session record per type');
412
+ }
413
+ const current = await readClaudeSessionIndex(this.sessionFile, this.schema);
414
+ if (current.issue !== null) {
415
+ throw new Error('Cannot append metadata to a corrupt Claude transcript');
416
+ }
417
+ if (current.byteLength !== expectedTail.byteLength ||
418
+ current.tail.lastLineHash !== expectedTail.lastLineHash ||
419
+ current.newlineTerminated !== expectedTail.newlineTerminated) {
420
+ return { status: 'conflict', reason: 'tail-changed' };
421
+ }
422
+ if (!current.newlineTerminated) {
423
+ throw new Error('Claude transcript is not newline-terminated');
424
+ }
425
+ const lines = entries.map((entry) => this.schema.serializeForAppend(entry));
426
+ const encoded = Buffer.from(`${lines.join('\n')}\n`);
427
+ const sessionHandle = await open(this.sessionFile, 'a');
428
+ try {
429
+ const before = await sessionHandle.stat();
430
+ if (before.size !== expectedTail.byteLength) {
431
+ return { status: 'conflict', reason: 'tail-changed' };
432
+ }
433
+ await sessionHandle.writeFile(encoded);
434
+ await sessionHandle.sync();
435
+ const after = await sessionHandle.stat();
436
+ if (after.size !== expectedTail.byteLength + encoded.length) {
437
+ return { status: 'conflict', reason: 'interleaved-write' };
438
+ }
439
+ }
440
+ finally {
441
+ await sessionHandle.close();
442
+ }
443
+ const last = entries.at(-1);
444
+ return {
445
+ status: 'appended',
446
+ tail: {
447
+ byteLength: expectedTail.byteLength + encoded.length,
448
+ lastLineHash: hashLine(Buffer.from(lines.at(-1) ?? '')),
449
+ lastUuid: typeof last?.leafUuid === 'string'
450
+ ? last.leafUuid
451
+ : expectedTail.lastUuid,
452
+ newlineTerminated: true,
453
+ },
454
+ };
455
+ }
381
456
  serializeNewSidechain(entries) {
382
457
  const lines = [];
383
458
  const history = [];
@@ -47,6 +47,7 @@ export class InMemoryTranscriptStore {
47
47
  load: () => this.load(),
48
48
  append: (expectedTail, entry) => this.appendMany(expectedTail, [entry]),
49
49
  appendMany: (expectedTail, entries) => this.appendMany(expectedTail, entries),
50
+ appendMetadataSnapshot: (expectedTail, entries) => this.appendMany(expectedTail, entries),
50
51
  });
51
52
  return { status: 'completed', value };
52
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -80,6 +80,7 @@
80
80
  "test:pr-link-compat": "npm run build && node scripts/verify-pr-link-compatibility.mjs",
81
81
  "test:resume-at-compat": "npm run build && node scripts/verify-resume-session-at-compatibility.mjs",
82
82
  "test:resume-selector-compat": "npm run build && node scripts/verify-resume-selector-compatibility.mjs",
83
+ "test:session-metadata-compat": "npm run build && node scripts/verify-session-metadata-compatibility.mjs",
83
84
  "test:thinking-compat": "npm run build && node scripts/verify-thinking-controls.mjs",
84
85
  "test:memory-import-compat": "npm run build && node scripts/verify-memory-import-compatibility.mjs",
85
86
  "test:tui-compat": "npm run build && node scripts/verify-tui-compatibility.mjs",