praxis-agent 0.20.16 → 0.20.20
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 +3 -1
- package/dist/application/scheduled-prompt-manager.d.ts +4 -0
- package/dist/application/scheduled-prompt-manager.js +31 -2
- package/dist/application/session-service.d.ts +6 -1
- package/dist/application/session-service.js +97 -13
- package/dist/cli/interactive.js +7 -3
- package/dist/cli/tui/claude-style.d.ts +1 -0
- package/dist/cli/tui/claude-style.js +69 -8
- package/dist/cli/tui/status-line.d.ts +3 -1
- package/dist/cli/tui/status-line.js +15 -5
- package/dist/compatibility/claude/paths.d.ts +15 -0
- package/dist/compatibility/claude/paths.js +101 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,7 +88,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
|
|
|
88
88
|
|
|
89
89
|
## What Praxis provides
|
|
90
90
|
|
|
91
|
-
- **Local agent runtime** — Claude-style responsive TUI with a
|
|
91
|
+
- **Local agent runtime** — Claude-style responsive TUI with a fixed fullscreen
|
|
92
|
+
viewport, non-shrinking composer/status area, and complete bounded welcome
|
|
93
|
+
surface, plus a shared-command
|
|
92
94
|
slash palette, tabbed help and shortcut surfaces, searchable resume picker,
|
|
93
95
|
restored active-branch conversation history, streaming and expandable
|
|
94
96
|
thinking, grouped multi-file reads, globally expandable tool results,
|
|
@@ -38,6 +38,7 @@ export declare class ScheduledPromptManager {
|
|
|
38
38
|
private readonly sessionTasks;
|
|
39
39
|
private readonly dueAt;
|
|
40
40
|
private readonly dueQueue;
|
|
41
|
+
private readonly pendingConfirmations;
|
|
41
42
|
private readonly dynamicWakeups;
|
|
42
43
|
private readonly dynamicLoopStates;
|
|
43
44
|
private readonly durableIds;
|
|
@@ -54,6 +55,9 @@ export declare class ScheduledPromptManager {
|
|
|
54
55
|
create(input: CreateScheduledPromptInput): Promise<ListedScheduledPrompt>;
|
|
55
56
|
list(): Promise<ListedScheduledPrompt[]>;
|
|
56
57
|
delete(id: string): Promise<boolean>;
|
|
58
|
+
pendingScheduledPrompts(): ScheduledPrompt[];
|
|
59
|
+
approveScheduledPrompt(id: string): boolean;
|
|
60
|
+
declineScheduledPrompt(id: string): boolean;
|
|
57
61
|
scheduleWakeup(input: {
|
|
58
62
|
delaySeconds: number;
|
|
59
63
|
prompt: string;
|
|
@@ -97,6 +97,7 @@ export class ScheduledPromptManager {
|
|
|
97
97
|
sessionTasks = new Map();
|
|
98
98
|
dueAt = new Map();
|
|
99
99
|
dueQueue = [];
|
|
100
|
+
pendingConfirmations = new Map();
|
|
100
101
|
dynamicWakeups = new Map();
|
|
101
102
|
dynamicLoopStates = new Map();
|
|
102
103
|
durableIds = new Set();
|
|
@@ -181,10 +182,29 @@ export class ScheduledPromptManager {
|
|
|
181
182
|
async delete(id) {
|
|
182
183
|
await this.initialize();
|
|
183
184
|
const removed = await this.removeTask(id);
|
|
184
|
-
|
|
185
|
+
const declined = this.pendingConfirmations.delete(id);
|
|
186
|
+
if (removed || declined) {
|
|
185
187
|
this.dueAt.delete(id);
|
|
186
188
|
this.notifyChange();
|
|
187
189
|
}
|
|
190
|
+
return removed || declined;
|
|
191
|
+
}
|
|
192
|
+
pendingScheduledPrompts() {
|
|
193
|
+
return [...this.pendingConfirmations.values()];
|
|
194
|
+
}
|
|
195
|
+
approveScheduledPrompt(id) {
|
|
196
|
+
const pending = this.pendingConfirmations.get(id);
|
|
197
|
+
if (!pending)
|
|
198
|
+
return false;
|
|
199
|
+
this.pendingConfirmations.delete(id);
|
|
200
|
+
this.dueQueue.push(pending);
|
|
201
|
+
this.notifyChange();
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
declineScheduledPrompt(id) {
|
|
205
|
+
const removed = this.pendingConfirmations.delete(id);
|
|
206
|
+
if (removed)
|
|
207
|
+
this.notifyChange();
|
|
188
208
|
return removed;
|
|
189
209
|
}
|
|
190
210
|
scheduleWakeup(input) {
|
|
@@ -278,6 +298,7 @@ export class ScheduledPromptManager {
|
|
|
278
298
|
this.sessionTasks.clear();
|
|
279
299
|
this.dueAt.clear();
|
|
280
300
|
this.durableIds.clear();
|
|
301
|
+
this.pendingConfirmations.clear();
|
|
281
302
|
this.dueQueue.length = 0;
|
|
282
303
|
this.notifyChange();
|
|
283
304
|
}
|
|
@@ -316,7 +337,15 @@ export class ScheduledPromptManager {
|
|
|
316
337
|
if (this.closed)
|
|
317
338
|
return;
|
|
318
339
|
this.durableIds.delete(task.id);
|
|
319
|
-
|
|
340
|
+
if (task.recurring) {
|
|
341
|
+
this.dueQueue.push({ id: task.id, prompt: task.prompt });
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
this.pendingConfirmations.set(task.id, {
|
|
345
|
+
id: task.id,
|
|
346
|
+
prompt: task.prompt,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
320
349
|
}
|
|
321
350
|
}
|
|
322
351
|
}
|
|
@@ -13,6 +13,7 @@ import type { ClaudeHookRunner } from '../hooks/claude-hooks.js';
|
|
|
13
13
|
import { type TranscriptParseIssue } from '../persistence/claude-transcript-store.js';
|
|
14
14
|
import type { ClaudeCostStateStore } from '../persistence/claude-cost-state-store.js';
|
|
15
15
|
import { type AgentPermissionMode } from './subagent-service.js';
|
|
16
|
+
import { type ScheduledPrompt } from './scheduled-prompt-manager.js';
|
|
16
17
|
import { type WorkflowTaskSnapshot } from './workflow-manager.js';
|
|
17
18
|
import type { WorkspaceContext } from './session-worktree.js';
|
|
18
19
|
import { type ClaudeSessionCostSnapshot } from './session-cost-tracker.js';
|
|
@@ -157,6 +158,7 @@ export declare class ClaudeSessionService {
|
|
|
157
158
|
private readonly backgroundTasks;
|
|
158
159
|
private readonly worktreeManager;
|
|
159
160
|
private readonly sessionCwds;
|
|
161
|
+
private readonly discoveredProjectRoots;
|
|
160
162
|
private readonly sessionPermissionUpdates;
|
|
161
163
|
private readonly hostedSubagents;
|
|
162
164
|
private readonly hostedSubagentsByRegistry;
|
|
@@ -169,7 +171,9 @@ export declare class ClaudeSessionService {
|
|
|
169
171
|
private closeCostSavePromise;
|
|
170
172
|
private runtimeCwd;
|
|
171
173
|
constructor(options: ClaudeSessionServiceOptions);
|
|
172
|
-
nextScheduledPrompt(signal?: AbortSignal): Promise<
|
|
174
|
+
nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
|
|
175
|
+
private nextScheduledPromptForManager;
|
|
176
|
+
private confirmPendingScheduledPrompt;
|
|
173
177
|
workflows(): readonly WorkflowTaskSnapshot[];
|
|
174
178
|
mcpInspect(): Promise<readonly ClaudeMcpServerStatus[]>;
|
|
175
179
|
mcpReconnect(name: string): Promise<void>;
|
|
@@ -231,6 +235,7 @@ export declare class ClaudeSessionService {
|
|
|
231
235
|
private activeCwd;
|
|
232
236
|
private restoreWorktree;
|
|
233
237
|
private paths;
|
|
238
|
+
private discoverProjectRoot;
|
|
234
239
|
private appendCdCommand;
|
|
235
240
|
private appendAgentColorUsage;
|
|
236
241
|
private appendSystemLocalCommand;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { appendFile, copyFile, link, lstat, mkdir, readdir, realpath, stat, unlink, } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { basename, extname, isAbsolute, join, relative } from 'node:path';
|
|
4
|
+
import { basename, extname, isAbsolute, join, relative, resolve, } from 'node:path';
|
|
5
5
|
import { AGENT_COLOR_DEFAULT, agentColorMessage, getClaudeEffectiveAgentColor, } from '../compatibility/claude/agent-color.js';
|
|
6
6
|
import { createClaudeCompactEntries, formatClaudeCompactSummary, getCumulativeDroppedTokens, } from '../compatibility/claude/compaction.js';
|
|
7
|
-
import { isClaudeSessionId, resolveClaudePaths, resolveClaudeScheduledTaskFile, } from '../compatibility/claude/paths.js';
|
|
7
|
+
import { discoverClaudeProjectRoot, isClaudeSessionId, resolveClaudePaths, resolveClaudeScheduledTaskFile, } from '../compatibility/claude/paths.js';
|
|
8
8
|
import { downloadClaudeFileResources, } from '../compatibility/claude/file-resources.js';
|
|
9
9
|
import { createClaudeNativeFork } from '../compatibility/claude/fork.js';
|
|
10
10
|
import { selectClaudeActiveTranscript, selectClaudeTranscriptAtMessage, } from '../compatibility/claude/history.js';
|
|
@@ -24,7 +24,7 @@ import { ClaudeTranscriptStore, } from '../persistence/claude-transcript-store.j
|
|
|
24
24
|
import { InMemoryTranscriptStore } from '../persistence/in-memory-transcript-store.js';
|
|
25
25
|
import { ModelCompactor } from './model-compactor.js';
|
|
26
26
|
import { agentMemoryPrompt, ClaudeSubagentExecutor, StructuredOutputRegistry, } from './subagent-service.js';
|
|
27
|
-
import { ScheduledPromptManager } from './scheduled-prompt-manager.js';
|
|
27
|
+
import { ScheduledPromptManager, } from './scheduled-prompt-manager.js';
|
|
28
28
|
import { ClaudeScheduledToolRegistry } from '../tools/claude-scheduled-tools.js';
|
|
29
29
|
import { ClaudeTaskToolRegistry } from '../tools/claude-task-tools.js';
|
|
30
30
|
import { ClaudeWorkflowToolRegistry } from '../tools/claude-workflow-tools.js';
|
|
@@ -312,6 +312,7 @@ export class ClaudeSessionService {
|
|
|
312
312
|
backgroundTasks;
|
|
313
313
|
worktreeManager;
|
|
314
314
|
sessionCwds = new Map();
|
|
315
|
+
discoveredProjectRoots = new Map();
|
|
315
316
|
sessionPermissionUpdates = new Map();
|
|
316
317
|
hostedSubagents = new Set();
|
|
317
318
|
hostedSubagentsByRegistry = new WeakMap();
|
|
@@ -359,7 +360,53 @@ export class ClaudeSessionService {
|
|
|
359
360
|
}
|
|
360
361
|
}
|
|
361
362
|
nextScheduledPrompt(signal) {
|
|
362
|
-
|
|
363
|
+
const manager = this.scheduledPrompts;
|
|
364
|
+
if (!manager)
|
|
365
|
+
return Promise.resolve(null);
|
|
366
|
+
return this.nextScheduledPromptForManager(manager, signal);
|
|
367
|
+
}
|
|
368
|
+
async nextScheduledPromptForManager(manager, signal) {
|
|
369
|
+
// Scan durable tasks so a missed one-shot surfaces as a pending
|
|
370
|
+
// confirmation instead of silently entering the normal due drain.
|
|
371
|
+
await manager.list();
|
|
372
|
+
const pending = manager.pendingScheduledPrompts()[0];
|
|
373
|
+
if (!pending)
|
|
374
|
+
return manager.next(signal);
|
|
375
|
+
const askUser = this.options.interactiveTools?.callbacks.askUser;
|
|
376
|
+
if (!askUser)
|
|
377
|
+
return manager.next(signal);
|
|
378
|
+
const approved = await this.confirmPendingScheduledPrompt(manager, pending, askUser, signal);
|
|
379
|
+
if (!approved)
|
|
380
|
+
return null;
|
|
381
|
+
// Consume the approved prompt from the scheduler due queue exactly once.
|
|
382
|
+
return manager.next(signal);
|
|
383
|
+
}
|
|
384
|
+
async confirmPendingScheduledPrompt(manager, pending, askUser, signal) {
|
|
385
|
+
const question = {
|
|
386
|
+
header: 'Missed scheduled prompt',
|
|
387
|
+
question: pending.prompt,
|
|
388
|
+
options: [
|
|
389
|
+
{
|
|
390
|
+
label: 'Run now',
|
|
391
|
+
description: 'Run this scheduled prompt that was missed while Praxis was not running.',
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
label: 'Skip',
|
|
395
|
+
description: 'Decline this scheduled prompt and discard it.',
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
multiSelect: false,
|
|
399
|
+
};
|
|
400
|
+
const result = await askUser([question], signal);
|
|
401
|
+
const decision = result?.answers[question.question];
|
|
402
|
+
if (decision === 'Run now') {
|
|
403
|
+
return manager.approveScheduledPrompt(pending.id);
|
|
404
|
+
}
|
|
405
|
+
if (decision === 'Skip') {
|
|
406
|
+
manager.declineScheduledPrompt(pending.id);
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
363
410
|
}
|
|
364
411
|
workflows() {
|
|
365
412
|
return this.workflowManager?.list() ?? [];
|
|
@@ -817,23 +864,31 @@ export class ClaudeSessionService {
|
|
|
817
864
|
return validSessionName(metrics.text);
|
|
818
865
|
}
|
|
819
866
|
async sessions() {
|
|
820
|
-
const
|
|
867
|
+
const discoveredRoot = await discoverClaudeProjectRoot({
|
|
868
|
+
configRoot: this.options.configRoot,
|
|
869
|
+
cwd: this.activeCwd(),
|
|
870
|
+
});
|
|
871
|
+
const projectRoot = discoveredRoot ?? this.paths(randomUUID()).projectRoot;
|
|
821
872
|
let names;
|
|
822
873
|
try {
|
|
823
|
-
names = await readdir(
|
|
874
|
+
names = await readdir(projectRoot);
|
|
824
875
|
}
|
|
825
876
|
catch (error) {
|
|
826
877
|
if (error.code === 'ENOENT')
|
|
827
878
|
return [];
|
|
828
879
|
throw error;
|
|
829
880
|
}
|
|
830
|
-
const
|
|
881
|
+
const sessionIds = names
|
|
831
882
|
.filter((name) => extname(name) === '.jsonl')
|
|
832
|
-
.map(
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
883
|
+
.map((name) => basename(name, '.jsonl'))
|
|
884
|
+
.filter((sessionId) => isClaudeSessionId(sessionId));
|
|
885
|
+
if (discoveredRoot !== undefined) {
|
|
886
|
+
for (const sessionId of sessionIds) {
|
|
887
|
+
this.discoveredProjectRoots.set(sessionId, projectRoot);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const summaries = await Promise.all(sessionIds.map(async (sessionId) => {
|
|
891
|
+
const sessionFile = join(projectRoot, `${sessionId}.jsonl`);
|
|
837
892
|
try {
|
|
838
893
|
const metadata = await lstat(sessionFile);
|
|
839
894
|
if (!metadata.isFile())
|
|
@@ -872,6 +927,7 @@ export class ClaudeSessionService {
|
|
|
872
927
|
}
|
|
873
928
|
async inspect(sessionId) {
|
|
874
929
|
this.assertSessionPersistence();
|
|
930
|
+
await this.discoverProjectRoot(sessionId);
|
|
875
931
|
const paths = this.paths(sessionId);
|
|
876
932
|
let metadata;
|
|
877
933
|
try {
|
|
@@ -912,6 +968,7 @@ export class ClaudeSessionService {
|
|
|
912
968
|
}
|
|
913
969
|
async export(sessionId) {
|
|
914
970
|
this.assertSessionPersistence();
|
|
971
|
+
await this.discoverProjectRoot(sessionId);
|
|
915
972
|
try {
|
|
916
973
|
return await this.store(sessionId).exportReadOnly();
|
|
917
974
|
}
|
|
@@ -923,6 +980,7 @@ export class ClaudeSessionService {
|
|
|
923
980
|
}
|
|
924
981
|
}
|
|
925
982
|
async transcript(sessionId, resumeSessionAt) {
|
|
983
|
+
await this.discoverProjectRoot(sessionId);
|
|
926
984
|
try {
|
|
927
985
|
const recovery = await this.store(sessionId).loadReadOnly();
|
|
928
986
|
if (recovery.entries.length === 0) {
|
|
@@ -1048,6 +1106,9 @@ export class ClaudeSessionService {
|
|
|
1048
1106
|
if (relocated.status === 'conflict') {
|
|
1049
1107
|
throw new Error(`Claude transcript relocation conflict: ${relocated.reason}`);
|
|
1050
1108
|
}
|
|
1109
|
+
// The transcript moved to the exact root for the new cwd; any
|
|
1110
|
+
// previously discovered alternate-hash root is now stale.
|
|
1111
|
+
this.discoveredProjectRoots.delete(sessionId);
|
|
1051
1112
|
}
|
|
1052
1113
|
else {
|
|
1053
1114
|
await this.appendCdCommand(sessionId, cwd);
|
|
@@ -1664,6 +1725,9 @@ export class ClaudeSessionService {
|
|
|
1664
1725
|
this.options.workspace?.setCwd(pinnedCwd);
|
|
1665
1726
|
}
|
|
1666
1727
|
this.runtimeCwd = pinnedCwd;
|
|
1728
|
+
if (requireExisting) {
|
|
1729
|
+
await this.discoverProjectRoot(sessionId);
|
|
1730
|
+
}
|
|
1667
1731
|
const sessionPaths = this.paths(sessionId);
|
|
1668
1732
|
const toolResultDirectory = join(sessionPaths.projectRoot, sessionId, 'tool-results');
|
|
1669
1733
|
const store = this.turnStore(sessionId);
|
|
@@ -3091,11 +3155,31 @@ export class ClaudeSessionService {
|
|
|
3091
3155
|
this.worktreeManager.restore(state);
|
|
3092
3156
|
}
|
|
3093
3157
|
paths(sessionId) {
|
|
3094
|
-
|
|
3158
|
+
const exact = resolveClaudePaths({
|
|
3095
3159
|
configDir: this.options.configRoot,
|
|
3096
3160
|
cwd: this.sessionCwds.get(sessionId) ?? this.activeCwd(),
|
|
3097
3161
|
sessionId,
|
|
3098
3162
|
});
|
|
3163
|
+
const discovered = this.discoveredProjectRoots.get(sessionId);
|
|
3164
|
+
if (discovered === undefined)
|
|
3165
|
+
return exact;
|
|
3166
|
+
return {
|
|
3167
|
+
...exact,
|
|
3168
|
+
projectRoot: discovered,
|
|
3169
|
+
sessionFile: resolve(discovered, `${sessionId}.jsonl`),
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
async discoverProjectRoot(sessionId) {
|
|
3173
|
+
if (this.discoveredProjectRoots.has(sessionId))
|
|
3174
|
+
return;
|
|
3175
|
+
const discovered = await discoverClaudeProjectRoot({
|
|
3176
|
+
configRoot: this.options.configRoot,
|
|
3177
|
+
cwd: this.sessionCwds.get(sessionId) ?? this.activeCwd(),
|
|
3178
|
+
sessionId,
|
|
3179
|
+
});
|
|
3180
|
+
if (discovered !== undefined) {
|
|
3181
|
+
this.discoveredProjectRoots.set(sessionId, discovered);
|
|
3182
|
+
}
|
|
3099
3183
|
}
|
|
3100
3184
|
async appendCdCommand(sessionId, cwd) {
|
|
3101
3185
|
const result = await this.store(sessionId).withLease(async (lease) => {
|
package/dist/cli/interactive.js
CHANGED
|
@@ -11,7 +11,7 @@ import { permissionRuleValueToString } from '../permissions/permission-updates.j
|
|
|
11
11
|
import { AgentRunCancelledError } from '../core/runtime.js';
|
|
12
12
|
import { claudePermissionActionKey, claudePermissionRuleMatches, } from '../permissions/claude-permission-resolver.js';
|
|
13
13
|
import { redactSensitiveText, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
|
|
14
|
-
import { CommandPalette, BtwPanel, Composer, DiffDashboard, DialogFrame, ExternalEditorWait, HelpMenu, HookDashboard, ListDashboard, MemoryDashboard, MentionPicker, ModelMenu, PermissionDashboard, SelectionMenu, SessionPicker, ThemePicker, CustomThemeEditor, Transcript, WelcomePanel, useTerminalWidth, } from './tui/claude-style.js';
|
|
14
|
+
import { CommandPalette, BtwPanel, Composer, DiffDashboard, DialogFrame, ExternalEditorWait, HelpMenu, HookDashboard, ListDashboard, MemoryDashboard, MentionPicker, ModelMenu, PermissionDashboard, SelectionMenu, SessionPicker, ThemePicker, CustomThemeEditor, Transcript, WelcomePanel, useTerminalRows, useTerminalWidth, } from './tui/claude-style.js';
|
|
15
15
|
import { loadTuiMemoryFiles, openTuiMemoryFolder, } from './tui/memory-files.js';
|
|
16
16
|
import { loadClaudeReleaseNotes } from './tui/release-notes.js';
|
|
17
17
|
import { createClaudeStatusLineInput, StatusLine } from './tui/status-line.js';
|
|
@@ -264,6 +264,7 @@ const HIDDEN_TUI_SLASH_COMMANDS = new Set([
|
|
|
264
264
|
export function InteractiveApp({ factory, initialSessions, initialPrompt, initialHistory = [], initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader = false, allowNewSession = true, resume, display = { version: 'dev', cwd: process.cwd() }, terminalWidth, slashCommands = EMPTY_SLASH_COMMANDS, agents = EMPTY_AGENTS, allowDangerouslySkipPermissions = false, additionalDirectories = [], diffLoader, doctorLoader, fileLoader, externalEditor = editTuiPrompt, keybindingsConfigRoot, keybindingsFile = ensureTuiKeybindingsFile, keybindingsLoader = loadTuiKeybindings, keybindingsEditor = openTuiEditorFile, memoryFilesLoader = (configRoot, cwd) => loadTuiMemoryFiles({ configRoot, cwd }), memoryEditor = openTuiEditorFile, memoryFolderOpener = openTuiMemoryFolder, suspendProcess = suspendTuiProcess, clipboardReader = readTuiClipboard, clipboardWriter = writeTuiClipboard, sideQuestionClipboardWriter = writeTuiOsc52Clipboard, exportWriter = writeConversationExport, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver = resolveTuiWorkspaceDirectory, workspaceDirectoryCompleter = completeTuiWorkspaceDirectory, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, elicitationUrlOpener = openTuiUrl, releaseNotesLoader = (configRoot) => loadClaudeReleaseNotes({ configRoot }), settingSources, }) {
|
|
265
265
|
const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
|
|
266
266
|
const width = useTerminalWidth(terminalWidth);
|
|
267
|
+
const rows = useTerminalRows();
|
|
267
268
|
const keybindingsRoot = useMemo(() => resolve(keybindingsConfigRoot ??
|
|
268
269
|
process.env.CLAUDE_CONFIG_DIR ??
|
|
269
270
|
resolve(homedir(), '.claude')), [keybindingsConfigRoot]);
|
|
@@ -320,6 +321,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
320
321
|
const [themeSettings, setThemeSettings] = useState(initialThemeSettings ?? DEFAULT_TUI_THEME_SETTINGS);
|
|
321
322
|
const [runtimeSettings, setRuntimeSettings] = useState(suppliedRuntimeSettings ??
|
|
322
323
|
projectRuntimeSettings({ settings: {}, state: {} }));
|
|
324
|
+
const fixedViewport = runtimeSettings.tui === 'fullscreen' && rows !== undefined;
|
|
323
325
|
const runtimeSettingsRef = useRef(runtimeSettings);
|
|
324
326
|
runtimeSettingsRef.current = runtimeSettings;
|
|
325
327
|
runtimeGitignoreRef.current = runtimeSettings.gitignore;
|
|
@@ -5626,7 +5628,9 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
5626
5628
|
}
|
|
5627
5629
|
editComposer();
|
|
5628
5630
|
});
|
|
5629
|
-
return (_jsx(TuiThemeProvider, { settings: themeSettings, children: _jsx(Box, { flexDirection: "column",
|
|
5631
|
+
return (_jsx(TuiThemeProvider, { settings: themeSettings, children: _jsx(Box, { flexDirection: "column", ...(!fixedViewport
|
|
5632
|
+
? {}
|
|
5633
|
+
: { height: rows, overflowY: 'hidden' }), children: selectingSession ? (_jsx(SessionPicker, { sessions: filteredPickerChoices, selectedIndex: selectedIndex, screenReader: axScreenReader, query: sessionSearch })) : (_jsxs(_Fragment, { children: [!axScreenReader && history.length === 0 && !sessionId ? (_jsx(WelcomePanel, { display: runtimeDisplay, width: width, showTips: runtimeSettings.tips })) : null, sessionId ? (_jsxs(Text, { dimColor: true, children: ["Session ", sessionId.slice(0, 8)] })) : null, _jsx(Transcript, { items: history, activeText: activeText, activeThinking: activeThinking, thinkingExpanded: thinkingExpanded, detailedTranscript: thinkingExpanded || runtimeSettings.verbose, screenReader: axScreenReader }), externalEditorRequest !== null ||
|
|
5630
5634
|
keybindingsEditing ||
|
|
5631
5635
|
memoryEditorRequest !== null ? (_jsx(ExternalEditorWait, { screenReader: axScreenReader })) : permission ? (permission.kind === 'tool' && toolPermissionModel ? (_jsx(ToolPermissionDialog, { model: toolPermissionModel, selection: permissionSelection, feedbackMode: permissionFeedbackMode, feedback: input, ruleEditor: permissionRuleEditor, screenReader: axScreenReader })) : (_jsxs(DialogFrame, { title: `Retry interrupted ${permission.call.name}?`, screenReader: axScreenReader, children: [_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: _jsx(Text, { bold: true, children: describeTool(permission.call, sensitiveValues) }) }), _jsx(Text, { children: "Do you want to proceed?" }), _jsxs(Text, { inverse: !axScreenReader && permissionSelection === 0, children: [selectionPrefix(permissionSelection === 0, axScreenReader), "1. Yes"] }), _jsxs(Text, { inverse: !axScreenReader && permissionSelection === 1, children: [selectionPrefix(permissionSelection === 1, axScreenReader), "2. No"] }), permissionFeedbackMode ? (_jsxs(Text, { children: ["\u203A", ' ', input ||
|
|
5632
5636
|
(permissionSelection === 0
|
|
@@ -5762,7 +5766,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
5762
5766
|
sessionName,
|
|
5763
5767
|
usage?.inputTokens,
|
|
5764
5768
|
usage?.outputTokens,
|
|
5765
|
-
].join(':'), ...(settingSources === undefined ? {} : { settingSources }) })] }))] })) }) }));
|
|
5769
|
+
].join(':'), width: width, ...(settingSources === undefined ? {} : { settingSources }) })] }))] })) }) }));
|
|
5766
5770
|
}
|
|
5767
5771
|
export async function runInteractive(options) {
|
|
5768
5772
|
const controller = new AbortController();
|
|
@@ -71,6 +71,7 @@ export interface TuiBtwEntry {
|
|
|
71
71
|
error?: string;
|
|
72
72
|
}
|
|
73
73
|
export declare function useTerminalWidth(override?: number): number;
|
|
74
|
+
export declare function useTerminalRows(override?: number): number | undefined;
|
|
74
75
|
export declare function WelcomePanel({ display, width, showTips, }: {
|
|
75
76
|
display: TuiDisplayMetadata;
|
|
76
77
|
width: number;
|
|
@@ -22,6 +22,23 @@ export function useTerminalWidth(override) {
|
|
|
22
22
|
}, [override, stdout]);
|
|
23
23
|
return Math.max(32, width);
|
|
24
24
|
}
|
|
25
|
+
export function useTerminalRows(override) {
|
|
26
|
+
const { stdout } = useStdout();
|
|
27
|
+
const initialRows = override ?? (stdout.isTTY ? stdout.rows : undefined);
|
|
28
|
+
const [rows, setRows] = useState(initialRows);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (override !== undefined) {
|
|
31
|
+
setRows(override);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const resize = () => setRows(stdout.rows);
|
|
35
|
+
stdout.on('resize', resize);
|
|
36
|
+
return () => {
|
|
37
|
+
stdout.off('resize', resize);
|
|
38
|
+
};
|
|
39
|
+
}, [override, stdout]);
|
|
40
|
+
return rows === undefined ? undefined : Math.max(12, rows);
|
|
41
|
+
}
|
|
25
42
|
function compactPath(cwd) {
|
|
26
43
|
const home = process.env.HOME;
|
|
27
44
|
return home && cwd.startsWith(`${home}/`)
|
|
@@ -44,6 +61,45 @@ function permissionLabel(mode) {
|
|
|
44
61
|
return 'permissions default';
|
|
45
62
|
}
|
|
46
63
|
}
|
|
64
|
+
function compactPermissionLabel(mode) {
|
|
65
|
+
switch (mode) {
|
|
66
|
+
case 'acceptEdits':
|
|
67
|
+
return 'accept edits';
|
|
68
|
+
case 'bypassPermissions':
|
|
69
|
+
return 'bypass';
|
|
70
|
+
case 'dontAsk':
|
|
71
|
+
return 'dont ask';
|
|
72
|
+
case 'plan':
|
|
73
|
+
return 'plan';
|
|
74
|
+
case 'auto':
|
|
75
|
+
return 'auto';
|
|
76
|
+
default:
|
|
77
|
+
return 'default';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Width-prioritized composer footer left text. The footer is one deliberate,
|
|
81
|
+
// non-wrapping line: mode and the busy/effort state always win, while
|
|
82
|
+
// shortcuts/agents/thinking hints are dropped or shortened as width shrinks.
|
|
83
|
+
function composerFooterLeft(width, busy, hasThinking, thinkingExpanded, mode, compactMode, includeHints = true) {
|
|
84
|
+
const cancelOrShortcut = busy ? 'esc to interrupt' : '? for shortcuts';
|
|
85
|
+
if (!includeHints) {
|
|
86
|
+
if (busy)
|
|
87
|
+
return `${width >= 60 ? mode : compactMode} · ${cancelOrShortcut}`;
|
|
88
|
+
return width >= 60 ? mode : compactMode;
|
|
89
|
+
}
|
|
90
|
+
if (width >= 60) {
|
|
91
|
+
let text = `${mode} · ${cancelOrShortcut}`;
|
|
92
|
+
if (width >= 80)
|
|
93
|
+
text += ' · ← for agents';
|
|
94
|
+
if (width >= 100 && hasThinking) {
|
|
95
|
+
text += ` · ctrl+o ${thinkingExpanded ? 'collapse' : 'expand'}`;
|
|
96
|
+
}
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
if (busy)
|
|
100
|
+
return `${compactMode} · ${cancelOrShortcut}`;
|
|
101
|
+
return width >= 40 ? mode : compactMode;
|
|
102
|
+
}
|
|
47
103
|
function selectionPrefix(selected, screenReader) {
|
|
48
104
|
if (selected)
|
|
49
105
|
return screenReader ? 'Selected: ' : ' ❯ ';
|
|
@@ -54,7 +110,7 @@ export function WelcomePanel({ display, width, showTips, }) {
|
|
|
54
110
|
if (!showTips)
|
|
55
111
|
return null;
|
|
56
112
|
const panelWidth = Math.min(100, Math.max(32, width));
|
|
57
|
-
const wide = panelWidth >=
|
|
113
|
+
const wide = panelWidth >= 72;
|
|
58
114
|
const brand = 'Praxis';
|
|
59
115
|
const model = display.model ?? 'provider default';
|
|
60
116
|
const effort = display.effort ? ` · ${display.effort} effort` : '';
|
|
@@ -63,9 +119,11 @@ export function WelcomePanel({ display, width, showTips, }) {
|
|
|
63
119
|
// ╭───Praxis Code vX.Y.Z ───...───╮
|
|
64
120
|
// Fixed prefix/suffix = "╭───"(4) + "Praxis"(6) + " Code vX.Y.Z "(8 + version)
|
|
65
121
|
// + "╮"(1), so the fill keeps the row exactly at panelWidth.
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
122
|
+
const title = `${brand} Code v${display.version}`;
|
|
123
|
+
const fill = Math.max(1, panelWidth - title.length - 6);
|
|
124
|
+
const leftWidth = Math.max(12, Math.floor((panelWidth - 4) / 2));
|
|
125
|
+
const rightWidth = Math.max(12, panelWidth - leftWidth - 4);
|
|
126
|
+
return (_jsxs(Box, { flexDirection: "column", width: panelWidth, children: [_jsxs(Text, { color: palette.muted, children: ['╭───', _jsx(Text, { color: palette.brand, bold: true, children: brand }), ` Code v${display.version} `, _jsx(Text, { dimColor: true, children: '─'.repeat(fill) }), '╮'] }), _jsxs(Box, { borderStyle: "round", borderColor: palette.muted, borderTop: false, flexDirection: wide ? 'row' : 'column', width: panelWidth, paddingX: 1, children: [_jsxs(Box, { alignItems: wide ? 'center' : undefined, flexDirection: "column", width: wide ? leftWidth : '100%', children: [_jsx(Text, { children: " " }), _jsxs(Text, { bold: true, children: ["Welcome to ", brand] }), _jsx(Text, { children: " " }), _jsx(Text, { color: palette.brand, bold: true, children: "\u2590\u259B\u2588\u2588\u2588\u259C\u258C" }), _jsx(Text, { color: palette.brand, children: "\u259D\u259C\u2588\u2588\u2588\u2588\u2588\u259B\u2598" }), _jsx(Text, { color: palette.brand, children: " \u2598\u2598 \u259D\u259D" }), _jsx(Text, { children: " " }), _jsxs(Text, { wrap: "truncate-end", children: [model, effort] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cwd })] }), _jsxs(Box, { flexDirection: "column", width: wide ? rightWidth : '100%', marginTop: wide ? 0 : 1, children: [_jsx(Text, { bold: true, children: "Get started" }), _jsx(Text, { wrap: "truncate-end", children: "/init to create CLAUDE.md" }), _jsx(Text, { wrap: "truncate-end", children: "/config to open settings" }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { bold: true, children: "Shared with Claude Code" }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Sessions \u00B7 memory \u00B7 skills" })] })] })] }));
|
|
69
127
|
}
|
|
70
128
|
const INLINE_SEGMENT_CACHE_MAX = 4096;
|
|
71
129
|
const inlineSegmentCache = new Map();
|
|
@@ -866,7 +924,12 @@ export function Composer({ input, cursor, busy, clipboardBusy = false, status, d
|
|
|
866
924
|
: `Prompt: ${input}` }));
|
|
867
925
|
const line = '─'.repeat(Math.max(12, Math.min(100, width)));
|
|
868
926
|
const separatorColor = sessionColor === undefined ? undefined : palette.sessionColors[sessionColor];
|
|
869
|
-
|
|
927
|
+
const footerWidth = Math.min(100, width);
|
|
928
|
+
const footerMode = `⏵⏵ ${permissionLabel(display.permissionMode)}`;
|
|
929
|
+
const footerCompactMode = `⏵⏵ ${compactPermissionLabel(display.permissionMode)}`;
|
|
930
|
+
const footerLeft = composerFooterLeft(footerWidth, busy, hasThinking, thinkingExpanded, footerMode, footerCompactMode);
|
|
931
|
+
const footerMessageLeft = composerFooterLeft(footerWidth, busy, hasThinking, thinkingExpanded, footerMode, footerCompactMode, false);
|
|
932
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, flexShrink: 0, children: [usage ? (_jsxs(Text, { dimColor: true, children: ["Context \u00B7 ", usage.inputTokens + usage.outputTokens, " tokens", display.contextWindowTokens
|
|
870
933
|
? ` / ${display.contextWindowTokens} (${Math.min(100, Math.round(((usage.inputTokens + usage.outputTokens) /
|
|
871
934
|
display.contextWindowTokens) *
|
|
872
935
|
100))}%)`
|
|
@@ -878,9 +941,7 @@ export function Composer({ input, cursor, busy, clipboardBusy = false, status, d
|
|
|
878
941
|
? 'Enter a shell command'
|
|
879
942
|
: 'Try "review this project"' }))] })), _jsx(Text, { ...(separatorColor === undefined
|
|
880
943
|
? { dimColor: true }
|
|
881
|
-
: { color: separatorColor }), children: line }), shortcutsVisible ? (_jsx(ShortcutHelp, { width: width })) : (
|
|
882
|
-
? ` · ctrl+o ${thinkingExpanded ? 'collapse' : 'expand'}`
|
|
883
|
-
: ''] })) }), _jsx(Box, { flexGrow: 1 }), footerMessage ? (footerMessage.isError ? (_jsx(Text, { color: palette.error, children: footerMessage.text })) : (_jsx(Text, { dimColor: true, children: footerMessage.text }))) : prStatus ? (_jsx(Text, { dimColor: true, children: prStatus })) : turnDuration ? (_jsxs(Text, { dimColor: true, children: ["Cooked for ", turnDuration] })) : display.effort ? (_jsxs(Text, { children: [_jsxs(Text, { color: palette.accent, children: ["\u25CF ", display.effort] }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", editorMode === 'vim' ? 'vim' : '/effort'] })] })) : null] }))] }));
|
|
944
|
+
: { color: separatorColor }), children: line }), shortcutsVisible ? (_jsx(ShortcutHelp, { width: width })) : (_jsx(Box, { width: footerWidth, children: _jsx(Text, { wrap: "truncate", children: shellMode ? (_jsx(Text, { dimColor: true, children: "! for bash mode" })) : footerMessage ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerMessageLeft, " \u00B7 "] }), footerMessage.isError ? (_jsx(Text, { color: palette.error, children: footerMessage.text })) : (_jsx(Text, { dimColor: true, children: footerMessage.text }))] })) : prStatus ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerLeft, " \u00B7 "] }), _jsx(Text, { dimColor: true, children: prStatus })] })) : turnDuration ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerLeft, " \u00B7 "] }), _jsxs(Text, { dimColor: true, children: ["Cooked for ", turnDuration] })] })) : display.effort ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: footerLeft }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: palette.accent, children: ["\u25CF ", display.effort] }), footerWidth >= 100 ? (_jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", editorMode === 'vim' ? 'vim' : '/effort'] })) : null] })) : (_jsx(Text, { dimColor: true, children: footerLeft })) }) }))] }));
|
|
884
945
|
}
|
|
885
946
|
export function DialogFrame({ title, children, screenReader, }) {
|
|
886
947
|
const palette = useTuiPalette();
|
|
@@ -61,6 +61,7 @@ export declare function executeClaudeStatusLine(setting: ClaudeStatusLineSetting
|
|
|
61
61
|
cwd: string;
|
|
62
62
|
signal?: AbortSignal;
|
|
63
63
|
timeoutMs?: number;
|
|
64
|
+
columns?: number;
|
|
64
65
|
}): Promise<string | undefined>;
|
|
65
66
|
export declare function createClaudeStatusLineInput(options: {
|
|
66
67
|
configRoot: string;
|
|
@@ -78,11 +79,12 @@ export declare function createClaudeStatusLineInput(options: {
|
|
|
78
79
|
contextWindowTokens?: number;
|
|
79
80
|
vimMode?: 'INSERT' | 'NORMAL';
|
|
80
81
|
}): ClaudeStatusLineInput;
|
|
81
|
-
export declare function StatusLine({ configRoot, cwd, input, refreshKey, settingSources, }: {
|
|
82
|
+
export declare function StatusLine({ configRoot, cwd, input, refreshKey, width, settingSources, }: {
|
|
82
83
|
configRoot: string;
|
|
83
84
|
cwd: string;
|
|
84
85
|
input: ClaudeStatusLineInput;
|
|
85
86
|
refreshKey: string;
|
|
87
|
+
width?: number;
|
|
86
88
|
settingSources?: readonly ClaudeResourceScope[];
|
|
87
89
|
}): import("react").JSX.Element | null;
|
|
88
90
|
//# sourceMappingURL=status-line.d.ts.map
|
|
@@ -49,10 +49,16 @@ export async function executeClaudeStatusLine(setting, input, options = {
|
|
|
49
49
|
};
|
|
50
50
|
let child;
|
|
51
51
|
try {
|
|
52
|
+
const env = { ...process.env };
|
|
53
|
+
if (options.columns !== undefined &&
|
|
54
|
+
Number.isFinite(options.columns) &&
|
|
55
|
+
options.columns > 0) {
|
|
56
|
+
env.COLUMNS = String(Math.trunc(options.columns));
|
|
57
|
+
}
|
|
52
58
|
child = spawn(commandShell(), commandShellArguments(setting.command), {
|
|
53
59
|
cwd: options.cwd,
|
|
54
60
|
detached: process.platform !== 'win32',
|
|
55
|
-
env
|
|
61
|
+
env,
|
|
56
62
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
57
63
|
});
|
|
58
64
|
}
|
|
@@ -169,7 +175,7 @@ export function createClaudeStatusLineInput(options) {
|
|
|
169
175
|
...(options.vimMode ? { vim: { mode: options.vimMode } } : {}),
|
|
170
176
|
};
|
|
171
177
|
}
|
|
172
|
-
export function StatusLine({ configRoot, cwd, input, refreshKey, settingSources, }) {
|
|
178
|
+
export function StatusLine({ configRoot, cwd, input, refreshKey, width, settingSources, }) {
|
|
173
179
|
const [text, setText] = useState();
|
|
174
180
|
const [padding, setPadding] = useState(0);
|
|
175
181
|
const timer = useRef(undefined);
|
|
@@ -193,7 +199,11 @@ export function StatusLine({ configRoot, cwd, input, refreshKey, settingSources,
|
|
|
193
199
|
setText(undefined);
|
|
194
200
|
return;
|
|
195
201
|
}
|
|
196
|
-
const result = await executeClaudeStatusLine(loaded.setting, latestInput.current, {
|
|
202
|
+
const result = await executeClaudeStatusLine(loaded.setting, latestInput.current, {
|
|
203
|
+
cwd,
|
|
204
|
+
signal: current.signal,
|
|
205
|
+
...(width === undefined ? {} : { columns: width }),
|
|
206
|
+
});
|
|
197
207
|
if (!current.signal.aborted)
|
|
198
208
|
setText(result);
|
|
199
209
|
}
|
|
@@ -201,7 +211,7 @@ export function StatusLine({ configRoot, cwd, input, refreshKey, settingSources,
|
|
|
201
211
|
if (!current.signal.aborted)
|
|
202
212
|
setText(undefined);
|
|
203
213
|
}
|
|
204
|
-
}, [configRoot, cwd, settingSources]);
|
|
214
|
+
}, [configRoot, cwd, settingSources, width]);
|
|
205
215
|
const schedule = useCallback(() => {
|
|
206
216
|
if (timer.current)
|
|
207
217
|
clearTimeout(timer.current);
|
|
@@ -229,6 +239,6 @@ export function StatusLine({ configRoot, cwd, input, refreshKey, settingSources,
|
|
|
229
239
|
}, [configRoot, cwd, schedule]);
|
|
230
240
|
if (!text)
|
|
231
241
|
return null;
|
|
232
|
-
return (_jsx(Box, { paddingX: padding, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: text }) }));
|
|
242
|
+
return (_jsx(Box, { paddingX: padding, flexShrink: 0, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: text }) }));
|
|
233
243
|
}
|
|
234
244
|
//# sourceMappingURL=status-line.js.map
|
|
@@ -12,6 +12,21 @@ export interface ClaudePaths {
|
|
|
12
12
|
praxisRoot: string;
|
|
13
13
|
}
|
|
14
14
|
export declare function sanitizeClaudeProjectPath(path: string): string;
|
|
15
|
+
export interface DiscoverClaudeProjectRootOptions {
|
|
16
|
+
configRoot: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Locates the Claude project directory for a cwd, preferring the exact
|
|
22
|
+
* sanitized hash path and falling back to a long-path truncated-prefix
|
|
23
|
+
* candidate when Claude used a different runtime hash. When a sessionId is
|
|
24
|
+
* supplied, the exact directory only matches if it contains the requested
|
|
25
|
+
* regular session file; an existing exact directory without that file still
|
|
26
|
+
* falls through to the long-path prefix scan. Candidate selection is
|
|
27
|
+
* directory-prefix based and never uses mtime; ambiguous prefixes are rejected.
|
|
28
|
+
*/
|
|
29
|
+
export declare function discoverClaudeProjectRoot({ configRoot, cwd, sessionId, }: DiscoverClaudeProjectRootOptions): Promise<string | undefined>;
|
|
15
30
|
export declare function resolveClaudeScheduledTaskFile(cwd: string): string;
|
|
16
31
|
export declare function resolveClaudePaths({ cwd, sessionId, configDir, }: ResolveClaudePathsOptions): ClaudePaths;
|
|
17
32
|
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
+
import { extname, resolve } from 'node:path';
|
|
3
4
|
import { getDataOwnership } from './ownership.js';
|
|
4
5
|
const MAX_SANITIZED_LENGTH = 200;
|
|
5
6
|
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -21,6 +22,105 @@ export function sanitizeClaudeProjectPath(path) {
|
|
|
21
22
|
}
|
|
22
23
|
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${stablePathHash(path).toString(36)}`;
|
|
23
24
|
}
|
|
25
|
+
async function isDirectory(path) {
|
|
26
|
+
try {
|
|
27
|
+
return (await lstat(path)).isDirectory();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code === 'ENOENT')
|
|
31
|
+
return false;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function isRegularFile(path) {
|
|
36
|
+
try {
|
|
37
|
+
return (await lstat(path)).isFile();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT')
|
|
41
|
+
return false;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function hasClaudeSessionTranscript(directory) {
|
|
46
|
+
let names;
|
|
47
|
+
try {
|
|
48
|
+
names = await readdir(directory);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT')
|
|
52
|
+
return false;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (extname(name) !== '.jsonl')
|
|
57
|
+
continue;
|
|
58
|
+
if (!isClaudeSessionId(name.slice(0, -'.jsonl'.length)))
|
|
59
|
+
continue;
|
|
60
|
+
if (await isRegularFile(resolve(directory, name)))
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Locates the Claude project directory for a cwd, preferring the exact
|
|
67
|
+
* sanitized hash path and falling back to a long-path truncated-prefix
|
|
68
|
+
* candidate when Claude used a different runtime hash. When a sessionId is
|
|
69
|
+
* supplied, the exact directory only matches if it contains the requested
|
|
70
|
+
* regular session file; an existing exact directory without that file still
|
|
71
|
+
* falls through to the long-path prefix scan. Candidate selection is
|
|
72
|
+
* directory-prefix based and never uses mtime; ambiguous prefixes are rejected.
|
|
73
|
+
*/
|
|
74
|
+
export async function discoverClaudeProjectRoot({ configRoot, cwd, sessionId, }) {
|
|
75
|
+
if (sessionId !== undefined && !isClaudeSessionId(sessionId)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const sanitized = sanitizeClaudeProjectPath(cwd);
|
|
79
|
+
const exactProjectRoot = resolve(configRoot, 'projects', sanitized);
|
|
80
|
+
if (sessionId !== undefined) {
|
|
81
|
+
// A sessionId targets a concrete transcript; an existing exact directory
|
|
82
|
+
// only matches when it actually holds the requested session file. Praxis
|
|
83
|
+
// may have created an empty exact directory before the transcript landed
|
|
84
|
+
// in an alternate long-path prefix directory, so fall through to the
|
|
85
|
+
// prefix scan instead of returning the empty exact directory.
|
|
86
|
+
if (await isRegularFile(resolve(exactProjectRoot, `${sessionId}.jsonl`))) {
|
|
87
|
+
return exactProjectRoot;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (await isDirectory(exactProjectRoot)) {
|
|
91
|
+
return exactProjectRoot;
|
|
92
|
+
}
|
|
93
|
+
if (sanitized.length <= MAX_SANITIZED_LENGTH)
|
|
94
|
+
return undefined;
|
|
95
|
+
const prefix = sanitized.slice(0, MAX_SANITIZED_LENGTH);
|
|
96
|
+
let names;
|
|
97
|
+
try {
|
|
98
|
+
names = await readdir(resolve(configRoot, 'projects'));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code === 'ENOENT')
|
|
102
|
+
return undefined;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const candidates = [];
|
|
106
|
+
for (const name of names) {
|
|
107
|
+
if (!name.startsWith(`${prefix}-`))
|
|
108
|
+
continue;
|
|
109
|
+
const candidate = resolve(configRoot, 'projects', name);
|
|
110
|
+
if (!(await isDirectory(candidate)))
|
|
111
|
+
continue;
|
|
112
|
+
if (sessionId !== undefined) {
|
|
113
|
+
if (await isRegularFile(resolve(candidate, `${sessionId}.jsonl`))) {
|
|
114
|
+
candidates.push(candidate);
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (await hasClaudeSessionTranscript(candidate)) {
|
|
119
|
+
candidates.push(candidate);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
123
|
+
}
|
|
24
124
|
export function resolveClaudeScheduledTaskFile(cwd) {
|
|
25
125
|
const policy = getDataOwnership('scheduled-prompts');
|
|
26
126
|
if (policy.plane !== 'shared' ||
|