praxis-agent 0.45.4 → 0.46.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.
package/README.md CHANGED
@@ -106,7 +106,10 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
106
106
  terminal-native background, and a minimal composer/status row. Interactive
107
107
  surfaces share the same presentation across terminals, with English
108
108
  permission/configuration choices and a taught `❯` / Up/Down / Enter / Esc
109
- interaction grammar, plus a
109
+ interaction grammar. While a regular turn is active, the composer remains
110
+ editable: Enter steers at the next safe continuation boundary, Tab or
111
+ Alt+Enter queues a sequential follow-up turn, and pending input stays visible
112
+ and can be withdrawn with Up. The TUI also includes a
110
113
  shared-command
111
114
  slash palette, tabbed help and shortcut surfaces, searchable resume picker,
112
115
  restored active-branch conversation history, streaming and expandable
@@ -9,6 +9,7 @@ import { type ClaudeInterruptionClassification } from '../native/interruption.js
9
9
  import { type ModelDocument, type ModelImage, type ModelToolCall, type ModelProvider, type ModelUsage, type PermissionApproval, type PermissionDecision, type PermissionResolver, type PermissionUpdate, type RuntimeEventSink, type ToolRegistry } from '../core/runtime.js';
10
10
  import { type BackgroundTaskSnapshot } from './background-task-runtime.js';
11
11
  import type { ModelPricingRegistry } from '../core/usage.js';
12
+ import { type ActiveTurnInputCommandResult } from '../core/active-turn-input.js';
12
13
  import type { Compactor } from '../core/compaction.js';
13
14
  import { ContextBudget } from '../core/context-budget.js';
14
15
  import { type ContextAssembler } from '../core/context.js';
