praxis-agent 0.45.4 → 0.46.1

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>;
@@ -44,6 +44,7 @@ import { projectTuiCommandPalette, tuiCommandPaletteCommandId, } from './tui/com
44
44
  import { runDoctor, } from '../maintenance/doctor.js';
45
45
  import { canonicalClaudeCostModelName, formatCostSummary, } from './tui/cost-summary.js';
46
46
  import { createComposerEditor, deleteComposerBackward, deleteComposerForward, insertComposerText, moveComposerCursor, } from './tui/composer-editor.js';
47
+ import { createComposerPromptHistory, navigateComposerPromptHistory, recordComposerPrompt, resetComposerPromptHistoryNavigation, seedComposerPromptHistory, } from './tui/composer-prompt-history.js';
47
48
  import { routeComposerKey, } from './tui/composer-key-router.js';
48
49
  import { routeTuiInteraction, } from './tui/tui-interaction-router.js';
49
50
  import { currentTuiInteractionLayer, projectTuiFocusStack, } from './tui/tui-focus-stack.js';
@@ -239,6 +240,9 @@ const HIDDEN_TUI_SLASH_COMMANDS = new Set([
239
240
  'update',
240
241
  'usage',
241
242
  ]);
243
+ function transcriptPrompts(items) {
244
+ return items.flatMap((item) => (item.kind === 'user' ? [item.text] : []));
245
+ }
242
246
  /** Advances the React-owned transcript plus its exact local mutation fact. */
