praxis-agent 0.32.0 → 0.34.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 (36) hide show
  1. package/dist/application/background-agent-manager.d.ts +48 -3
  2. package/dist/application/background-agent-manager.js +249 -73
  3. package/dist/application/background-task-runtime.d.ts +20 -1
  4. package/dist/application/background-task-runtime.js +45 -6
  5. package/dist/application/managed-worktree.d.ts +8 -0
  6. package/dist/application/managed-worktree.js +54 -2
  7. package/dist/application/session-service.d.ts +38 -0
  8. package/dist/application/session-service.js +590 -70
  9. package/dist/application/subagent-service.d.ts +34 -7
  10. package/dist/application/subagent-service.js +694 -135
  11. package/dist/cli/interactive.d.ts +1 -0
  12. package/dist/cli/interactive.js +26 -1
  13. package/dist/cli/tui/task-panel.d.ts +1 -1
  14. package/dist/cli/tui/task-panel.js +2 -1
  15. package/dist/cli-runtime.d.ts +1 -0
  16. package/dist/cli-runtime.js +111 -31
  17. package/dist/compatibility/claude/history.d.ts +8 -0
  18. package/dist/compatibility/claude/history.js +54 -0
  19. package/dist/compatibility/claude/interruption.d.ts +16 -0
  20. package/dist/compatibility/claude/interruption.js +147 -0
  21. package/dist/compatibility/claude/projection.d.ts +4 -0
  22. package/dist/compatibility/claude/projection.js +82 -0
  23. package/dist/compatibility/claude/schema.js +22 -0
  24. package/dist/compatibility/claude/session-metadata.d.ts +44 -0
  25. package/dist/compatibility/claude/session-metadata.js +180 -0
  26. package/dist/compatibility/claude/sidechain.d.ts +5 -0
  27. package/dist/compatibility/claude/sidechain.js +13 -4
  28. package/dist/persistence/claude-session-index.d.ts +39 -0
  29. package/dist/persistence/claude-session-index.js +175 -0
  30. package/dist/persistence/claude-sidechain-store.js +19 -3
  31. package/dist/persistence/claude-transcript-store.d.ts +6 -2
  32. package/dist/persistence/claude-transcript-store.js +78 -3
  33. package/dist/persistence/in-memory-transcript-store.js +1 -0
  34. package/dist/persistence/subagent-lifecycle-store.d.ts +66 -0
  35. package/dist/persistence/subagent-lifecycle-store.js +299 -0
  36. package/package.json +2 -1