@@ -227,6 +228,7 @@ export declare class ClaudeSessionService {
227
228
  private readonly hookLifecycle;
228
229
  private readonly leadOperations;
229
230
  private readonly fileChangeWatcher;
231
+ private readonly activeTurnInputs;
230
232
  private runtimeCwd;
231
233
  constructor(options: ClaudeSessionServiceOptions);
232
234
  nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
@@ -270,6 +272,8 @@ export declare class ClaudeSessionService {
270
272
  run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[]): Promise<SessionRunResult>;
271
273
  runShell(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
272
274
  resume(sessionId: string, prompt: string, signal?: AbortSignal, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[], resumeSessionAt?: string): Promise<SessionRunResult>;
275
+ steer(sessionId: string, content: string): ActiveTurnInputCommandResult;
276
+ withdrawSteering(sessionId: string, id: string): ActiveTurnInputCommandResult;
273
277
  resumeShell(sessionId: string, command: string, signal?: AbortSignal, name?: string, resumeSessionAt?: string): Promise<SessionRunResult>;
274
278
  answerSideQuestion(sessionId: string | undefined, question: string, signal?: AbortSignal, onDelta?: (delta: string) => void, permissionMode?: ClaudePermissionMode): Promise<SideQuestionResult>;
275
279
  forkSideQuestion(sessionId: string, question: string, signal?: AbortSignal): Promise<SideQuestionForkResult>;
@@ -21,6 +21,7 @@ import { BackgroundTaskRuntime, } from './background-task-runtime.js';
21
21
  import { backgroundAgentNotificationMarkers, } from './background-agent-manager.js';
22
22
  import { usageCostUsd } from '../core/usage.js';
23
23
  import { isSessionId } from '../core/session.js';
24
+ import { ActiveTurnInputMailbox, } from '../core/active-turn-input.js';
24
25
  import { ContextBudget, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
25
26
  import { ContextEngine } from './context-engine.js';
26
27
  import { TurnMemoryCoordinator } from './turn-memory-coordinator.js';
@@ -682,6 +683,7 @@ export class ClaudeSessionService {
682
683
  hookLifecycle;
683
684
  leadOperations;
684
685
  fileChangeWatcher;
686
+ activeTurnInputs = new Map();
685
687
  runtimeCwd;
686
688
  constructor(options) {
687
689
  const dataPlane = options.dataPlane ?? 'native';
@@ -887,6 +889,18 @@ export class ClaudeSessionService {
887
889
  }
888
890
  async close() {
889
891
  this.closing = true;
892
+ for (const { mailbox } of this.activeTurnInputs.values()) {
893
+ if (!mailbox)
894
+ continue;
895
+ for (const item of mailbox.close()) {
896
+ this.options.eventSink?.({
897
+ type: 'user-input-rejected',
898
+ id: item.id,
899
+ content: item.content,
900
+ reason: 'closed',
901
+ });
902
+ }
903
+ }
890
904
  await this.fileChangeWatcher?.close(5_000);
891
905
  await this.hookLifecycle.close();
892
906
  await this.drainDetachedHookRuns(5_000);
@@ -1299,6 +1313,30 @@ export class ClaudeSessionService {
1299
1313
  ...(signal ? { signal } : {}),
1300
1314
  });
1301
1315
  }
1316
+ steer(sessionId, content) {
1317
+ const active = this.activeTurnInputs.get(sessionId);
1318
+ if (!active)
1319
+ return { kind: 'no-active-turn' };
1320
+ const mailbox = active.mailbox;
1321
+ if (!mailbox)
1322
+ return { kind: 'not-steerable' };
1323
+ const result = mailbox.enqueue(content);
1324
+ if (result.kind === 'accepted')
1325
+ return result;
1326
+ if (result.kind === 'empty')
1327
+ return result;
1328
+ return { kind: 'turn-completing' };
1329
+ }
1330
+ withdrawSteering(sessionId, id) {
1331
+ const active = this.activeTurnInputs.get(sessionId);
1332
+ if (!active)
1333
+ return { kind: 'no-active-turn' };
1334
+ const mailbox = active.mailbox;
1335
+ if (!mailbox)
1336
+ return { kind: 'not-steerable' };
1337
+ const result = mailbox.withdraw(id);
1338
+ return result.kind === 'withdrawn' ? result : { kind: 'not-pending' };
1339
+ }
1302
1340
  async resumeShell(sessionId, command, signal, name, resumeSessionAt) {
1303
1341
  this.worktreeManager?.bindSession(sessionId);
1304
1342
  return this.executeTurn({
@@ -2449,6 +2487,8 @@ export class ClaudeSessionService {
2449
2487
  const shellCommand = submission.kind === 'shell' ? submission.command : undefined;
2450
2488
  const skipUserPrompt = submission.kind === 'retry';
2451
2489
  const controller = new TurnTerminalController(this.options.eventSink ?? (() => undefined));
2490
+ let activeTurnInput;
2491
+ let activeTurnRecord;
2452
2492
  try {
2453
2493
  this.assertTurnWritable();
2454
2494
  if (prompt.length === 0 && images.length === 0 && documents.length === 0)
@@ -2459,6 +2499,16 @@ export class ClaudeSessionService {
2459
2499
  if (shellCommand !== undefined && shellCommand.trim().length === 0) {
2460
2500
  throw new Error('Shell command must not be empty');
2461
2501
  }
2502
+ if (this.activeTurnInputs.has(sessionId)) {
2503
+ throw new Error(`conflict: locked (session ${sessionId} already has an active turn)`);
2504
+ }
2505
+ activeTurnRecord =
2506
+ shellCommand === undefined
2507
+ ? {
2508
+ mailbox: (activeTurnInput = new ActiveTurnInputMailbox(randomUUID)),
2509
+ }
2510
+ : {};
2511
+ this.activeTurnInputs.set(sessionId, activeTurnRecord);
2462
2512
  await this.activateSessionCostTracker(sessionId);
2463
2513
  await this.ensureFileResources(sessionId, signal);
2464
2514
  this.worktreeManager?.bindSession(sessionId);
@@ -3213,6 +3263,24 @@ export class ClaudeSessionService {
3213
3263
  }
3214
3264
  await durableFollowUps.followUpUserMessagesCompleted(messages);
3215
3265
  },
3266
+ userInputDelivered: async (item) => {
3267
+ if (nativeLease) {
3268
+ await nativeLease.appendMessages({
3269
+ messages: [{ role: 'user', content: item.content }],
3270
+ });
3271
+ currentTurnUserMessages?.push(item.content);
3272
+ return;
3273
+ }
3274
+ const [steeringEntry] = translateProviderEvents([{ type: 'user-text-block', text: item.content }], this.translationContext(sessionId, snapshot));
3275
+ if (!steeringEntry)
3276
+ throw new Error('Could not translate steering message');
3277
+ const steeringTail = await this.append(lease, snapshot.tail, steeringEntry);
3278
+ snapshot = {
3279
+ entries: [...snapshot.entries, steeringEntry],
3280
+ tail: steeringTail,
3281
+ };
3282
+ currentTurnUserMessages?.push(item.content);
3283
+ },
3216
3284
  };
3217
3285
  let turnCompleted = false;
3218
3286
  try {
@@ -3974,6 +4042,7 @@ export class ClaudeSessionService {
3974
4042
  cwd: this.activeCwd(),
3975
4043
  toolResultDirectory,
3976
4044
  observer,
4045
+ ...(activeTurnInput ? { steering: activeTurnInput } : {}),
3977
4046
  ...(this.options.effort ? { effort: this.options.effort } : {}),
3978
4047
  ...(this.options.maxModelTurns !== undefined
3979
4048
  ? { maxModelTurns: this.options.maxModelTurns }
@@ -4373,6 +4442,20 @@ export class ClaudeSessionService {
4373
4442
  controller.fail(error, signal);
4374
4443
  throw error;
4375
4444
  }
4445
+ finally {
4446
+ if (activeTurnInput !== undefined) {
4447
+ for (const item of activeTurnInput.close()) {
4448
+ this.options.eventSink?.({
4449
+ type: 'user-input-rejected',
4450
+ id: item.id,
4451
+ content: item.content,
4452
+ reason: signal?.aborted ? 'cancelled' : 'failed',
4453
+ });
4454
+ }
4455
+ }
4456
+ if (this.activeTurnInputs.get(sessionId) === activeTurnRecord)
4457
+ this.activeTurnInputs.delete(sessionId);
4458
+ }
4376
4459
  }
4377
4460
  async ensureFileResources(sessionId, signal) {
4378
4461
  const resources = this.options.fileResources ?? [];
@@ -1,6 +1,7 @@
1
1
  import type { ForkResult, ManualCompactResult, ManualCompactSelection, RewindPoint, SessionForkCheckpoint, SessionRunResult, SessionSummary, SideQuestionForkResult, SideQuestionResult } from '../application/session-service.js';
2
2
  import type { ClaudeSessionCostSnapshot } from '../application/session-cost-tracker.js';
3
3
  import type { ModelImage, ModelToolCall, PermissionApproval, PermissionDecision, RuntimeEventSink } from '../core/runtime.js';
4
+ import type { ActiveTurnInputCommandResult } from '../core/active-turn-input.js';
4
5
  import { type DataPlane } from '../persistence/data-plane.js';
5
6
  import type { CliElicitationRequest, CliElicitationResult, CliRuntimeInfo } from './protocol.js';
6
7
  import type { ClaudeInteractiveToolCallbacks } from '../tools/claude-interactive-tools.js';
@@ -34,6 +35,8 @@ import type { BackgroundTaskSnapshot } from '../application/background-task-runt
34
35
  interface InteractiveSessionCommands {
35
36
  run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[]): Promise<SessionRunResult>;
36
37
  resume(sessionId: string, prompt: string, signal?: AbortSignal, name?: string, images?: readonly ModelImage[]): Promise<SessionRunResult>;
38
+ steer?(sessionId: string, content: string): ActiveTurnInputCommandResult;
39
+ withdrawSteering?(sessionId: string, id: string): ActiveTurnInputCommandResult;
37
40
  runShell?(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
38
41
  resumeShell?(sessionId: string, command: string, signal?: AbortSignal): Promise<SessionRunResult>;
39
42
  fork(sessionId: string, targetSessionId?: string, resumeSessionAt?: string): Promise<ForkResult>;
@@ -375,6 +375,15 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
375
375
  const inputHistoryRef = useRef([]);
376
376
  const inputHistoryIndexRef = useRef(null);
377
377
  const inputHistoryDraftRef = useRef('');
378
+ const [pendingInputs, setPendingInputs] = useState([]);
379
+ const pendingInputsRef = useRef(pendingInputs);
380
+ pendingInputsRef.current = pendingInputs;
381
+ const followUpQueueRef = useRef([]);
382
+ const updatePendingInputs = (update) => {
383
+ const next = typeof update === 'function' ? update(pendingInputsRef.current) : update;
384
+ pendingInputsRef.current = next;
385
+ setPendingInputs(next);
386
+ };
378
387
  const undoStackRef = useRef([]);
379
388
  const composerImagesRef = useRef(new Map());
380
389
  const nextImageIdRef = useRef(1);
@@ -1728,6 +1737,30 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1728
1737
  inputHistoryIndexRef.current = nextIndex;
1729
1738
  updateComposerInput(history[nextIndex] ?? '');
1730
1739
  };
1740
+ const withdrawLatestPending = () => {
1741
+ if (inputRef.current.trim().length !== 0)
1742
+ return false;
1743
+ const pending = pendingInputsRef.current.at(-1);
1744
+ if (!pending)
1745
+ return false;
1746
+ if (pending.kind === 'follow-up') {
1747
+ followUpQueueRef.current = followUpQueueRef.current.filter((item) => item.id !== pending.id);
1748
+ updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
1749
+ updateComposerInput(pending.text);
1750
+ return true;
1751
+ }
1752
+ const activeSessionId = sessionIdRef.current;
1753
+ const result = activeSessionId
1754
+ ? serviceRef.current?.withdrawSteering?.(activeSessionId, pending.id)
1755
+ : undefined;
1756
+ if (result?.kind === 'withdrawn') {
1757
+ updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
1758
+ updateComposerInput(result.item.content);
1759
+ return true;
1760
+ }
1761
+ append({ kind: 'warning', text: 'Input is already delivered.' });
1762
+ return true;
1763
+ };
1731
1764
  const dismissExitConfirmation = () => {
1732
1765
  if (!exitConfirmation)
1733
1766
  return;
@@ -1781,6 +1814,33 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1781
1814
  case 'user-message':
1782
1815
  append({ kind: 'assistant', text: event.message });
1783
1816
  break;
1817
+ case 'user-input-delivered':
1818
+ // Steering is delivered after the preceding assistant batch has been
1819
+ // persisted. Commit that batch to the visible transcript before the
1820
+ // user row, then start the next provider continuation with a fresh
1821
+ // active buffer. Otherwise the active row would appear after the
1822
+ // steering message and be discarded when the turn finally completes.
1823
+ if ((streamingFrameRef.current?.text.trim().length ?? 0) > 0) {
1824
+ append({
1825
+ kind: 'assistant',
1826
+ text: streamingFrameRef.current?.text ?? '',
1827
+ });
1828
+ }
1829
+ streamingFrameRef.current?.resetText();
1830
+ streamingFrameRef.current?.resetThinking();
1831
+ streamingFrameRef.current?.flush();
1832
+ updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
1833
+ append({ kind: 'user', text: event.content });
1834
+ break;
1835
+ case 'user-input-rejected':
1836
+ updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
1837
+ if (inputRef.current.trim().length === 0)
1838
+ updateComposerInput(event.content);
1839
+ append({
1840
+ kind: 'warning',
1841
+ text: `Input not delivered · ${redactSensitiveText(event.content, sensitiveValues)}`,
1842
+ });
1843
+ break;
1784
1844
  case 'state':
1785
1845
  if (event.state === 'awaiting-model') {
1786
1846
  activeAttemptThinkingItemsRef.current = [];
@@ -3212,7 +3272,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3212
3272
  onTurnChange?.(restoring);
3213
3273
  void restoring.finally(() => onTurnChange?.(null));
3214
3274
  };
3215
- const submit = async (prompt, shellCommand, images = []) => {
3275
+ const submitTurn = async (prompt, shellCommand, images = [], followUpId, internal = false) => {
3216
3276
  setTranscriptScrollOffset(0);
3217
3277
  const turnNumber = turnNumberRef.current + 1;
3218
3278
  const turnStartedAt = Date.now();
@@ -3227,6 +3287,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3227
3287
  ? AbortSignal.any([signal, turnController.signal])
3228
3288
  : turnController.signal;
3229
3289
  setBusy(true);
3290
+ if (followUpId !== undefined) {
3291
+ updatePendingInputs((current) => current.filter((item) => item.id !== followUpId));
3292
+ }
3230
3293
  setTurnDuration(undefined);
3231
3294
  setCommandPaletteOpen(false);
3232
3295
  const submittedCommandName = /^\/([^\s]+)/u.exec(prompt)?.[1]?.toLowerCase();
@@ -3245,9 +3308,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3245
3308
  else
3246
3309
  turnMutatedFilesRef.current = true;
3247
3310
  let commands;
3311
+ let turnSucceeded = false;
3248
3312
  try {
3249
3313
  commands = await service();
3250
- let activeSessionId = sessionId;
3314
+ let activeSessionId = sessionIdRef.current;
3251
3315
  const startedNewSession = activeSessionId === null;
3252
3316
  if (activeSessionId === null) {
3253
3317
  activeSessionId = randomUUID();
@@ -3316,7 +3380,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3316
3380
  streamingFrameRef.current?.flush();
3317
3381
  setStatus('ready');
3318
3382
  setTurnDuration(Date.now() - turnStartedAt);
3319
- if (runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3383
+ if (!internal &&
3384
+ followUpQueueRef.current.length === 0 &&
3385
+ runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3320
3386
  notifyTerminal({
3321
3387
  channel: runtimeSettingsRef.current.notifChannel,
3322
3388
  title: 'Praxis',
@@ -3326,6 +3392,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3326
3392
  : { write: notificationWriter }),
3327
3393
  });
3328
3394
  }
3395
+ turnSucceeded = true;
3329
3396
  }
3330
3397
  catch (error) {
3331
3398
  if (turnController.signal.aborted && !signal?.aborted) {
@@ -3343,7 +3410,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3343
3410
  }
3344
3411
  }
3345
3412
  finally {
3346
- if (!factory.scheduledPrompts && commands) {
3413
+ if (!internal && !factory.scheduledPrompts && commands) {
3347
3414
  try {
3348
3415
  await commands.close?.();
3349
3416
  }
@@ -3360,6 +3427,62 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3360
3427
  }
3361
3428
  if (turnControllerRef.current === turnController)
3362
3429
  turnControllerRef.current = null;
3430
+ if (!internal) {
3431
+ setBusy(false);
3432
+ }
3433
+ }
3434
+ return turnSucceeded;
3435
+ };
3436
+ const submit = async (prompt, shellCommand, images = [], followUpId) => {
3437
+ let currentPrompt = prompt;
3438
+ let currentShellCommand = shellCommand;
3439
+ let currentImages = images;
3440
+ let currentFollowUpId = followUpId;
3441
+ let completed = false;
3442
+ try {
3443
+ while (true) {
3444
+ completed = await submitTurn(currentPrompt, currentShellCommand, currentImages, currentFollowUpId, true);
3445
+ if (!completed)
3446
+ break;
3447
+ const next = followUpQueueRef.current.shift();
3448
+ if (!next)
3449
+ break;
3450
+ currentPrompt = next.text;
3451
+ currentShellCommand = undefined;
3452
+ currentImages = [];
3453
+ currentFollowUpId = next.id;
3454
+ }
3455
+ if (completed &&
3456
+ runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3457
+ notifyTerminal({
3458
+ channel: runtimeSettingsRef.current.notifChannel,
3459
+ title: 'Praxis',
3460
+ message: 'Turn complete',
3461
+ ...(notificationWriter === undefined
3462
+ ? {}
3463
+ : { write: notificationWriter }),
3464
+ });
3465
+ }
3466
+ }
3467
+ finally {
3468
+ if (!factory.scheduledPrompts) {
3469
+ const commands = serviceRef.current;
3470
+ if (commands) {
3471
+ try {
3472
+ await commands.close?.();
3473
+ }
3474
+ catch (error) {
3475
+ append({
3476
+ kind: 'warning',
3477
+ text: redactSensitiveText(error instanceof Error ? error.message : String(error), sensitiveValues),
3478
+ });
3479
+ }
3480
+ finally {
3481
+ if (serviceRef.current === commands)
3482
+ serviceRef.current = null;
3483
+ }
3484
+ }
3485
+ }
3363
3486
  setBusy(false);
3364
3487
  }
3365
3488
  };
@@ -5890,6 +6013,94 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5890
6013
  void backgrounding;
5891
6014
  return;
5892
6015
  }
6016
+ const pendingText = inputRef.current.trim();
6017
+ const addPending = (item) => updatePendingInputs((current) => [...current, item]);
6018
+ if (key.upArrow && pendingText.length === 0) {
6019
+ withdrawLatestPending();
6020
+ return;
6021
+ }
6022
+ if (key.return && key.shift) {
6023
+ updateComposerEditor(insertComposerText(editor(), '\n'));
6024
+ return;
6025
+ }
6026
+ if (isKeybinding('chat:submit') && !key.shift && !key.meta && !key.ctrl) {
6027
+ if (!pendingText)
6028
+ return;
6029
+ const controller = turnControllerRef.current;
6030
+ if (!controller) {
6031
+ append({
6032
+ kind: 'warning',
6033
+ text: 'Turn is completing; input was retained.',
6034
+ });
6035
+ return;
6036
+ }
6037
+ const restoreSteeringText = () => {
6038
+ const current = inputRef.current;
6039
+ updateComposerInput(current.trim().length === 0
6040
+ ? pendingText
6041
+ : `${pendingText}\n${current}`);
6042
+ };
6043
+ // Service construction may still be in flight while the busy composer
6044
+ // is already visible. Clear now so newly typed text becomes a distinct
6045
+ // draft; restore both drafts if the active-turn command loses a race.
6046
+ clearComposerInput();
6047
+ const steer = async () => {
6048
+ const commands = serviceRef.current ?? (await service());
6049
+ if (turnControllerRef.current !== controller ||
6050
+ controller.signal.aborted) {
6051
+ restoreSteeringText();
6052
+ append({
6053
+ kind: 'warning',
6054
+ text: 'Active turn changed; input was retained.',
6055
+ });
6056
+ return;
6057
+ }
6058
+ const activeSessionId = sessionIdRef.current;
6059
+ const result = activeSessionId
6060
+ ? commands.steer?.(activeSessionId, pendingText)
6061
+ : undefined;
6062
+ if (result?.kind === 'accepted') {
6063
+ addPending({
6064
+ id: result.item.id,
6065
+ kind: 'steering',
6066
+ text: result.item.content,
6067
+ });
6068
+ }
6069
+ else {
6070
+ restoreSteeringText();
6071
+ append({
6072
+ kind: 'warning',
6073
+ text: result?.kind === 'turn-completing'
6074
+ ? 'Turn is completing; input was retained.'
6075
+ : result?.kind === 'not-steerable'
6076
+ ? 'This turn cannot be steered; input was retained.'
6077
+ : 'Steering is unavailable; input was retained.',
6078
+ });
6079
+ }
6080
+ };
6081
+ void steer().catch((error) => {
6082
+ restoreSteeringText();
6083
+ warn(error);
6084
+ });
6085
+ return;
6086
+ }
6087
+ if (key.tab || (key.return && key.meta)) {
6088
+ if (!pendingText)
6089
+ return;
6090
+ if (!turnControllerRef.current) {
6091
+ append({
6092
+ kind: 'warning',
6093
+ text: 'Turn is completing; follow-up input was retained.',
6094
+ });
6095
+ return;
6096
+ }
6097
+ const item = { id: randomUUID(), text: pendingText };
6098
+ followUpQueueRef.current.push(item);
6099
+ addPending({ id: item.id, kind: 'follow-up', text: item.text });
6100
+ clearComposerInput();
6101
+ return;
6102
+ }
6103
+ editComposer();
5893
6104
  return;
5894
6105
  }
5895
6106
  if (isKeybinding('chat:imagePaste')) {
@@ -6571,6 +6782,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6571
6782
  }
6572
6783
  return;
6573
6784
  }
6785
+ if (isKeybinding('history:previous') &&
6786
+ inputRef.current.length === 0 &&
6787
+ withdrawLatestPending()) {
6788
+ return;
6789
+ }
6574
6790
  if (isKeybinding('history:previous')) {
6575
6791
  restorePromptHistory('previous');
6576
6792
  return;
@@ -6664,6 +6880,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6664
6880
  shellMode,
6665
6881
  busy,
6666
6882
  status: conciseStatus,
6883
+ pendingItems: pendingInputs,
6667
6884
  display: runtimeDisplay,
6668
6885
  }), [
6669
6886
  screen,
@@ -6672,6 +6889,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6672
6889
  inputCursor,
6673
6890
  busy,
6674
6891
  conciseStatus,
6892
+ pendingInputs,
6675
6893
  runtimeDisplay,
6676
6894
  ]);
6677
6895
  const terminalSelectionContext = useMemo(() => createTerminalSelectionContext(quietFrame, conversationScreen?.transcript.rows.length ?? 0, transcriptScrollOffset, maxTranscriptScrollOffset), [
@@ -1,7 +1,7 @@
1
1
  import type { TuiScreenModel } from './tui-screen-model.js';
2
2
  import type { TuiRow, TuiTextRole } from './tui-row-ir.js';
3
3
  export type QuietFrameDensity = 'full' | 'standard' | 'compact' | 'narrow' | 'minimal';
4
- export type QuietFrameRegion = 'identity' | 'transcript' | 'active' | 'focus' | 'composer' | 'status';
4
+ export type QuietFrameRegion = 'identity' | 'transcript' | 'active' | 'pending' | 'focus' | 'composer' | 'status';
5
5
  export interface QuietFrameRow extends TuiRow {
6
6
  readonly region: QuietFrameRegion;
7
7
  readonly accessibleText?: string;
@@ -26,6 +26,11 @@ export interface QuietFrameInput {
26
26
  readonly shellMode: boolean;
27
27
  readonly busy: boolean;
28
28
  readonly status: string;
29
+ readonly pendingItems?: readonly {
30
+ readonly id: string;
31
+ readonly kind: 'steering' | 'follow-up';
32
+ readonly text: string;
33
+ }[];
29
34
  readonly display?: {
30
35
  readonly cwd?: string;
31
36
  readonly model?: string;
@@ -280,6 +280,10 @@ export function projectQuietFrame(input) {
280
280
  thinking.lines.forEach(({ text: part, start }, index) => lines.push(createQuietFrameRow(`quiet:active:thinking:${start}`, `${index === 0 ? '✻ ' : ' '}${part}`, 'active', 'muted', `${index === 0 ? 'Thinking: ' : ''}${part}`)));
281
281
  }
282
282
  }
283
+ for (const item of input.pendingItems ?? []) {
284
+ const label = item.kind === 'steering' ? 'steer' : 'follow-up';
285
+ lines.push(createQuietFrameRow(`quiet:pending:${item.id}`, `${label} · ${clean(item.text)}`, 'pending', 'muted', `${label} pending input: ${clean(item.text)}`));
286
+ }
283
287
  const focus = input.focusRows.length > 0
284
288
  ? input.focusRows.map((item) => withRegion(item, 'focus'))
285
289
  : [
@@ -19,6 +19,7 @@ export interface QuietScreenProjectionInput {
19
19
  readonly shellMode: boolean;
20
20
  readonly busy: boolean;
21
21
  readonly status: string;
22
+ readonly pendingItems?: QuietFrameInput['pendingItems'];
22
23
  readonly display?: QuietFrameInput['display'];
23
24
  }
24
25
  export declare function projectQuietScreenFrame(input: QuietScreenProjectionInput): QuietFrame;
@@ -103,6 +103,9 @@ export function projectQuietScreenFrame(input) {
103
103
  shellMode: input.shellMode,
104
104
  busy: input.busy,
105
105
  status: input.status,
106
+ ...(input.pendingItems === undefined
107
+ ? {}
108
+ : { pendingItems: input.pendingItems }),
106
109
  ...(input.display === undefined ? {} : { display: input.display }),
107
110
  focusRows,
108
111
  });
@@ -71,7 +71,7 @@ export function routeTuiInteraction(snapshot, input) {
71
71
  }
72
72
  if (input.action === 'chat:cancel')
73
73
  return handled([...confirmationEffects, { kind: 'interrupt-turn' }]);
74
- return handled(confirmationEffects);
74
+ return delegated(confirmationEffects);
75
75
  }
76
76
  if (input.callerIntent !== 'none')
77
77
  return delegated(confirmationEffects);
@@ -0,0 +1,63 @@
1
+ /** A user input item accepted by an active, steerable turn. */
2
+ export interface SteeringItem {
3
+ readonly id: string;
4
+ readonly content: string;
5
+ }
6
+ export type SteeringEnqueueResult = {
7
+ readonly kind: 'accepted';
8
+ readonly item: SteeringItem;
9
+ } | {
10
+ readonly kind: 'empty';
11
+ } | {
12
+ readonly kind: 'sealed';
13
+ };
14
+ export type SteeringWithdrawResult = {
15
+ readonly kind: 'withdrawn';
16
+ readonly item: SteeringItem;
17
+ } | {
18
+ readonly kind: 'not-pending';
19
+ };
20
+ /** Results returned by the session-level steering commands. */
21
+ export type ActiveTurnInputCommandResult = {
22
+ readonly kind: 'accepted';
23
+ readonly item: SteeringItem;
24
+ } | {
25
+ readonly kind: 'withdrawn';
26
+ readonly item: SteeringItem;
27
+ } | {
28
+ readonly kind: 'empty';
29
+ } | {
30
+ readonly kind: 'no-active-turn';
31
+ } | {
32
+ readonly kind: 'not-steerable';
33
+ } | {
34
+ readonly kind: 'turn-completing';
35
+ } | {
36
+ readonly kind: 'not-pending';
37
+ };
38
+ /** The synchronous port exposed to the runtime at safe continuation points. */
39
+ export interface ActiveTurnInputPort {
40
+ take(): SteeringItem | undefined;
41
+ takeOrSeal(): SteeringItem | undefined;
42
+ /** Drain and seal the port, returning every item that was not delivered. */
43
+ close(): readonly SteeringItem[];
44
+ }
45
+ /**
46
+ * A small synchronous FIFO. Keeping all state transitions synchronous makes
47
+ * enqueue/take-or-seal atomic with respect to the runtime's await boundaries.
48
+ */
49
+ export declare class ActiveTurnInputMailbox implements ActiveTurnInputPort {
50
+ private readonly createId;
51
+ private readonly items;
52
+ private sealed;
53
+ constructor(createId: () => string);
54
+ enqueue(content: string): SteeringEnqueueResult;
55
+ take(): SteeringItem | undefined;
56
+ /** Take one item, or seal an empty mailbox before returning. */
57
+ takeOrSeal(): SteeringItem | undefined;
58
+ withdraw(id: string): SteeringWithdrawResult;
59
+ close(): readonly SteeringItem[];
60
+ isSealed(): boolean;
61
+ get pendingCount(): number;
62
+ }
63
+ //# sourceMappingURL=active-turn-input.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A small synchronous FIFO. Keeping all state transitions synchronous makes
3
+ * enqueue/take-or-seal atomic with respect to the runtime's await boundaries.
4
+ */
5
+ export class ActiveTurnInputMailbox {
6
+ createId;
7
+ items = [];
8
+ sealed = false;
9
+ constructor(createId) {
10
+ this.createId = createId;
11
+ }
12
+ enqueue(content) {
13
+ const trimmed = content.trim();
14
+ if (trimmed.length === 0)
15
+ return { kind: 'empty' };
16
+ if (this.sealed)
17
+ return { kind: 'sealed' };
18
+ const item = { id: this.createId(), content: trimmed };
19
+ this.items.push(item);
20
+ return { kind: 'accepted', item };
21
+ }
22
+ take() {
23
+ return this.items.shift();
24
+ }
25
+ /** Take one item, or seal an empty mailbox before returning. */
26
+ takeOrSeal() {
27
+ const item = this.items.shift();
28
+ if (item !== undefined)
29
+ return item;
30
+ this.sealed = true;
31
+ return undefined;
32
+ }
33
+ withdraw(id) {
34
+ const index = this.items.findIndex((item) => item.id === id);
35
+ if (index < 0)
36
+ return { kind: 'not-pending' };
37
+ const [item] = this.items.splice(index, 1);
38
+ return item === undefined
39
+ ? { kind: 'not-pending' }
40
+ : { kind: 'withdrawn', item };
41
+ }
42
+ close() {
43
+ this.sealed = true;
44
+ return this.items.splice(0);
45
+ }
46
+ isSealed() {
47
+ return this.sealed;
48
+ }
49
+ get pendingCount() {
50
+ return this.items.length;
51
+ }
52
+ }
53
+ //# sourceMappingURL=active-turn-input.js.map
@@ -1,3 +1,4 @@
1
+ import type { ActiveTurnInputPort, SteeringItem } from './active-turn-input.js';
1
2
  export type RuntimeState = 'idle' | 'assembling-context' | 'compacting' | 'awaiting-model' | 'streaming' | 'awaiting-permission' | 'executing-tools' | 'persisting-results' | 'completed' | 'cancelled' | 'failed';
2
3
  export type ModelMessage = {
3
4
  role: 'system';
@@ -175,6 +176,15 @@ export type RuntimeEvent = {
175
176
  message: string;
176
177
  attachments?: readonly string[];
177
178
  status: 'normal' | 'proactive';
179
+ } | {
180
+ type: 'user-input-delivered';
181
+ id: string;
182
+ content: string;
183
+ } | {
184
+ type: 'user-input-rejected';
185
+ id: string;
186
+ content: string;
187
+ reason: 'cancelled' | 'failed' | 'closed';
178
188
  } | {
179
189
  type: 'usage';
180
190
  usage: ModelUsage;
@@ -434,6 +444,7 @@ export interface AgentRunObserver {
434
444
  }): Promise<void>;
435
445
  toolCompleted(call: ModelToolCall, result: ToolExecutionResult): Promise<void>;
436
446
  followUpUserMessagesCompleted?(messages: readonly string[]): Promise<void>;
447
+ userInputDelivered?(item: SteeringItem): Promise<void>;
437
448
  }
438
449
  export interface ToolUseSummaryOutcome {
439
450
  summary: string | null;
@@ -471,6 +482,7 @@ export interface AgentRunRequest {
471
482
  cwd?: string;
472
483
  toolResultDirectory?: string;
473
484
  observer?: AgentRunObserver;
485
+ steering?: ActiveTurnInputPort;
474
486
  reloadMessages?: () => Promise<readonly ModelMessage[]>;
475
487
  approveTool?: (call: ModelToolCall, originalCall?: ModelToolCall, decision?: PermissionDecision) => PermissionApproval | Promise<PermissionApproval>;
476
488
  permissionUpdates?: readonly PermissionUpdate[];
@@ -255,13 +255,58 @@ export class AgentRuntime {
255
255
  this.options = options;
256
256
  }
257
257
  async run(request) {
258
+ const steering = request.steering;
259
+ const rejectSteering = (reason) => {
260
+ for (const item of steering?.close() ?? []) {
261
+ this.emit({
262
+ type: 'user-input-rejected',
263
+ id: item.id,
264
+ content: item.content,
265
+ reason,
266
+ });
267
+ }
268
+ };
269
+ const cancelWithSteering = () => {
270
+ rejectSteering('cancelled');
271
+ return this.cancel();
272
+ };
273
+ const deliverSteering = async (item) => {
274
+ try {
275
+ if (!request.observer?.userInputDelivered) {
276
+ throw new Error('Active-turn steering requires a durable delivery observer');
277
+ }
278
+ await request.observer.userInputDelivered(item);
279
+ }
280
+ catch (error) {
281
+ this.emit({
282
+ type: 'user-input-rejected',
283
+ id: item.id,
284
+ content: item.content,
285
+ reason: 'failed',
286
+ });
287
+ throw error;
288
+ }
289
+ messages.push({ role: 'user', content: item.content });
290
+ this.emit({
291
+ type: 'user-input-delivered',
292
+ id: item.id,
293
+ content: item.content,
294
+ });
295
+ };
296
+ const deliverNextSteering = async () => {
297
+ const item = steering?.take();
298
+ if (!item)
299
+ return false;
300
+ await deliverSteering(item);
301
+ return true;
302
+ };
258
303
  const failurePresentationDeferred = (kind) => request.deferFailureKinds === true ||
259
304
  request.deferFailureKinds?.includes(kind) === true;
260
305
  if (this.options.emitInitialContextState !== false) {
261
306
  this.emit({ type: 'state', state: 'assembling-context' });
262
307
  }
263
308
  if (request.signal?.aborted)
264
- return this.cancel();
309
+ return cancelWithSteering();
265
310
  const messages = [...request.messages];
266
311
  let usage = emptyUsage();
267
312
  let modelUsage = emptyUsage();
@@ -295,7 +340,7 @@ export class AgentRuntime {
295
340
  activeAttemptHasPresentation = false;
296
341
  activeAttemptDiscarded = false;
297
342
  if (request.signal?.aborted)
298
- return this.cancel();
343
+ return cancelWithSteering();
299
344
  if (maxModelTurns !== undefined && modelTurns >= maxModelTurns) {
300
345
  throw new Error(`Maximum model turns of ${maxModelTurns} exceeded`);
301
346
  }
@@ -399,7 +444,7 @@ export class AgentRuntime {
399
444
  try {
400
445
  for await (const event of this.provider.complete(providerRequest)) {
401
446
  if (request.signal?.aborted)
402
- return this.cancel();
447
+ return cancelWithSteering();
403
448
  if (terminalReason !== undefined) {
404
449
  throw new ModelProviderError(`Provider emitted ${event.type} after terminal reason ${terminalReason}`, { retryable: false });
405
450
  }
@@ -499,7 +544,7 @@ export class AgentRuntime {
499
544
  }
500
545
  await toolScheduler.settle().catch(() => undefined);
501
546
  if (request.signal?.aborted)
502
- return this.cancel();
547
+ return cancelWithSteering();
503
548
  throw error;
504
549
  }
505
550
  finally {
@@ -582,6 +627,11 @@ export class AgentRuntime {
582
627
  messages.push(assistantMessage);
583
628
  toolScheduler.releaseExclusiveTools();
584
629
  if (toolCalls.length === 0) {
630
+ const preStopSteering = steering?.take();
631
+ if (preStopSteering !== undefined) {
632
+ await deliverSteering(preStopSteering);
633
+ continue;
634
+ }
585
635
  const stopResult = (await request.onStop?.(text)) ?? [];
586
636
  const stopBatch = Array.isArray(stopResult)
587
637
  ? null
@@ -617,11 +667,16 @@ export class AgentRuntime {
617
667
  if (request.reloadMessages) {
618
668
  const reloadedMessages = await request.reloadMessages();
619
669
  if (request.signal?.aborted)
620
- return this.cancel();
670
+ return cancelWithSteering();
621
671
  messages.splice(0, messages.length, ...reloadedMessages);
622
672
  }
623
673
  continue;
624
674
  }
675
+ const completionInput = steering?.takeOrSeal();
676
+ if (completionInput !== undefined) {
677
+ await deliverSteering(completionInput);
678
+ continue;
679
+ }
625
680
  this.emit({ type: 'state', state: 'completed' });
626
681
  const modelUsage = modelUsageByModel.size === 0
627
682
  ? undefined
@@ -709,14 +764,18 @@ export class AgentRuntime {
709
764
  if (request.reloadMessages) {
710
765
  const reloadedMessages = await request.reloadMessages();
711
766
  if (request.signal?.aborted)
712
- return this.cancel();
767
+ return cancelWithSteering();
713
768
  messages.splice(0, messages.length, ...reloadedMessages);
714
769
  }
770
+ await deliverNextSteering();
715
771
  }
716
772
  }
717
773
  catch (error) {
718
- if (request.signal?.aborted)
774
+ if (request.signal?.aborted) {
775
+ rejectSteering('cancelled');
719
776
  return this.cancel();
777
+ }
778
+ rejectSteering('failed');
720
779
  const message = error instanceof Error ? error.message : String(error);
721
780
  const retryable = error instanceof ModelProviderError ? error.retryable : false;
722
781
  const kind = error instanceof ModelProviderError
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.45.4",
3
+ "version": "0.46.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",