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 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 shared-command
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
- if (removed) {
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
- this.dueQueue.push({ id: task.id, prompt: task.prompt });
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<import("./scheduled-prompt-manager.js").ScheduledPrompt | null>;
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
- return this.scheduledPrompts?.next(signal) ?? Promise.resolve(null);
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 paths = this.paths(randomUUID());
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(paths.projectRoot);
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 summaries = await Promise.all(names
881
+ const sessionIds = names
831
882
  .filter((name) => extname(name) === '.jsonl')
832
- .map(async (name) => {
833
- const sessionId = basename(name, '.jsonl');
834
- if (!isClaudeSessionId(sessionId))
835
- return null;
836
- const sessionFile = join(paths.projectRoot, name);
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
- return resolveClaudePaths({
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) => {
@@ -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", 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 ||
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 >= 68;
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 fill = Math.max(1, panelWidth - display.version.length - 19);
67
- const identity = `Welcome to ${brand} · ${model}${effort} · ${cwd}`;
68
- 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) }), '╮'] }), wide ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: identity }), _jsx(Text, { children: "/init to create CLAUDE.md \u00B7 /config to open settings" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: ["Welcome to ", brand] }), _jsxs(Text, { children: [model, effort] }), _jsx(Text, { children: cwd }), _jsx(Text, { children: "/init to create CLAUDE.md" }), _jsx(Text, { children: "/config to open settings" })] }))] }));
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
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [usage ? (_jsxs(Text, { dimColor: true, children: ["Context \u00B7 ", usage.inputTokens + usage.outputTokens, " tokens", display.contextWindowTokens
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 })) : (_jsxs(Box, { width: Math.min(100, width), children: [_jsx(Text, { dimColor: true, children: shellMode ? ('! for bash mode') : (_jsxs(_Fragment, { children: ["\u23F5\u23F5 ", permissionLabel(display.permissionMode), " \u00B7", ' ', busy ? 'esc to interrupt' : '? for shortcuts', " \u00B7 \u2190 for agents", hasThinking
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: process.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, { cwd, signal: current.signal });
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' ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.20.16",
3
+ "version": "0.20.20",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",