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
@@ -58,6 +58,7 @@ interface InteractiveSessionCommands {
58
58
  workflows?(): readonly Record<string, unknown>[];
59
59
  taskSnapshots?(sessionId: string): Promise<BackgroundTaskSnapshot>;
60
60
  stopTask?(sessionId: string, taskId: string): Promise<void>;
61
+ backgroundForegroundTask?(sessionId: string): void | Promise<void>;
61
62
  mcpInspect?(): Promise<readonly ClaudeMcpServerStatus[]>;
62
63
  mcpReconnect?(name: string): Promise<void>;
63
64
  mcpAuthenticate?(name: string): Promise<void>;
@@ -2947,7 +2947,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2947
2947
  ? ['Confirmation']
2948
2948
  : selectingSession
2949
2949
  ? ['Select']
2950
- : ['Chat'];
2950
+ : busy
2951
+ ? ['Task', 'Chat']
2952
+ : ['Chat'];
2951
2953
  const inputChord = tuiKeyChord(value, key);
2952
2954
  const pendingSequence = keySequenceRef.current;
2953
2955
  let keybindingAction;
@@ -5140,6 +5142,29 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5140
5142
  return;
5141
5143
  }
5142
5144
  if (busy) {
5145
+ if (isKeybinding('task:background')) {
5146
+ const backgrounding = (async () => {
5147
+ try {
5148
+ const activeSessionId = sessionIdRef.current;
5149
+ if (!activeSessionId)
5150
+ throw new Error('No active session');
5151
+ const commands = await service();
5152
+ if (!commands.backgroundForegroundTask) {
5153
+ throw new Error('Foreground Agent backgrounding is unavailable.');
5154
+ }
5155
+ await commands.backgroundForegroundTask(activeSessionId);
5156
+ append({
5157
+ kind: 'notice',
5158
+ text: 'Agent moved to background · continuing this turn',
5159
+ });
5160
+ }
5161
+ catch (error) {
5162
+ warn(error);
5163
+ }
5164
+ })();
5165
+ void backgrounding;
5166
+ return;
5167
+ }
5143
5168
  if (isKeybinding('chat:cancel'))
5144
5169
  turnControllerRef.current?.abort();
5145
5170
  return;
@@ -2,7 +2,7 @@ import type { BackgroundAgentSnapshot } from '../../application/background-agent
2
2
  import type { BackgroundBashSnapshot } from '../../application/background-bash-manager.js';
3
3
  import type { WorkflowTaskSnapshot } from '../../application/workflow-manager.js';
4
4
  export type TuiTaskKind = 'shell' | 'agent' | 'workflow';
5
- export type TuiTaskStatus = 'running' | 'completed' | 'failed' | 'stopped';
5
+ export type TuiTaskStatus = 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
6
6
  export interface TuiTaskEntry {
7
7
  id: string;
8
8
  kind: TuiTaskKind;
@@ -21,7 +21,8 @@ function taskStatus(record) {
21
21
  if (status === 'running' ||
22
22
  status === 'completed' ||
23
23
  status === 'failed' ||
24
- status === 'stopped') {
24
+ status === 'stopped' ||
25
+ status === 'interrupted') {
25
26
  return status;
26
27
  }
27
28
  if (status === 'error')
@@ -57,6 +57,7 @@ interface SessionCommands {
57
57
  sessionId?: string;
58
58
  }): Promise<void>;
59
59
  sessions(): Promise<SessionSummary[]>;
60
+ registerResumePath?(path: string): Promise<SessionSummary>;
60
61
  inspect(sessionId: string): Promise<SessionInspection>;
61
62
  export(sessionId: string): Promise<Buffer>;
62
63
  transcript?(sessionId: string): Promise<ClaudeDisplayTranscriptItem[]>;
@@ -3,7 +3,7 @@ import { realpathSync } from 'node:fs';
3
3
  import { access, copyFile, mkdir, readFile } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, join, resolve } from 'node:path';
6
+ import { basename, dirname, extname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { ClaudeSessionService, } from './application/session-service.js';
@@ -882,6 +882,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
882
882
  claudeVersion,
883
883
  eventSink: runtimeEventSink,
884
884
  sessionPersistence: cli.sessionPersistence,
885
+ resumeInterruptedTurn: /^(?:1|true|yes|on)$/iu.test((runtimeEnvironment.CLAUDE_CODE_RESUME_INTERRUPTED_TURN ?? '').trim()),
885
886
  costStateStore,
886
887
  simpleMode,
887
888
  explicitModel: interactiveModel !== undefined || controls.model !== undefined,
@@ -1693,6 +1694,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1693
1694
  }
1694
1695
  },