@@ -18,13 +18,16 @@ const APPENDABLE_ENTRY_TYPES = new Set([
18
18
  'assistant',
19
19
  'attachment',
20
20
  'custom-title',
21
+ 'ai-title',
21
22
  'file-history-delta',
22
23
  'file-history-snapshot',
23
24
  'last-prompt',
25
+ 'mode',
24
26
  'permission-mode',
25
27
  'pr-link',
26
28
  'queue-operation',
27
29
  'relocated',
30
+ 'tag',
28
31
  'system',
29
32
  'user',
30
33
  'worktree-state',
@@ -732,6 +735,25 @@ function validateAppendableEntry(entry) {
732
735
  }
733
736
  return;
734
737
  }
738
+ if (entry.type === 'ai-title') {
739
+ if (!isNonEmptyString(entry.aiTitle) ||
740
+ !isNonEmptyString(entry.sessionId)) {
741
+ throw new Error('Claude ai-title entry has invalid metadata');
742
+ }
743
+ return;
744
+ }
745
+ if (entry.type === 'tag') {
746
+ if (!isNonEmptyString(entry.tag) || !isNonEmptyString(entry.sessionId)) {
747
+ throw new Error('Claude tag entry has invalid metadata');
748
+ }
749
+ return;
750
+ }
751
+ if (entry.type === 'mode') {
752
+ if (!isNonEmptyString(entry.mode) || !isNonEmptyString(entry.sessionId)) {
753
+ throw new Error('Claude mode entry has invalid metadata');
754
+ }
755
+ return;
756
+ }
735
757
  if (entry.type === 'agent-color') {
736
758
  if (!isAgentColorValue(entry.agentColor) ||
737
759
  !isNonEmptyString(entry.sessionId)) {
@@ -0,0 +1,44 @@
1
+ import type { ClaudeTranscriptEntry } from './schema.js';
2
+ export interface ClaudeSessionPrLink {
3
+ prNumber: number;
4
+ prUrl?: string;
5
+ prRepository?: string;
6
+ }
7
+ export interface ClaudeSessionMetadata {
8
+ title?: string;
9
+ titleSource?: 'custom-title' | 'ai-title';
10
+ customTitle?: string;
11
+ aiTitle?: string;
12
+ tag?: string;
13
+ agentName?: string;
14
+ agentColor?: string;
15
+ agentSetting?: string;
16
+ permissionMode?: string;
17
+ mode?: string;
18
+ worktreeSession?: Readonly<Record<string, unknown>>;
19
+ prLink?: ClaudeSessionPrLink;
20
+ lastPrompt?: string;
21
+ lastPromptLeafUuid?: string;
22
+ }
23
+ export declare const CLAUDE_DURABLE_METADATA_TYPES: readonly ["custom-title", "ai-title", "tag", "agent-name", "agent-color", "agent-setting", "permission-mode", "mode", "worktree-state", "pr-link", "last-prompt"];
24
+ export type ClaudeDurableMetadataType = (typeof CLAUDE_DURABLE_METADATA_TYPES)[number];
25
+ export declare function isClaudeDurableMetadataType(type: string): type is ClaudeDurableMetadataType;
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 declare function reduceClaudeSessionMetadata(entries: readonly ClaudeTranscriptEntry[], sessionId?: string): ClaudeSessionMetadata;
35
+ /** Returns one current record per durable metadata type for a tail snapshot. */
36
+ export declare function createClaudeDurableMetadataSnapshot(entries: readonly ClaudeTranscriptEntry[], sessionId: string, options?: {
37
+ trustLastPromptReference?: boolean;
38
+ }): ClaudeTranscriptEntry[];
39
+ /** Keeps the last known full baseline while overlaying metadata visible in a
40
+ * newer bounded head/tail observation. Structural entries are included only
41
+ * to validate or safely advance the committed last-prompt hint. */
42
+ export declare function mergeClaudeDurableMetadataSnapshot(baseline: readonly ClaudeTranscriptEntry[], observed: readonly ClaudeTranscriptEntry[], sessionId: string): ClaudeTranscriptEntry[];
43
+ export declare function createClaudeTagEntry(sessionId: string, tag: string): ClaudeTranscriptEntry;
44
+ //# sourceMappingURL=session-metadata.d.ts.map
@@ -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
@@ -1,5 +1,7 @@
1
1
  import type { ModelUsage } from '../../core/runtime.js';
2
2
  import type { ClaudeTranscriptEntry } from './schema.js';
3
+ export declare function isClaudeAgentId(agentId: string): boolean;
4
+ export declare function assertClaudeAgentId(agentId: string): void;
3
5
  export interface ClaudeSidechainPaths {
4
6
  sessionId: string;
5
7
  agentId: string;
@@ -12,6 +14,7 @@ export interface ClaudeSidechainPathOptions {
12
14
  subdirectory?: string;
13
15
  }
14
16
  export interface ClaudeSidechainMetadata {
17
+ readonly [key: string]: unknown;
15
18
  agentType: string;
16
19
  description: string;
17
20
  toolUseId: string;
@@ -19,6 +22,8 @@ export interface ClaudeSidechainMetadata {
19
22
  name?: string;
20
23
  permissionMode?: ClaudeSidechainPermissionMode;
21
24
  isolation?: 'worktree';
25
+ parentAgentId?: string;
26
+ worktreePath?: string;
22
27
  }
23
28
  export type ClaudeSidechainPermissionMode = 'acceptEdits' | 'auto' | 'bypassPermissions' | 'default' | 'dontAsk' | 'plan';
24
29
  export declare function resolveClaudeSidechainPaths(projectRoot: string, sessionId: string, agentId: string, options?: ClaudeSidechainPathOptions): ClaudeSidechainPaths;
@@ -1,13 +1,22 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { isClaudeSessionId } from './paths.js';
3
- const AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/u;
3
+ /** Claude uses a stable 16-hex suffix and may prefix it with a bounded label.
4
+ * Keeping the accepted alphabet filename-safe makes path construction a pure
5
+ * validation boundary rather than a sanitizing rewrite. */
6
+ const CLAUDE_AGENT_ID_PATTERN = /^a(?:[A-Za-z0-9][A-Za-z0-9_-]{0,62}-)?[0-9a-f]{16}$/u;
7
+ export function isClaudeAgentId(agentId) {
8
+ return CLAUDE_AGENT_ID_PATTERN.test(agentId);
9
+ }
10
+ export function assertClaudeAgentId(agentId) {
11
+ if (!isClaudeAgentId(agentId)) {
12
+ throw new Error(`Invalid Claude agent ID: ${agentId}`);
13
+ }
14
+ }
4
15
  export function resolveClaudeSidechainPaths(projectRoot, sessionId, agentId, options = {}) {
5
16
  if (!isClaudeSessionId(sessionId)) {
6
17
  throw new Error(`Invalid Claude session ID: ${sessionId}`);
7
18
  }
8
- if (!AGENT_ID_PATTERN.test(agentId)) {
9
- throw new Error(`Invalid Claude agent ID: ${agentId}`);
10
- }
19
+ assertClaudeAgentId(agentId);
11
20
  const subdirectory = options.subdirectory;
12
21
  if (subdirectory !== undefined &&
13
22
  (subdirectory.length === 0 ||
@@ -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,4 +1,5 @@
1
1
  import { mkdir, open, readFile, rm } from 'node:fs/promises';
2
+ import { isClaudeAgentId } from '../compatibility/claude/sidechain.js';
2
3
  import { ClaudeTranscriptStore, } from './claude-transcript-store.js';
3
4
  export class ClaudeSidechainStore {
4
5
  paths;
@@ -72,7 +73,8 @@ export class ClaudeSidechainStore {
72
73
  if (typeof record.agentType !== 'string' ||
73
74
  typeof record.description !== 'string' ||
74
75
  typeof record.toolUseId !== 'string' ||
75
- typeof record.spawnDepth !== 'number' ||
76
+ !Number.isSafeInteger(record.spawnDepth) ||
77
+ Number(record.spawnDepth) < 1 ||
76
78
  (record.name !== undefined && typeof record.name !== 'string') ||
77
79
  (record.permissionMode !== undefined &&
78
80
  (typeof record.permissionMode !== 'string' ||
@@ -84,14 +86,22 @@ export class ClaudeSidechainStore {
84
86
  'dontAsk',
85
87
  'plan',
86
88
  ].includes(record.permissionMode))) ||
87
- (record.isolation !== undefined && record.isolation !== 'worktree')) {
89
+ (record.isolation !== undefined && record.isolation !== 'worktree') ||
90
+ (record.parentAgentId !== undefined &&
91
+ (typeof record.parentAgentId !== 'string' ||
92
+ !isClaudeAgentId(record.parentAgentId))) ||
93
+ (record.worktreePath !== undefined &&
94
+ (typeof record.worktreePath !== 'string' ||
95
+ record.worktreePath.length === 0 ||
96
+ record.worktreePath.includes('\0')))) {
88
97
  throw new Error('Claude sidechain metadata is invalid');
89
98
  }
90
99
  return {
100
+ ...record,
91
101
  agentType: record.agentType,
92
102
  description: record.description,
93
103
  toolUseId: record.toolUseId,
94
- spawnDepth: record.spawnDepth,
104
+ spawnDepth: Number(record.spawnDepth),
95
105
  ...(record.name === undefined ? {} : { name: record.name }),
96
106
  ...(record.permissionMode === undefined
97
107
  ? {}
@@ -101,6 +111,12 @@ export class ClaudeSidechainStore {
101
111
  ...(record.isolation === undefined
102
112
  ? {}
103
113
  : { isolation: record.isolation }),
114
+ ...(record.parentAgentId === undefined
115
+ ? {}
116
+ : { parentAgentId: record.parentAgentId }),
117
+ ...(record.worktreePath === undefined
118
+ ? {}
119
+ : { worktreePath: record.worktreePath }),
104
120
  };
105
121
  }
106
122
  }
@@ -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