243
247
  export function advanceInteractiveHistoryState(current, items, changedFrom) {
244
248
  if (!Number.isInteger(changedFrom) ||
@@ -372,9 +376,16 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
372
376
  const [pendingFork, setPendingFork] = useState(resume?.forkSession === true);
373
377
  const inputRef = useRef('');
374
378
  const inputCursorRef = useRef(0);
375
- const inputHistoryRef = useRef([]);
376
- const inputHistoryIndexRef = useRef(null);
377
- const inputHistoryDraftRef = useRef('');
379
+ const promptHistoryRef = useRef(seedComposerPromptHistory(transcriptPrompts(initialHistory)));
380
+ const [pendingInputs, setPendingInputs] = useState([]);
381
+ const pendingInputsRef = useRef(pendingInputs);
382
+ pendingInputsRef.current = pendingInputs;
383
+ const followUpQueueRef = useRef([]);
384
+ const updatePendingInputs = (update) => {
385
+ const next = typeof update === 'function' ? update(pendingInputsRef.current) : update;
386
+ pendingInputsRef.current = next;
387
+ setPendingInputs(next);
388
+ };
378
389
  const undoStackRef = useRef([]);
379
390
  const composerImagesRef = useRef(new Map());
380
391
  const nextImageIdRef = useRef(1);
@@ -1457,6 +1468,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1457
1468
  fileSelectionRef.current = 0;
1458
1469
  };
1459
1470
  const updateComposerEditor = (editor, recordUndo = true) => {
1471
+ promptHistoryRef.current = resetComposerPromptHistoryNavigation(promptHistoryRef.current);
1460
1472
  if (recordUndo && editor.text !== inputRef.current) {
1461
1473
  undoStackRef.current = [
1462
1474
  ...undoStackRef.current,
@@ -1465,7 +1477,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1465
1477
  }
1466
1478
  updateComposerInput(editor.text, editor.cursor);
1467
1479
  };
1468
- const clearComposerInput = () => updateComposerInput('');
1480
+ const clearComposerInput = () => {
1481
+ promptHistoryRef.current = resetComposerPromptHistoryNavigation(promptHistoryRef.current);
1482
+ updateComposerInput('');
1483
+ };
1469
1484
  const promptImages = (prompt) => {
1470
1485
  const seen = new Set();
1471
1486
  return composerImageIds(prompt).flatMap((id) => {
@@ -1697,36 +1712,37 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1697
1712
  return () => clearInterval(timer);
1698
1713
  }, [menu?.kind]);
1699
1714
  const appendPromptHistory = (prompt) => {
1700
- if (!prompt)
1701
- return;
1702
- const history = inputHistoryRef.current.filter((item) => item !== prompt);
1703
- inputHistoryRef.current = [prompt, ...history].slice(0, 100);
1704
- inputHistoryIndexRef.current = null;
1705
- inputHistoryDraftRef.current = '';
1715
+ promptHistoryRef.current = recordComposerPrompt(promptHistoryRef.current, prompt);
1706
1716
  };
1707
1717
  const restorePromptHistory = (direction) => {
1708
- const history = inputHistoryRef.current;
1709
- if (history.length === 0)
1710
- return;
1711
- const currentIndex = inputHistoryIndexRef.current;
1712
- if (direction === 'previous') {
1713
- if (currentIndex === null)
1714
- inputHistoryDraftRef.current = inputRef.current;
1715
- const nextIndex = Math.min(history.length - 1, (currentIndex ?? -1) + 1);
1716
- inputHistoryIndexRef.current = nextIndex;
1717
- updateComposerInput(history[nextIndex] ?? '');
1718
- return;
1718
+ const transition = navigateComposerPromptHistory(promptHistoryRef.current, direction, createComposerEditor(inputRef.current, inputCursorRef.current));
1719
+ promptHistoryRef.current = transition.state;
1720
+ if (transition.editor !== null)
1721
+ updateComposerInput(transition.editor.text, transition.editor.cursor);
1722
+ };
1723
+ const withdrawLatestPending = () => {
1724
+ if (inputRef.current.trim().length !== 0)
1725
+ return false;
1726
+ const pending = pendingInputsRef.current.at(-1);
1727
+ if (!pending)
1728
+ return false;
1729
+ if (pending.kind === 'follow-up') {
1730
+ followUpQueueRef.current = followUpQueueRef.current.filter((item) => item.id !== pending.id);
1731
+ updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
1732
+ updateComposerInput(pending.text);
1733
+ return true;
1719
1734
  }
1720
- if (currentIndex === null)
1721
- return;
1722
- const nextIndex = currentIndex - 1;
1723
- if (nextIndex < 0) {
1724
- inputHistoryIndexRef.current = null;
1725
- updateComposerInput(inputHistoryDraftRef.current);
1726
- return;
1735
+ const activeSessionId = sessionIdRef.current;
1736
+ const result = activeSessionId
1737
+ ? serviceRef.current?.withdrawSteering?.(activeSessionId, pending.id)
1738
+ : undefined;
1739
+ if (result?.kind === 'withdrawn') {
1740
+ updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
1741
+ updateComposerInput(result.item.content);
1742
+ return true;
1727
1743
  }
1728
- inputHistoryIndexRef.current = nextIndex;
1729
- updateComposerInput(history[nextIndex] ?? '');
1744
+ append({ kind: 'warning', text: 'Input is already delivered.' });
1745
+ return true;
1730
1746
  };
1731
1747
  const dismissExitConfirmation = () => {
1732
1748
  if (!exitConfirmation)
@@ -1781,6 +1797,33 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1781
1797
  case 'user-message':
1782
1798
  append({ kind: 'assistant', text: event.message });
1783
1799
  break;
1800
+ case 'user-input-delivered':
1801
+ // Steering is delivered after the preceding assistant batch has been
1802
+ // persisted. Commit that batch to the visible transcript before the
1803
+ // user row, then start the next provider continuation with a fresh
1804
+ // active buffer. Otherwise the active row would appear after the
1805
+ // steering message and be discarded when the turn finally completes.
1806
+ if ((streamingFrameRef.current?.text.trim().length ?? 0) > 0) {
1807
+ append({
1808
+ kind: 'assistant',
1809
+ text: streamingFrameRef.current?.text ?? '',
1810
+ });
1811
+ }
1812
+ streamingFrameRef.current?.resetText();
1813
+ streamingFrameRef.current?.resetThinking();
1814
+ streamingFrameRef.current?.flush();
1815
+ updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
1816
+ append({ kind: 'user', text: event.content });
1817
+ break;
1818
+ case 'user-input-rejected':
1819
+ updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
1820
+ if (inputRef.current.trim().length === 0)
1821
+ updateComposerInput(event.content);
1822
+ append({
1823
+ kind: 'warning',
1824
+ text: `Input not delivered · ${redactSensitiveText(event.content, sensitiveValues)}`,
1825
+ });
1826
+ break;
1784
1827
  case 'state':
1785
1828
  if (event.state === 'awaiting-model') {
1786
1829
  activeAttemptThinkingItemsRef.current = [];
@@ -2193,10 +2236,12 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2193
2236
  const loadId = sessionLoadRef.current + 1;
2194
2237
  sessionLoadRef.current = loadId;
2195
2238
  if (nextSessionId === null) {
2239
+ promptHistoryRef.current = createComposerPromptHistory();
2196
2240
  setHistory([]);
2197
2241
  setSessionColor(undefined);
2198
2242
  return;
2199
2243
  }
2244
+ promptHistoryRef.current = createComposerPromptHistory();
2200
2245
  const loading = (async () => {
2201
2246
  try {
2202
2247
  const commands = await service();
@@ -2206,6 +2251,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2206
2251
  : await commands.agentColor(nextSessionId);
2207
2252
  if (sessionLoadRef.current === loadId) {
2208
2253
  setHistory(transcript ? [...transcript] : []);
2254
+ promptHistoryRef.current = seedComposerPromptHistory(transcriptPrompts(transcript ?? []));
2209
2255
  setSessionColor(agentColor);
2210
2256
  }
2211
2257
  }
@@ -3189,6 +3235,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3189
3235
  setSessionId(restoredConversation.sessionId || null);
3190
3236
  setPendingFork(false);
3191
3237
  setHistory(restoredConversation.history);
3238
+ promptHistoryRef.current = seedComposerPromptHistory(transcriptPrompts(restoredConversation.history));
3192
3239
  updateComposerInput(point.prompt);
3193
3240
  }
3194
3241
  append({
@@ -3212,7 +3259,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3212
3259
  onTurnChange?.(restoring);
3213
3260
  void restoring.finally(() => onTurnChange?.(null));
3214
3261
  };
3215
- const submit = async (prompt, shellCommand, images = []) => {
3262
+ const submitTurn = async (prompt, shellCommand, images = [], followUpId, internal = false) => {
3216
3263
  setTranscriptScrollOffset(0);
3217
3264
  const turnNumber = turnNumberRef.current + 1;
3218
3265
  const turnStartedAt = Date.now();
@@ -3227,6 +3274,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3227
3274
  ? AbortSignal.any([signal, turnController.signal])
3228
3275
  : turnController.signal;
3229
3276
  setBusy(true);
3277
+ if (followUpId !== undefined) {
3278
+ updatePendingInputs((current) => current.filter((item) => item.id !== followUpId));
3279
+ }
3230
3280
  setTurnDuration(undefined);
3231
3281
  setCommandPaletteOpen(false);
3232
3282
  const submittedCommandName = /^\/([^\s]+)/u.exec(prompt)?.[1]?.toLowerCase();
@@ -3245,9 +3295,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3245
3295
  else
3246
3296
  turnMutatedFilesRef.current = true;
3247
3297
  let commands;
3298
+ let turnSucceeded = false;
3248
3299
  try {
3249
3300
  commands = await service();
3250
- let activeSessionId = sessionId;
3301
+ let activeSessionId = sessionIdRef.current;
3251
3302
  const startedNewSession = activeSessionId === null;
3252
3303
  if (activeSessionId === null) {
3253
3304
  activeSessionId = randomUUID();
@@ -3316,7 +3367,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3316
3367
  streamingFrameRef.current?.flush();
3317
3368
  setStatus('ready');
3318
3369
  setTurnDuration(Date.now() - turnStartedAt);
3319
- if (runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3370
+ if (!internal &&
3371
+ followUpQueueRef.current.length === 0 &&
3372
+ runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3320
3373
  notifyTerminal({
3321
3374
  channel: runtimeSettingsRef.current.notifChannel,
3322
3375
  title: 'Praxis',
@@ -3326,6 +3379,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3326
3379
  : { write: notificationWriter }),
3327
3380
  });
3328
3381
  }
3382
+ turnSucceeded = true;
3329
3383
  }
3330
3384
  catch (error) {
3331
3385
  if (turnController.signal.aborted && !signal?.aborted) {
@@ -3343,7 +3397,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3343
3397
  }
3344
3398
  }
3345
3399
  finally {
3346
- if (!factory.scheduledPrompts && commands) {
3400
+ if (!internal && !factory.scheduledPrompts && commands) {
3347
3401
  try {
3348
3402
  await commands.close?.();
3349
3403
  }
@@ -3360,6 +3414,62 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3360
3414
  }
3361
3415
  if (turnControllerRef.current === turnController)
3362
3416
  turnControllerRef.current = null;
3417
+ if (!internal) {
3418
+ setBusy(false);
3419
+ }
3420
+ }
3421
+ return turnSucceeded;
3422
+ };
3423
+ const submit = async (prompt, shellCommand, images = [], followUpId) => {
3424
+ let currentPrompt = prompt;
3425
+ let currentShellCommand = shellCommand;
3426
+ let currentImages = images;
3427
+ let currentFollowUpId = followUpId;
3428
+ let completed = false;
3429
+ try {
3430
+ while (true) {
3431
+ completed = await submitTurn(currentPrompt, currentShellCommand, currentImages, currentFollowUpId, true);
3432
+ if (!completed)
3433
+ break;
3434
+ const next = followUpQueueRef.current.shift();
3435
+ if (!next)
3436
+ break;
3437
+ currentPrompt = next.text;
3438
+ currentShellCommand = undefined;
3439
+ currentImages = [];
3440
+ currentFollowUpId = next.id;
3441
+ }
3442
+ if (completed &&
3443
+ runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3444
+ notifyTerminal({
3445
+ channel: runtimeSettingsRef.current.notifChannel,
3446
+ title: 'Praxis',
3447
+ message: 'Turn complete',
3448
+ ...(notificationWriter === undefined
3449
+ ? {}
3450
+ : { write: notificationWriter }),
3451
+ });
3452
+ }
3453
+ }
3454
+ finally {
3455
+ if (!factory.scheduledPrompts) {
3456
+ const commands = serviceRef.current;
3457
+ if (commands) {
3458
+ try {
3459
+ await commands.close?.();
3460
+ }
3461
+ catch (error) {
3462
+ append({
3463
+ kind: 'warning',
3464
+ text: redactSensitiveText(error instanceof Error ? error.message : String(error), sensitiveValues),
3465
+ });
3466
+ }
3467
+ finally {
3468
+ if (serviceRef.current === commands)
3469
+ serviceRef.current = null;
3470
+ }
3471
+ }
3472
+ }
3363
3473
  setBusy(false);
3364
3474
  }
3365
3475
  };
@@ -5890,6 +6000,94 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5890
6000
  void backgrounding;
5891
6001
  return;
5892
6002
  }
6003
+ const pendingText = inputRef.current.trim();
6004
+ const addPending = (item) => updatePendingInputs((current) => [...current, item]);
6005
+ if (key.upArrow && pendingText.length === 0) {
6006
+ withdrawLatestPending();
6007
+ return;
6008
+ }
6009
+ if (key.return && key.shift) {
6010
+ updateComposerEditor(insertComposerText(editor(), '\n'));
6011
+ return;
6012
+ }
6013
+ if (isKeybinding('chat:submit') && !key.shift && !key.meta && !key.ctrl) {
6014
+ if (!pendingText)
6015
+ return;
6016
+ const controller = turnControllerRef.current;
6017
+ if (!controller) {
6018
+ append({
6019
+ kind: 'warning',
6020
+ text: 'Turn is completing; input was retained.',
6021
+ });
6022
+ return;
6023
+ }
6024
+ const restoreSteeringText = () => {
6025
+ const current = inputRef.current;
6026
+ updateComposerInput(current.trim().length === 0
6027
+ ? pendingText
6028
+ : `${pendingText}\n${current}`);
6029
+ };
6030
+ // Service construction may still be in flight while the busy composer
6031
+ // is already visible. Clear now so newly typed text becomes a distinct
6032
+ // draft; restore both drafts if the active-turn command loses a race.
6033
+ clearComposerInput();
6034
+ const steer = async () => {
6035
+ const commands = serviceRef.current ?? (await service());
6036
+ if (turnControllerRef.current !== controller ||
6037
+ controller.signal.aborted) {
6038
+ restoreSteeringText();
6039
+ append({
6040
+ kind: 'warning',
6041
+ text: 'Active turn changed; input was retained.',
6042
+ });
6043
+ return;
6044
+ }
6045
+ const activeSessionId = sessionIdRef.current;
6046
+ const result = activeSessionId
6047
+ ? commands.steer?.(activeSessionId, pendingText)
6048
+ : undefined;
6049
+ if (result?.kind === 'accepted') {
6050
+ addPending({
6051
+ id: result.item.id,
6052
+ kind: 'steering',
6053
+ text: result.item.content,
6054
+ });
6055
+ }
6056
+ else {
6057
+ restoreSteeringText();
6058
+ append({
6059
+ kind: 'warning',
6060
+ text: result?.kind === 'turn-completing'
6061
+ ? 'Turn is completing; input was retained.'
6062
+ : result?.kind === 'not-steerable'
6063
+ ? 'This turn cannot be steered; input was retained.'
6064
+ : 'Steering is unavailable; input was retained.',
6065
+ });
6066
+ }
6067
+ };
6068
+ void steer().catch((error) => {
6069
+ restoreSteeringText();
6070
+ warn(error);
6071
+ });
6072
+ return;
6073
+ }
6074
+ if (key.tab || (key.return && key.meta)) {
6075
+ if (!pendingText)
6076
+ return;
6077
+ if (!turnControllerRef.current) {
6078
+ append({
6079
+ kind: 'warning',
6080
+ text: 'Turn is completing; follow-up input was retained.',
6081
+ });
6082
+ return;
6083
+ }
6084
+ const item = { id: randomUUID(), text: pendingText };
6085
+ followUpQueueRef.current.push(item);
6086
+ addPending({ id: item.id, kind: 'follow-up', text: item.text });
6087
+ clearComposerInput();
6088
+ return;
6089
+ }
6090
+ editComposer();
5893
6091
  return;
5894
6092
  }
5895
6093
  if (isKeybinding('chat:imagePaste')) {
@@ -6074,9 +6272,6 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6074
6272
  streamingFrameRef.current?.flush();
6075
6273
  setThinkingExpanded(false);
6076
6274
  setStatus('ready');
6077
- inputHistoryRef.current = [];
6078
- inputHistoryIndexRef.current = null;
6079
- inputHistoryDraftRef.current = '';
6080
6275
  })().catch(warn);
6081
6276
  }
6082
6277
  else if (prompt === '/model') {
@@ -6571,6 +6766,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6571
6766
  }
6572
6767
  return;
6573
6768
  }
6769
+ if (isKeybinding('history:previous') &&
6770
+ inputRef.current.length === 0 &&
6771
+ withdrawLatestPending()) {
6772
+ return;
6773
+ }
6574
6774
  if (isKeybinding('history:previous')) {
6575
6775
  restorePromptHistory('previous');
6576
6776
  return;
@@ -6664,6 +6864,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6664
6864
  shellMode,
6665
6865
  busy,
6666
6866
  status: conciseStatus,
6867
+ pendingItems: pendingInputs,
6667
6868
  display: runtimeDisplay,
6668
6869
  }), [
6669
6870
  screen,
@@ -6672,6 +6873,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6672
6873
  inputCursor,
6673
6874
  busy,
6674
6875
  conciseStatus,
6876
+ pendingInputs,
6675
6877
  runtimeDisplay,
6676
6878
  ]);
6677
6879
  const terminalSelectionContext = useMemo(() => createTerminalSelectionContext(quietFrame, conversationScreen?.transcript.rows.length ?? 0, transcriptScrollOffset, maxTranscriptScrollOffset), [
@@ -0,0 +1,17 @@
1
+ import { type ComposerEditorState } from './composer-editor.js';
2
+ export declare const COMPOSER_PROMPT_HISTORY_LIMIT = 100;
3
+ export interface ComposerPromptHistoryState {
4
+ readonly entries: readonly string[];
5
+ readonly index: number | null;
6
+ readonly draft: ComposerEditorState | null;
7
+ }
8
+ export interface ComposerPromptHistoryTransition {
9
+ readonly state: ComposerPromptHistoryState;
10
+ readonly editor: ComposerEditorState | null;
11
+ }
12
+ export declare function createComposerPromptHistory(): ComposerPromptHistoryState;
13
+ export declare function seedComposerPromptHistory(prompts: readonly string[]): ComposerPromptHistoryState;
14
+ export declare function recordComposerPrompt(state: ComposerPromptHistoryState, prompt: string): ComposerPromptHistoryState;
15
+ export declare function navigateComposerPromptHistory(state: ComposerPromptHistoryState, direction: 'previous' | 'next', currentEditor: ComposerEditorState): ComposerPromptHistoryTransition;
16
+ export declare function resetComposerPromptHistoryNavigation(state: ComposerPromptHistoryState): ComposerPromptHistoryState;
17
+ //# sourceMappingURL=composer-prompt-history.d.ts.map
@@ -0,0 +1,63 @@
1
+ import { createComposerEditor, } from './composer-editor.js';
2
+ export const COMPOSER_PROMPT_HISTORY_LIMIT = 100;
3
+ export function createComposerPromptHistory() {
4
+ return { entries: [], index: null, draft: null };
5
+ }
6
+ function normalize(prompts) {
7
+ const entries = [];
8
+ for (const prompt of prompts) {
9
+ if (prompt.trim() === '')
10
+ continue;
11
+ if (entries.at(-1) === prompt)
12
+ continue;
13
+ entries.push(prompt);
14
+ }
15
+ return entries.slice(-COMPOSER_PROMPT_HISTORY_LIMIT);
16
+ }
17
+ export function seedComposerPromptHistory(prompts) {
18
+ return { entries: normalize(prompts), index: null, draft: null };
19
+ }
20
+ export function recordComposerPrompt(state, prompt) {
21
+ if (prompt.trim() === '')
22
+ return state;
23
+ return {
24
+ entries: normalize([...state.entries, prompt]),
25
+ index: null,
26
+ draft: null,
27
+ };
28
+ }
29
+ export function navigateComposerPromptHistory(state, direction, currentEditor) {
30
+ if (state.entries.length === 0)
31
+ return { state, editor: null };
32
+ if (direction === 'previous') {
33
+ const index = state.index === null
34
+ ? state.entries.length - 1
35
+ : Math.max(0, state.index - 1);
36
+ const draft = state.index === null
37
+ ? createComposerEditor(currentEditor.text, currentEditor.cursor)
38
+ : state.draft;
39
+ const nextState = { ...state, index, draft };
40
+ return {
41
+ state: nextState,
42
+ editor: createComposerEditor(state.entries[index]),
43
+ };
44
+ }
45
+ if (state.index === null)
46
+ return { state, editor: null };
47
+ if (state.index < state.entries.length - 1) {
48
+ const index = state.index + 1;
49
+ return {
50
+ state: { ...state, index },
51
+ editor: createComposerEditor(state.entries[index]),
52
+ };
53
+ }
54
+ const editor = state.draft;
55
+ return {
56
+ state: { ...state, index: null, draft: null },
57
+ editor,
58
+ };
59
+ }
60
+ export function resetComposerPromptHistoryNavigation(state) {
61
+ return { ...state, index: null, draft: null };
62
+ }
63
+ //# sourceMappingURL=composer-prompt-history.js.map
@@ -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
  });
@@ -44,7 +44,8 @@ export function routeTuiInteraction(snapshot, input) {
44
44
  if (snapshot.layer.kind === 'delegated')
45
45
  return delegated(confirmationEffects);
46
46
  const viewport = snapshot.viewport;
47
- if (viewport.enabled && input.scrollIntent !== 'none') {
47
+ const scrollIsIncidental = input.action === undefined || input.action.startsWith('scroll:');
48
+ if (viewport.enabled && input.scrollIntent !== 'none' && scrollIsIncidental) {
48
49
  const pageRows = finiteNonNegativeInteger(viewport.pageRows);
49
50
  const offset = finiteNonNegativeInteger(viewport.offset);
50
51
  const maxOffset = finiteNonNegativeInteger(viewport.maxOffset);
@@ -71,7 +72,7 @@ export function routeTuiInteraction(snapshot, input) {
71
72
  }
72
73
  if (input.action === 'chat:cancel')
73
74
  return handled([...confirmationEffects, { kind: 'interrupt-turn' }]);
74
- return handled(confirmationEffects);
75
+ return delegated(confirmationEffects);
75
76
  }
76
77
  if (input.callerIntent !== 'none')
77
78
  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.1",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",