1695
1696
  sessions: () => service.sessions(),
1697
+ registerResumePath: (path) => service.registerResumePath(path),
1696
1698
  costSnapshot: (sessionId) => service.costSnapshot(sessionId),
1697
1699
  slashCommands: () => extensions.slashCommandDefinitions().map((definition) => ({
1698
1700
  name: definition.name,
@@ -1998,22 +2000,40 @@ export function createDefaultDependencies(entrypoint = fileURLToPath(import.meta
1998
2000
  statePath: interactiveStatePath,
1999
2001
  });
2000
2002
  const effectiveModel = resolveRuntimeModel(interactiveControls.model, process.env, interactiveRuntimeSettings);
2003
+ const resumePath = typeof resume?.sessionSelector === 'string' &&
2004
+ isResumePathSelector(resume.sessionSelector)
2005
+ ? resume.sessionSelector
2006
+ : undefined;
2007
+ let continueSessionFilter;
2008
+ if (interactiveControls.continueSession &&
2009
+ resume?.fromPr === undefined &&
2010
+ resume?.sessionSelector === undefined) {
2011
+ continueSessionFilter = await createContinueSessionFilter(liveTopLevelSessions(dependencies, interactiveDataPlane));
2012
+ }
2013
+ const createInteractiveService = async (options) => {
2014
+ const commands = await createDefaultService({
2015
+ ...options,
2016
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
2017
+ sandboxOriginalCwd: process.cwd(),
2018
+ ...(agent === undefined ? {} : { agent }),
2019
+ controls: {
2020
+ ...interactiveControls,
2021
+ addDirectories: options.additionalDirectories ??
2022
+ interactiveControls.addDirectories,
2023
+ },
2024
+ interactive: true,
2025
+ });
2026
+ if (resumePath !== undefined) {
2027
+ await commands.registerResumePath?.(resumePath);
2028
+ }
2029
+ return commands;
2030
+ };
2001
2031
  return runInteractive({
2002
2032
  dataPlane: interactiveDataPlane,
2003
2033
  configRoot: interactiveConfigRoot,
2004
2034
  statePath: interactiveStatePath,
2005
2035
  factory: {
2006
- createService: ({ additionalDirectories, cwd, ...options }) => createDefaultService({
2007
- ...options,
2008
- ...(cwd === undefined ? {} : { cwd }),
2009
- sandboxOriginalCwd: process.cwd(),
2010
- ...(agent === undefined ? {} : { agent }),
2011
- controls: {
2012
- ...interactiveControls,
2013
- addDirectories: additionalDirectories ?? interactiveControls.addDirectories,
2014
- },
2015
- interactive: true,
2016
- }),
2036
+ createService: createInteractiveService,
2017
2037
  scheduledPrompts: Boolean(process.env.PRAXIS_API_KEY && effectiveModel),
2018
2038
  },
2019
2039
  ...(signal ? { signal } : {}),
@@ -2056,15 +2076,22 @@ export function createDefaultDependencies(entrypoint = fileURLToPath(import.meta
2056
2076
  sessionFilter: createClaudePrSessionFilter(resume.fromPr),
2057
2077
  requireSession: true,
2058
2078
  }
2059
- : resume?.sessionSelector === undefined
2060
- ? {}
2061
- : {
2062
- sessionFilter: createResumeSessionFilter(resume.sessionSelector),
2079
+ : resume?.sessionSelector !== undefined
2080
+ ? {
2081
+ sessionFilter: createResumeSessionFilter(resumePathSessionId(resume.sessionSelector) ??
2082
+ resume.sessionSelector),
2063
2083
  requireSession: true,
2064
2084
  missingSessionMessage: isClaudeSessionId(resume.sessionSelector)
2065
2085
  ? `No conversation found with session ID: ${resume.sessionSelector}`
2066
2086
  : `No conversation found matching: ${resume.sessionSelector}`,
2067
- }),
2087
+ }
2088
+ : continueSessionFilter === undefined
2089
+ ? {}
2090
+ : {
2091
+ sessionFilter: continueSessionFilter,
2092
+ requireSession: true,
2093
+ missingSessionMessage: 'No conversation found to continue',
2094
+ }),
2068
2095
  ...(resume?.requireSession ? { requireSession: true } : {}),
2069
2096
  });
2070
2097
  },
@@ -2116,7 +2143,7 @@ function writeJson(io, value) {
2116
2143
  }
2117
2144
  function formatSessionIssue(issue) {
2118
2145
  return issue
2119
- ? `line ${issue.lineNumber}, byte ${issue.byteOffset}: ${issue.message}`
2146
+ ? `${issue.lineNumber === null ? '' : `line ${issue.lineNumber}, `}byte ${issue.byteOffset}: ${issue.message}`
2120
2147
  : '';
2121
2148
  }
2122
2149
  function selectPrLinkedSession(sessions, selector) {
@@ -2132,14 +2159,45 @@ function selectPrLinkedSession(sessions, selector) {
2132
2159
  }
2133
2160
  return matches[0];
2134
2161
  }
2135
- function selectImplicitResumeSession(sessions, fromPr) {
2162
+ async function selectImplicitResumeSession(sessions, fromPr, liveSessions) {
2136
2163
  if (fromPr !== undefined)
2137
2164
  return selectPrLinkedSession(sessions, fromPr);
2138
- const latest = sessions[0];
2165
+ const filter = await createContinueSessionFilter(liveSessions);
2166
+ const eligible = filter === undefined ? sessions : sessions.filter(filter);
2167
+ const latest = eligible[0];
2139
2168
  if (!latest)
2140
2169
  throw new Error('No conversation found to continue');
2141
2170
  return latest;
2142
2171
  }
2172
+ async function createContinueSessionFilter(liveSessions) {
2173
+ if (liveSessions === undefined)
2174
+ return undefined;
2175
+ try {
2176
+ const liveBackgroundIds = new Set((await liveSessions())
2177
+ .filter((agent) => ['background', 'bg', 'daemon', 'daemon-worker'].includes(agent.kind))
2178
+ .filter((agent) => agent.state === 'working' || agent.status !== undefined)
2179
+ .map((agent) => agent.sessionId));
2180
+ return (session) => !liveBackgroundIds.has(session.sessionId);
2181
+ }
2182
+ catch {
2183
+ // Deterministic local ordering remains the fallback when the optional
2184
+ // liveness registry is unavailable or unreadable.
2185
+ return undefined;
2186
+ }
2187
+ }
2188
+ function liveTopLevelSessions(dependencies, dataPlane) {
2189
+ let manager;
2190
+ try {
2191
+ manager = dependencies.createTopLevelAgents?.(dataPlane);
2192
+ manager ??= dependencies.topLevelAgents;
2193
+ }
2194
+ catch {
2195
+ return undefined;
2196
+ }
2197
+ return manager === undefined
2198
+ ? undefined
2199
+ : () => manager.list({ cwd: process.cwd(), all: false });
2200
+ }
2143
2201
  function missingResumeSelectorMessage(invocation) {
2144
2202
  if (invocation.print) {
2145
2203
  return '--resume requires a valid session ID or session title when used with --print. Usage: praxis -p --resume <session-id|title>';
@@ -2167,6 +2225,24 @@ function selectResumeSession(sessions, selector, invocation) {
2167
2225
  }
2168
2226
  throw new Error(`${missingResumeSelectorMessage(invocation)}. Provided value "${selector}" is not a UUID and does not match any session title.`);
2169
2227
  }
2228
+ function isResumePathSelector(selector) {
2229
+ return extname(selector).toLowerCase() === '.jsonl';
2230
+ }
2231
+ function resumePathSessionId(selector) {
2232
+ if (!isResumePathSelector(selector))
2233
+ return undefined;
2234
+ const sessionId = basename(selector, extname(selector));
2235
+ return isClaudeSessionId(sessionId) ? sessionId : undefined;
2236
+ }
2237
+ async function resolveExplicitResumeSession(service, selector, invocation) {
2238
+ if (isResumePathSelector(selector)) {
2239
+ if (!service.registerResumePath) {
2240
+ throw new Error('Explicit Claude transcript path resume is unavailable');
2241
+ }
2242
+ return service.registerResumePath(selector);
2243
+ }
2244
+ return selectResumeSession(await service.sessions(), selector, invocation);
2245
+ }
2170
2246
  function createResumeSessionFilter(selector) {
2171
2247
  const normalized = selector.toLowerCase();
2172
2248
  if (isClaudeSessionId(selector)) {
@@ -4268,8 +4344,12 @@ async function execute(argv, io, dependencies, signal) {
4268
4344
  ...(typeof invocation.resumeSelector === 'string'
4269
4345
  ? {
4270
4346
  sessionSelector: invocation.resumeSelector,
4271
- ...(isClaudeSessionId(invocation.resumeSelector)
4272
- ? { sessionId: invocation.resumeSelector }
4347
+ ...(isClaudeSessionId(invocation.resumeSelector) ||
4348
+ resumePathSessionId(invocation.resumeSelector) !== undefined
4349
+ ? {
4350
+ sessionId: resumePathSessionId(invocation.resumeSelector) ??
4351
+ invocation.resumeSelector,
4352
+ }
4273
4353
  : {}),
4274
4354
  }
4275
4355
  : {}),
@@ -4480,12 +4560,12 @@ async function execute(argv, io, dependencies, signal) {
4480
4560
  controls: invocation,
4481
4561
  });
4482
4562
  try {
4483
- const sessions = await sessionService.sessions();
4484
4563
  if (explicitResumeSelector !== undefined) {
4485
- resumeSessionId = selectResumeSession(sessions, explicitResumeSelector, invocation).sessionId;
4564
+ resumeSessionId = (await resolveExplicitResumeSession(sessionService, explicitResumeSelector, invocation)).sessionId;
4486
4565
  }
4487
4566
  else {
4488
- resumeSessionId = selectImplicitResumeSession(sessions, invocation.fromPr).sessionId;
4567
+ const sessions = await sessionService.sessions();
4568
+ resumeSessionId = (await selectImplicitResumeSession(sessions, invocation.fromPr, liveTopLevelSessions(dependencies, invocation.dataPlane ?? resolveDataPlane()))).sessionId;
4489
4569
  }
4490
4570
  if (invocation.forkSession) {
4491
4571
  resumeSessionId = (await sessionService.fork(resumeSessionId, invocation.sessionId)).sessionId;
@@ -4872,12 +4952,12 @@ async function execute(argv, io, dependencies, signal) {
4872
4952
  if (explicitResumeSelector !== undefined ||
4873
4953
  invocation.continueSession ||
4874
4954
  invocation.fromPr !== undefined) {
4875
- const sessions = await service.sessions();
4876
- existingSessionId =
4877
- explicitResumeSelector === undefined
4878
- ? selectImplicitResumeSession(sessions, invocation.fromPr).sessionId
4879
- : selectResumeSession(sessions, explicitResumeSelector, invocation)
4880
- .sessionId;
4955
+ if (explicitResumeSelector === undefined) {
4956
+ existingSessionId = (await selectImplicitResumeSession(await service.sessions(), invocation.fromPr, liveTopLevelSessions(dependencies, dataPlane))).sessionId;
4957
+ }
4958
+ else {
4959
+ existingSessionId = (await resolveExplicitResumeSession(service, explicitResumeSelector, invocation)).sessionId;
4960
+ }
4881
4961
  }
4882
4962
  if (existingSessionId && invocation.forkSession) {
4883
4963
  existingSessionId = (await service.fork(existingSessionId, invocation.sessionId)).sessionId;
@@ -1,4 +1,12 @@
1
1
  import type { ClaudeTranscriptEntry } from './schema.js';
2
+ export declare function isClaudeDurableLastPromptSnapshot(entries: readonly ClaudeTranscriptEntry[], index: number): boolean;
2
3
  export declare function selectClaudeActiveTranscript(entries: readonly ClaudeTranscriptEntry[]): ClaudeTranscriptEntry[];
4
+ /** Validates and selects ancestry from the newest structural non-sidechain
5
+ * entry. Explicit path resume uses this instead of potentially stale
6
+ * last-prompt metadata. */
7
+ export declare function selectClaudeTranscriptFromNewestLeaf(entries: readonly ClaudeTranscriptEntry[]): {
8
+ entries: ClaudeTranscriptEntry[];
9
+ leafUuid: string;
10
+ };
3
11
  export declare function selectClaudeTranscriptAtMessage(entries: readonly ClaudeTranscriptEntry[], messageUuid: string): ClaudeTranscriptEntry[];
4
12
  //# sourceMappingURL=history.d.ts.map
@@ -1,7 +1,37 @@
1
1
  import { getClaudePreservedMessageUuids } from './compaction.js';
2
+ import { isClaudeDurableMetadataType } from './session-metadata.js';
2
3
  function entryUuid(entry) {
3
4
  return typeof entry.uuid === 'string' ? entry.uuid : null;
4
5
  }
6
+ const ADDITIONAL_NON_STRUCTURAL_ENTRY_TYPES = new Set([
7
+ 'queue-operation',
8
+ 'relocated',
9
+ ]);
10
+ function isNonStructuralEntryType(type) {
11
+ return (isClaudeDurableMetadataType(type) ||
12
+ ADDITIONAL_NON_STRUCTURAL_ENTRY_TYPES.has(type));
13
+ }
14
+ export function isClaudeDurableLastPromptSnapshot(entries, index) {
15
+ const entry = entries[index];
16
+ if (entry?.type !== 'last-prompt')
17
+ return false;
18
+ for (let prior = index - 1; prior >= 0; prior -= 1) {
19
+ const candidate = entries[prior];
20
+ if (candidate?.type !== 'last-prompt' ||
21
+ candidate.sessionId !== entry.sessionId ||
22
+ candidate.leafUuid !== entry.leafUuid ||
23
+ candidate.lastPrompt !== entry.lastPrompt) {
24
+ continue;
25
+ }
26
+ const structuralEntries = entries
27
+ .slice(prior + 1, index)
28
+ .filter((between) => between.isSidechain !== true &&
29
+ typeof between.uuid === 'string' &&
30
+ !isNonStructuralEntryType(between.type));
31
+ return structuralEntries.length > 0;
32
+ }
33
+ return false;
34
+ }
5
35
  function latestLeafUuid(entries) {
6
36
  for (let index = entries.length - 1; index >= 0; index -= 1) {
7
37
  const summary = entries[index];
@@ -29,6 +59,8 @@ function latestLeafUuid(entries) {
29
59
  if (!entry || entry.isSidechain === true)
30
60
  continue;
31
61
  if (entry.type === 'last-prompt' && typeof entry.leafUuid === 'string') {
62
+ if (isClaudeDurableLastPromptSnapshot(entries, index))
63
+ continue;
32
64
  return entry.leafUuid;
33
65
  }
34
66
  const uuid = entryUuid(entry);
@@ -155,6 +187,28 @@ export function selectClaudeActiveTranscript(entries) {
155
187
  }
156
188
  return expandPreservedMessages(entries, selectAncestry(entries, leafUuid));
157
189
  }
190
+ /** Validates and selects ancestry from the newest structural non-sidechain
191
+ * entry. Explicit path resume uses this instead of potentially stale
192
+ * last-prompt metadata. */
193
+ export function selectClaudeTranscriptFromNewestLeaf(entries) {
194
+ let leafUuid;
195
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
196
+ const entry = entries[index];
197
+ if (entry?.isSidechain !== true &&
198
+ !isNonStructuralEntryType(entry?.type ?? '') &&
199
+ typeof entry?.uuid === 'string') {
200
+ leafUuid = entry.uuid;
201
+ break;
202
+ }
203
+ }
204
+ if (leafUuid === undefined) {
205
+ throw new Error('Claude transcript has no resumable non-sidechain leaf');
206
+ }
207
+ return {
208
+ entries: expandPreservedMessages(entries, selectAncestry(entries, leafUuid)),
209
+ leafUuid,
210
+ };
211
+ }
158
212
  export function selectClaudeTranscriptAtMessage(entries, messageUuid) {
159
213
  const active = selectClaudeActiveTranscript(entries);
160
214
  const target = active.find((entry) => entry.uuid === messageUuid &&
@@ -0,0 +1,16 @@
1
+ import type { ClaudeTranscriptEntry } from './schema.js';
2
+ export type ClaudeInterruptionClassification = {
3
+ kind: 'complete';
4
+ } | {
5
+ kind: 'none';
6
+ } | {
7
+ kind: 'interrupted-prompt' | 'interrupted-turn';
8
+ prompt?: string;
9
+ replayEntries?: ClaudeTranscriptEntry[];
10
+ replayParentUuid?: string | null;
11
+ };
12
+ export declare const CLAUDE_INTERRUPTED_TURN_CONTINUATION = "Continue the interrupted turn using the available tool results and context.";
13
+ /** Classifies only model-visible active-tail state; passive metadata never
14
+ * creates a phantom continuation. */
15
+ export declare function classifyClaudeInterruption(source: readonly ClaudeTranscriptEntry[]): ClaudeInterruptionClassification;
16
+ //# sourceMappingURL=interruption.d.ts.map
@@ -0,0 +1,147 @@
1
+ import { selectClaudeActiveTranscript } from './history.js';
2
+ export const CLAUDE_INTERRUPTED_TURN_CONTINUATION = 'Continue the interrupted turn using the available tool results and context.';
3
+ const PASSIVE_METADATA_TYPES = new Set([
4
+ 'agent-color',
5
+ 'agent-name',
6
+ 'agent-setting',
7
+ 'ai-title',
8
+ 'custom-title',
9
+ 'file-history-delta',
10
+ 'file-history-snapshot',
11
+ 'last-prompt',
12
+ 'mode',
13
+ 'permission-mode',
14
+ 'pr-link',
15
+ 'queue-operation',
16
+ 'relocated',
17
+ 'tag',
18
+ 'worktree-state',
19
+ ]);
20
+ function isRecord(value) {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ }
23
+ function contentBlocks(entry) {
24
+ if (!isRecord(entry.message) || !Array.isArray(entry.message.content)) {
25
+ return [];
26
+ }
27
+ return entry.message.content.filter(isRecord);
28
+ }
29
+ function plainUserPrompt(entry) {
30
+ if (entry.type !== 'user' ||
31
+ entry.isMeta === true ||
32
+ entry.isCompactSummary === true ||
33
+ !isRecord(entry.message) ||
34
+ entry.message.role !== 'user') {
35
+ return undefined;
36
+ }
37
+ if (typeof entry.message.content === 'string') {
38
+ const prompt = entry.message.content.trim();
39
+ if (prompt.length === 0 ||
40
+ /^<(?:command-name|local-command-caveat|local-command-stdout)>/u.test(prompt)) {
41
+ return undefined;
42
+ }
43
+ return entry.message.content;
44
+ }
45
+ if (!Array.isArray(entry.message.content))
46
+ return undefined;
47
+ const blocks = entry.message.content.filter(isRecord);
48
+ if (blocks.some((block) => block.type === 'tool_result'))
49
+ return undefined;
50
+ const text = blocks
51
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
52
+ .map((block) => String(block.text))
53
+ .join('');
54
+ return text.trim().length === 0 ? undefined : text;
55
+ }
56
+ function continuationState(entries, throughIndex) {
57
+ const replayEntries = entries.slice(0, throughIndex + 1);
58
+ const replayParentUuid = [...replayEntries]
59
+ .reverse()
60
+ .find((entry) => typeof entry.uuid === 'string')?.uuid;
61
+ return {
62
+ prompt: CLAUDE_INTERRUPTED_TURN_CONTINUATION,
63
+ replayEntries,
64
+ replayParentUuid: typeof replayParentUuid === 'string' ? replayParentUuid : null,
65
+ };
66
+ }
67
+ function assistantHasVisibleText(entry) {
68
+ if (!isRecord(entry.message) || entry.message.role !== 'assistant') {
69
+ return false;
70
+ }
71
+ if (typeof entry.message.content === 'string') {
72
+ return entry.message.content.trim().length > 0;
73
+ }
74
+ return contentBlocks(entry).some((block) => block.type === 'text' &&
75
+ typeof block.text === 'string' &&
76
+ block.text.trim().length > 0);
77
+ }
78
+ function terminalToolResult(entry) {
79
+ if (entry.toolDenialKind !== undefined)
80
+ return true;
81
+ const blocks = contentBlocks(entry).filter((block) => block.type === 'tool_result');
82
+ return (blocks.length > 0 &&
83
+ blocks.every((block) => block.is_error === true &&
84
+ typeof block.content === 'string' &&
85
+ /(?:user (?:rejected|declined)|doesn't want to proceed|interrupted)/iu.test(block.content)));
86
+ }
87
+ /** Classifies only model-visible active-tail state; passive metadata never
88
+ * creates a phantom continuation. */
89
+ export function classifyClaudeInterruption(source) {
90
+ const entries = selectClaudeActiveTranscript(source);
91
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
92
+ const entry = entries[index];
93
+ if (!entry || entry.isSidechain === true)
94
+ continue;
95
+ if (PASSIVE_METADATA_TYPES.has(entry.type))
96
+ continue;
97
+ if (entry.type === 'assistant') {
98
+ if (entry.isApiErrorMessage === true ||
99
+ entry.synthetic === true ||
100
+ entry.error === true) {
101
+ return { kind: 'none' };
102
+ }
103
+ if (assistantHasVisibleText(entry))
104
+ return { kind: 'complete' };
105
+ // Thinking-only, whitespace, and unresolved tool-use-only assistants do
106
+ // not hide the user prompt that was interrupted before a valid answer.
107
+ continue;
108
+ }
109
+ if (entry.type === 'user') {
110
+ if (entry.isCompactSummary === true)
111
+ return { kind: 'none' };
112
+ const blocks = contentBlocks(entry);
113
+ if (blocks.some((block) => block.type === 'tool_result')) {
114
+ if (terminalToolResult(entry))
115
+ return { kind: 'none' };
116
+ return {
117
+ kind: 'interrupted-turn',
118
+ ...continuationState(entries, index),
119
+ };
120
+ }
121
+ const prompt = plainUserPrompt(entry);
122
+ if (prompt !== undefined) {
123
+ return {
124
+ kind: 'interrupted-prompt',
125
+ prompt,
126
+ replayEntries: entries.slice(0, index),
127
+ replayParentUuid: typeof entry.parentUuid === 'string' ? entry.parentUuid : null,
128
+ };
129
+ }
130
+ return { kind: 'none' };
131
+ }
132
+ if (entry.type === 'attachment') {
133
+ return {
134
+ kind: 'interrupted-turn',
135
+ ...continuationState(entries, index),
136
+ };
137
+ }
138
+ if (entry.type === 'system' &&
139
+ (entry.subtype === 'compact_boundary' ||
140
+ entry.subtype === 'api_error' ||
141
+ entry.subtype === 'local_command')) {
142
+ return { kind: 'none' };
143
+ }
144
+ }
145
+ return { kind: 'none' };
146
+ }
147
+ //# sourceMappingURL=interruption.js.map
@@ -34,6 +34,10 @@ export type ClaudeDisplayTranscriptItem = {
34
34
  export declare function projectClaudeDisplayTranscript(entries: readonly ClaudeTranscriptEntry[]): ClaudeDisplayTranscriptItem[];
35
35
  export declare function projectClaudeTextMessages(entries: readonly ClaudeTranscriptEntry[]): ClaudeTextMessage[];
36
36
  export declare function projectClaudeModelMessages(entries: readonly ClaudeTranscriptEntry[]): ModelMessage[];
37
+ /** Builds restart/SendMessage context without mutating the retained sidechain.
38
+ * Only complete tool call/result pairs survive. Ambiguous duplicate IDs fail
39
+ * locally because silently choosing one could resume the wrong operation. */
40
+ export declare function projectClaudeSidechainContinuationMessages(entries: readonly ClaudeTranscriptEntry[]): ModelMessage[];
37
41
  export declare function getClaudeLastPrompt(entries: readonly ClaudeTranscriptEntry[]): string | null;
38
42
  export declare function getClaudeAgentSetting(entries: readonly ClaudeTranscriptEntry[]): string | null;
39
43
  //# sourceMappingURL=projection.d.ts.map
@@ -432,6 +432,88 @@ export function projectClaudeModelMessages(entries) {
432
432
  }
433
433
  return messages;
434
434
  }
435
+ function sidechainToolResultReplacements(entries) {
436
+ const replacements = new Map();
437
+ for (const entry of entries) {
438
+ if (entry.type !== 'content-replacement' ||
439
+ !Array.isArray(entry.replacements)) {
440
+ continue;
441
+ }
442
+ for (const replacement of entry.replacements) {
443
+ if (isRecord(replacement) &&
444
+ replacement.kind === 'tool-result' &&
445
+ typeof replacement.toolUseId === 'string' &&
446
+ typeof replacement.replacement === 'string') {
447
+ replacements.set(replacement.toolUseId, replacement.replacement);
448
+ }
449
+ }
450
+ }
451
+ return replacements;
452
+ }
453
+ /** Builds restart/SendMessage context without mutating the retained sidechain.
454
+ * Only complete tool call/result pairs survive. Ambiguous duplicate IDs fail
455
+ * locally because silently choosing one could resume the wrong operation. */
456
+ export function projectClaudeSidechainContinuationMessages(entries) {
457
+ const messages = projectClaudeModelMessages(entries);
458
+ const callCounts = new Map();
459
+ const resultCounts = new Map();
460
+ for (const message of messages) {
461
+ if (message.role === 'assistant') {
462
+ for (const call of message.toolCalls ?? []) {
463
+ callCounts.set(call.id, (callCounts.get(call.id) ?? 0) + 1);
464
+ }
465
+ }
466
+ else if (message.role === 'tool') {
467
+ resultCounts.set(message.toolCallId, (resultCounts.get(message.toolCallId) ?? 0) + 1);
468
+ }
469
+ }
470
+ for (const [toolUseId, count] of callCounts) {
471
+ if (count > 1) {
472
+ throw new Error(`Claude sidechain continuation has duplicate tool ID ${toolUseId}`);
473
+ }
474
+ }
475
+ for (const [toolUseId, count] of resultCounts) {
476
+ if (count > 1) {
477
+ throw new Error(`Claude sidechain continuation has duplicate tool result ${toolUseId}`);
478
+ }
479
+ }
480
+ const pairedToolIds = new Set([...callCounts.keys()].filter((toolUseId) => resultCounts.get(toolUseId) === 1));
481
+ const replacements = sidechainToolResultReplacements(entries);
482
+ return messages.flatMap((message) => {
483
+ if (message.role === 'assistant') {
484
+ const toolCalls = (message.toolCalls ?? []).filter((call) => pairedToolIds.has(call.id));
485
+ if (message.content.trim().length === 0 && toolCalls.length === 0) {
486
+ return [];
487
+ }
488
+ return [
489
+ {
490
+ role: 'assistant',
491
+ content: message.content,
492
+ ...(message.thinkingBlocks
493
+ ? { thinkingBlocks: message.thinkingBlocks }
494
+ : {}),
495
+ ...(toolCalls.length === 0 ? {} : { toolCalls }),
496
+ },
497
+ ];
498
+ }
499
+ if (message.role === 'tool') {
500
+ if (!pairedToolIds.has(message.toolCallId))
501
+ return [];
502
+ const replacement = replacements.get(message.toolCallId);
503
+ return [
504
+ replacement === undefined
505
+ ? message
506
+ : {
507
+ role: 'tool',
508
+ toolCallId: message.toolCallId,
509
+ content: replacement,
510
+ isError: message.isError,
511
+ },
512
+ ];
513
+ }
514
+ return [message];
515
+ });
516
+ }
435
517
  export function getClaudeLastPrompt(entries) {
436
518
  for (let index = entries.length - 1; index >= 0; index -= 1) {
437
519
  const entry = entries[index];