tinker-agent 1.10.1 → 1.11.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/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.11.0] - 2026-08-15
9
+
10
+ ### Added
11
+
12
+ - Allow text follow-up prompts to be queued while a turn is running. Follow-ups
13
+ are applied safely after a complete tool batch or handed off to a new turn
14
+ after a final response, while preserving canonical session history and TUI
15
+ continuity.
16
+
8
17
  ## [1.10.1] - 2026-08-15
9
18
 
10
19
  ### Added
@@ -199,7 +208,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
199
208
  - First formal npm release under the `tinker-agent` package name with the `tinker`
200
209
  executable.
201
210
 
202
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.10.1...HEAD
211
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.11.0...HEAD
212
+ [1.11.0]: https://github.com/ishowshao/tinker/releases/tag/v1.11.0
203
213
  [1.10.1]: https://github.com/ishowshao/tinker/releases/tag/v1.10.1
204
214
  [1.9.0]: https://github.com/ishowshao/tinker/releases/tag/v1.9.0
205
215
  [1.8.0]: https://github.com/ishowshao/tinker/releases/tag/v1.8.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/agent/loop.ts CHANGED
@@ -498,6 +498,10 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
498
498
  }
499
499
  }
500
500
 
501
+ await input.runtimeSession.applyQueuedSteering?.({
502
+ turn: input.turn,
503
+ ledger: input.ledger,
504
+ });
501
505
  await input.runtimeSession.append({
502
506
  type: "agent.iteration.finished",
503
507
  ...iteration,
@@ -139,6 +139,18 @@ export type AcceptedTurn = {
139
139
  readonly completion: Promise<RunAgentResult>;
140
140
  };
141
141
 
142
+ export type PromptSchedulerSnapshot = {
143
+ readonly state: "idle" | "running";
144
+ readonly activeTurnId?: TurnIdentity["turnId"];
145
+ readonly pendingCount: number;
146
+ };
147
+
148
+ export type QueueFollowUpResult = {
149
+ readonly kind: "queued";
150
+ readonly pendingCount: number;
151
+ readonly activeTurnId?: TurnIdentity["turnId"];
152
+ };
153
+
142
154
  export type SessionDisposeReason =
143
155
  | { type: "oneshot_complete" }
144
156
  | { type: "tui_exit" }
@@ -167,6 +179,9 @@ export type RuntimeSession = {
167
179
  ): Promise<void>;
168
180
  admitTurn(input: ExecuteTurnInput): Promise<AcceptedTurn>;
169
181
  executeTurn(input: ExecuteTurnInput): Promise<RunAgentResult>;
182
+ promptScheduler(): PromptSchedulerSnapshot;
183
+ subscribePromptScheduler(listener: () => void): () => void;
184
+ queueFollowUp(userMessage: UserMessage): QueueFollowUpResult;
170
185
  compactContext(): Promise<ContextCompactionResult>;
171
186
  retireContext(): Promise<ContextRetirementResult>;
172
187
  undoLatestFileMutationTurn(): Promise<TurnUndoResult>;
@@ -227,6 +242,10 @@ export type RuntimeSessionContext = {
227
242
  consumedThroughOrdinal: number;
228
243
  ledger: AgentTurnLedger;
229
244
  }): Promise<void>;
245
+ applyQueuedSteering?(input: {
246
+ turn: TurnIdentity;
247
+ ledger: AgentTurnLedger;
248
+ }): Promise<number>;
230
249
  };
231
250
 
232
251
  export type ContextSurfaceRefreshSummary = {
@@ -354,10 +373,18 @@ type RuntimeSessionState =
354
373
  | "disposed";
355
374
 
356
375
  type ActiveTurn = {
376
+ turn: TurnIdentity;
357
377
  controller: AbortController;
358
378
  completion: Promise<RunAgentResult>;
359
379
  };
360
380
 
381
+ type QueuedPrompt = {
382
+ readonly userMessage: UserMessage;
383
+ };
384
+
385
+ const MAX_QUEUED_PROMPTS = 8;
386
+ const MAX_QUEUED_PROMPT_TEXT_BYTES = 64 * 1024;
387
+
361
388
  type ActiveAdmission = {
362
389
  controller: AbortController;
363
390
  settled: Promise<void>;
@@ -415,6 +442,13 @@ class DefaultRuntimeSession implements RuntimeSession {
415
442
  private ledger?: SessionLedger;
416
443
  private activeAdmission?: ActiveAdmission;
417
444
  private activeTurn?: ActiveTurn;
445
+ private executionChainRunning = false;
446
+ private readonly queuedPrompts: QueuedPrompt[] = [];
447
+ private promptSchedulerSnapshot: PromptSchedulerSnapshot = Object.freeze({
448
+ state: "idle",
449
+ pendingCount: 0,
450
+ });
451
+ private readonly promptSchedulerListeners = new Set<() => void>();
418
452
  private activeContextRevision?: Promise<
419
453
  ContextCompactionResult | ContextRetirementResult
420
454
  >;
@@ -494,6 +528,7 @@ class DefaultRuntimeSession implements RuntimeSession {
494
528
  prepareModelDispatch: (dispatch) => this.prepareModelDispatch(dispatch),
495
529
  maintainContextAfterIteration: (maintenance) =>
496
530
  this.performActiveTurnContextMaintenance(maintenance),
531
+ applyQueuedSteering: (steering) => this.applyQueuedSteering(steering),
497
532
  };
498
533
  }
499
534
 
@@ -1509,11 +1544,71 @@ class DefaultRuntimeSession implements RuntimeSession {
1509
1544
  }
1510
1545
  }
1511
1546
 
1547
+ promptScheduler(): PromptSchedulerSnapshot {
1548
+ return this.promptSchedulerSnapshot;
1549
+ }
1550
+
1551
+ subscribePromptScheduler(listener: () => void): () => void {
1552
+ this.promptSchedulerListeners.add(listener);
1553
+ return () => this.promptSchedulerListeners.delete(listener);
1554
+ }
1555
+
1556
+ queueFollowUp(userMessage: UserMessage): QueueFollowUpResult {
1557
+ if (!this.executionChainRunning) {
1558
+ throw new Error("Cannot queue a follow-up while no execution chain is running.");
1559
+ }
1560
+ validateUserMessage(userMessage);
1561
+ if (userMessage.attachments !== undefined) {
1562
+ throw new Error("Active-turn follow-ups do not support image attachments.");
1563
+ }
1564
+ if (this.queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
1565
+ throw new Error(`At most ${MAX_QUEUED_PROMPTS} follow-ups may be queued.`);
1566
+ }
1567
+ const queuedBytes = this.queuedPrompts.reduce(
1568
+ (total, entry) => total + Buffer.byteLength(entry.userMessage.content, "utf8"),
1569
+ 0,
1570
+ );
1571
+ const nextBytes = Buffer.byteLength(userMessage.content, "utf8");
1572
+ if (queuedBytes + nextBytes > MAX_QUEUED_PROMPT_TEXT_BYTES) {
1573
+ throw new Error("Queued follow-ups exceed the 64 KiB text limit.");
1574
+ }
1575
+ this.queuedPrompts.push({
1576
+ userMessage: Object.freeze({ ...userMessage }),
1577
+ });
1578
+ this.notifyPromptScheduler();
1579
+ return Object.freeze({
1580
+ kind: "queued",
1581
+ pendingCount: this.queuedPrompts.length,
1582
+ ...(this.activeTurn === undefined
1583
+ ? {}
1584
+ : { activeTurnId: this.activeTurn.turn.turnId }),
1585
+ });
1586
+ }
1587
+
1512
1588
  async executeTurn(input: ExecuteTurnInput): Promise<RunAgentResult> {
1513
1589
  return (await this.admitTurn(input)).completion;
1514
1590
  }
1515
1591
 
1516
1592
  async admitTurn(input: ExecuteTurnInput): Promise<AcceptedTurn> {
1593
+ if (this.executionChainRunning) {
1594
+ throw new Error(
1595
+ `Cannot execute a turn while RuntimeSession is ${this.state}; a prompt chain is already executing.`,
1596
+ );
1597
+ }
1598
+ this.executionChainRunning = true;
1599
+ this.notifyPromptScheduler();
1600
+ try {
1601
+ const accepted = await this.admitSingleTurn(input);
1602
+ const completion = this.continueExecutionChain(accepted.completion, input.signal);
1603
+ return Object.freeze({ ...accepted, completion });
1604
+ } catch (error) {
1605
+ this.executionChainRunning = false;
1606
+ this.notifyPromptScheduler();
1607
+ throw error;
1608
+ }
1609
+ }
1610
+
1611
+ private async admitSingleTurn(input: ExecuteTurnInput): Promise<AcceptedTurn> {
1517
1612
  if (this.state !== "ready") {
1518
1613
  throw new Error(`Cannot execute a turn while RuntimeSession is ${this.state}.`);
1519
1614
  }
@@ -1586,7 +1681,8 @@ class DefaultRuntimeSession implements RuntimeSession {
1586
1681
  usage: admissionSnapshot,
1587
1682
  },
1588
1683
  });
1589
- this.activeTurn = { controller, completion };
1684
+ this.activeTurn = { turn, controller, completion };
1685
+ this.notifyPromptScheduler();
1590
1686
  return Object.freeze({
1591
1687
  turnId: turn.turnId,
1592
1688
  userMessage: input.userMessage,
@@ -1608,6 +1704,79 @@ class DefaultRuntimeSession implements RuntimeSession {
1608
1704
  }
1609
1705
  }
1610
1706
 
1707
+ private async continueExecutionChain(
1708
+ initialCompletion: Promise<RunAgentResult>,
1709
+ signal: AbortSignal,
1710
+ ): Promise<RunAgentResult> {
1711
+ let completion = initialCompletion;
1712
+ let finalResult: RunAgentResult;
1713
+ try {
1714
+ for (;;) {
1715
+ finalResult = await completion;
1716
+ if (finalResult.status !== "completed" || this.queuedPrompts.length === 0) {
1717
+ return finalResult;
1718
+ }
1719
+ const next = this.queuedPrompts[0];
1720
+ if (next === undefined) {
1721
+ return finalResult;
1722
+ }
1723
+ const accepted = await this.admitSingleTurn({
1724
+ userMessage: next.userMessage,
1725
+ signal,
1726
+ });
1727
+ this.queuedPrompts.shift();
1728
+ this.notifyPromptScheduler();
1729
+ completion = accepted.completion;
1730
+ }
1731
+ } finally {
1732
+ this.queuedPrompts.splice(0);
1733
+ this.executionChainRunning = false;
1734
+ this.notifyPromptScheduler();
1735
+ }
1736
+ }
1737
+
1738
+ private notifyPromptScheduler(): void {
1739
+ this.promptSchedulerSnapshot = Object.freeze({
1740
+ state: this.executionChainRunning ? "running" : "idle",
1741
+ ...(this.activeTurn === undefined
1742
+ ? {}
1743
+ : { activeTurnId: this.activeTurn.turn.turnId }),
1744
+ pendingCount: this.queuedPrompts.length,
1745
+ });
1746
+ for (const listener of this.promptSchedulerListeners) listener();
1747
+ }
1748
+
1749
+ private async applyQueuedSteering(input: {
1750
+ turn: TurnIdentity;
1751
+ ledger: AgentTurnLedger;
1752
+ }): Promise<number> {
1753
+ if (this.activeTurn?.turn.turnId !== input.turn.turnId) {
1754
+ throw new Error("Cannot apply steering outside the active turn.");
1755
+ }
1756
+ if (this.queuedPrompts.length === 0) return 0;
1757
+ const drained = this.queuedPrompts.splice(0);
1758
+ const records = input.ledger.appendSteeringUserMessages(
1759
+ drained.map((entry) => entry.userMessage),
1760
+ );
1761
+ this.notifyPromptScheduler();
1762
+ for (let index = 0; index < records.length; index += 1) {
1763
+ const record = records[index];
1764
+ const queued = drained[index];
1765
+ if (record === undefined || queued === undefined) {
1766
+ throw new Error("Steering ledger result did not match the drained queue.");
1767
+ }
1768
+ await this.append({
1769
+ type: "turn.steering.applied",
1770
+ ...input.turn,
1771
+ data: {
1772
+ userPrompt: projectUserMessage(queued.userMessage),
1773
+ ordinal: record.ordinal,
1774
+ },
1775
+ });
1776
+ }
1777
+ return records.length;
1778
+ }
1779
+
1611
1780
  private settleAdmission(admission: ActiveAdmission): void {
1612
1781
  if (this.activeAdmission !== admission) {
1613
1782
  throw new Error("Runtime admission ownership was lost.");
@@ -1838,6 +2007,8 @@ class DefaultRuntimeSession implements RuntimeSession {
1838
2007
  canSwitchSession(): boolean {
1839
2008
  return (
1840
2009
  this.state === "ready" &&
2010
+ !this.executionChainRunning &&
2011
+ this.queuedPrompts.length === 0 &&
1841
2012
  this.activeTurn === undefined &&
1842
2013
  (this.tooling?.taskManager
1843
2014
  .listBackgroundTasks()
@@ -2025,6 +2196,7 @@ class DefaultRuntimeSession implements RuntimeSession {
2025
2196
  } finally {
2026
2197
  removeExternalAbortListener();
2027
2198
  this.activeTurn = undefined;
2199
+ this.notifyPromptScheduler();
2028
2200
  this.pendingAutomaticContextMaintenance = false;
2029
2201
  if (this.state === "executing") {
2030
2202
  this.state = "ready";
@@ -2398,6 +2570,9 @@ class DefaultRuntimeSession implements RuntimeSession {
2398
2570
  }
2399
2571
 
2400
2572
  this.state = "disposing";
2573
+ this.queuedPrompts.splice(0);
2574
+ this.executionChainRunning = false;
2575
+ this.notifyPromptScheduler();
2401
2576
  const errors: unknown[] = this.faultCause === undefined ? [] : [this.faultCause];
2402
2577
  const activeAdmission = this.activeAdmission;
2403
2578
  if (activeAdmission !== undefined) {
@@ -100,6 +100,9 @@ export type PendingLedgerTurn = {
100
100
  };
101
101
 
102
102
  export type AgentTurnLedger = {
103
+ appendSteeringUserMessages(
104
+ messages: readonly UserMessage[],
105
+ ): readonly CanonicalMessageRecord[];
103
106
  appendAssistant(input: {
104
107
  iteration: IterationIdentity;
105
108
  message: AssistantMessage;
@@ -129,6 +132,13 @@ export type LedgerMutation =
129
132
  admissionBase?: AdmissionBaseToken;
130
133
  next: ProtocolContextView;
131
134
  }
135
+ | {
136
+ kind: "append_steering_users";
137
+ turn: TurnIdentity;
138
+ frames: readonly ProtocolFrame[];
139
+ messages: readonly CanonicalMessageRecord[];
140
+ next: ProtocolContextView;
141
+ }
132
142
  | {
133
143
  kind: "append_assistant";
134
144
  iteration: IterationIdentity;
@@ -396,6 +406,74 @@ export class InMemorySessionLedger implements SessionLedger {
396
406
  this.pending = undefined;
397
407
  }
398
408
 
409
+ appendSteeringUserMessages(
410
+ pending: InMemoryPendingLedgerTurn,
411
+ userMessages: readonly UserMessage[],
412
+ ): readonly CanonicalMessageRecord[] {
413
+ this.requirePending(pending, "append steering user messages");
414
+ this.assertNoOpenFrame();
415
+ if (userMessages.length === 0) {
416
+ return Object.freeze([]);
417
+ }
418
+ for (const userMessage of userMessages) {
419
+ validateUserMessage(userMessage);
420
+ }
421
+
422
+ const frames: ProtocolFrame[] = [];
423
+ const messages: CanonicalMessageRecord[] = [];
424
+ for (const userMessage of userMessages) {
425
+ const createdAt = this.clock();
426
+ const ordinal = this.view.messages.length + messages.length + 1;
427
+ const frameId = this.input.idFactory.createProtocolFrameId();
428
+ messages.push(
429
+ immutableRecord<CanonicalMessageRecord>({
430
+ messageId: this.input.idFactory.createMessageId(),
431
+ sessionId: this.input.sessionId,
432
+ frameId,
433
+ ordinal,
434
+ contentSha256: userMessageHash(userMessage),
435
+ createdAt,
436
+ role: "user",
437
+ turnId: pending.turn.turnId,
438
+ content: userMessage.content,
439
+ ...(userMessage.attachments === undefined
440
+ ? {}
441
+ : {
442
+ attachments: Object.freeze(
443
+ immutableCanonicalClone(userMessage.attachments),
444
+ ),
445
+ }),
446
+ origin: "user",
447
+ }),
448
+ );
449
+ frames.push(
450
+ immutableRecord<ProtocolFrame>({
451
+ frameId,
452
+ sessionId: this.input.sessionId,
453
+ turnId: pending.turn.turnId,
454
+ kind: "user",
455
+ state: "closed",
456
+ firstOrdinal: ordinal,
457
+ lastOrdinal: ordinal,
458
+ createdAt,
459
+ closedAt: createdAt,
460
+ }),
461
+ );
462
+ }
463
+ const immutableFrames = Object.freeze(frames);
464
+ const immutableMessages = Object.freeze(messages);
465
+ const next = appendView(this.view, immutableFrames, immutableMessages, []);
466
+ this.validator.validate(next, { fullIntegrity: true });
467
+ this.commit({
468
+ kind: "append_steering_users",
469
+ turn: pending.turn,
470
+ frames: immutableFrames,
471
+ messages: immutableMessages,
472
+ next,
473
+ });
474
+ return immutableMessages;
475
+ }
476
+
399
477
  appendAssistant(
400
478
  pending: InMemoryPendingLedgerTurn,
401
479
  input: {
@@ -776,6 +854,8 @@ class InMemoryPendingLedgerTurn implements PendingLedgerTurn {
776
854
  readonly turn: TurnIdentity,
777
855
  ) {
778
856
  this.agent = {
857
+ appendSteeringUserMessages: (messages) =>
858
+ this.ledger.appendSteeringUserMessages(this, messages),
779
859
  appendAssistant: (input) => this.ledger.appendAssistant(this, input),
780
860
  assertCanExecuteTool: (call) => this.ledger.assertCanExecuteTool(this, call),
781
861
  commitToolCompletions: (completions) =>
@@ -35,6 +35,10 @@ export function renderObservationLogEvent(event: AgentEvent): string | undefined
35
35
  ].join("\n");
36
36
  case "turn.started":
37
37
  return renderTurnStarted(event);
38
+ case "turn.steering.applied":
39
+ return ["## User follow-up", "", event.data.userPrompt.text, "", "---", ""].join(
40
+ "\n",
41
+ );
38
42
  case "assistant.progress":
39
43
  return renderAssistantProgress(event);
40
44
  case "tool.observation":
@@ -37,6 +37,11 @@ export class StdoutEventPrinter implements EventSink {
37
37
  `turn.started turn=${event.turnNumber} turnId=${event.turnId}\n`,
38
38
  );
39
39
  break;
40
+ case "turn.steering.applied":
41
+ this.stdout.write(
42
+ `turn.steering.applied turn=${event.turnNumber} ordinal=${event.data.ordinal}\n`,
43
+ );
44
+ break;
40
45
  case "agent.iteration.started":
41
46
  this.stdout.write(
42
47
  `agent.iteration.started iteration=${event.iterationNumber} iterationId=${event.iterationId}\n`,
@@ -307,6 +307,10 @@ export type AgentEventDataMap = {
307
307
  "session.interrupted_frame_recovered": InterruptedFrameRecoveredData;
308
308
  "session.finished": SessionFinishedData;
309
309
  "turn.started": { userPrompt: UserPromptProjection };
310
+ "turn.steering.applied": {
311
+ userPrompt: UserPromptProjection;
312
+ ordinal: number;
313
+ };
310
314
  "turn.finished": TurnFinishedData;
311
315
  "turn.failed": { error: string };
312
316
  "turn.cancelled": { cancellation: TurnCancellation };
@@ -411,7 +415,7 @@ export type AgentEventInput =
411
415
  >
412
416
  | SessionEventInput<"mcp.server.connected" | "mcp.server.failed">
413
417
  | SessionEventInput<"diagnostic.sink_failed">
414
- | TurnEventInput<"turn.started" | "turn.finished">
418
+ | TurnEventInput<"turn.started" | "turn.steering.applied" | "turn.finished">
415
419
  | (
416
420
  | TurnEventInput<"turn.failed" | "turn.cancelled">
417
421
  | IterationEventInput<"turn.failed" | "turn.cancelled">
@@ -241,6 +241,9 @@ export class FakeModelClient implements ModelClient {
241
241
  if (this.mode === "pty-incremental-output") {
242
242
  return this.ptyIncrementalOutput(input, prepared, options);
243
243
  }
244
+ if (this.mode === "pty-steering-notice") {
245
+ return this.ptySteeringNotice(input, prepared, options);
246
+ }
244
247
  if (this.mode === "pty-resume-layout") {
245
248
  return this.ptyResumeLayout(input, prepared, options);
246
249
  }
@@ -412,6 +415,32 @@ export class FakeModelClient implements ModelClient {
412
415
  return textOutput(prepared, chunks.join(""));
413
416
  }
414
417
 
418
+ private async ptySteeringNotice(
419
+ input: ModelRequestInput,
420
+ prepared: PreparedModelRequest,
421
+ options: ModelRequestOptions,
422
+ ): Promise<ModelRequestOutput> {
423
+ requireTools(input, ["Bash"]);
424
+ const prompt = lastUserMessage(input.messages);
425
+ if (prompt === "PTY_STEERING_START") {
426
+ await Bun.sleep(600);
427
+ options.signal.throwIfAborted();
428
+ return toolCallOutput(prepared, options, "Bash", {
429
+ command: "printf 'PTY_STEERING_TOOL_DONE\\n'",
430
+ description: "Create steering boundary",
431
+ });
432
+ }
433
+ if (prompt === "PTY_STEERING_FOLLOWUP") {
434
+ requireToolMessage(input.messages, "Bash", "PTY_STEERING_TOOL_DONE");
435
+ await Bun.sleep(1_500);
436
+ options.signal.throwIfAborted();
437
+ return textOutput(prepared, "PTY_STEERING_FINAL");
438
+ }
439
+ throw new Error(
440
+ `Unexpected pty-steering-notice prompt: ${JSON.stringify(prompt)}.`,
441
+ );
442
+ }
443
+
415
444
  private ptyResumeLayout(
416
445
  input: ModelRequestInput,
417
446
  prepared: PreparedModelRequest,
@@ -660,6 +660,9 @@ export class SessionStore implements SessionLedgerCommitter {
660
660
  case "begin_turn":
661
661
  this.commitBeginTurn(mutation, now);
662
662
  break;
663
+ case "append_steering_users":
664
+ this.commitSteeringUsers(mutation, now);
665
+ break;
663
666
  case "append_assistant":
664
667
  this.commitAssistant(mutation, now);
665
668
  break;
@@ -2848,6 +2851,32 @@ export class SessionStore implements SessionLedgerCommitter {
2848
2851
  requireSingleChange(this.database, updated.changes, "advance turn counter");
2849
2852
  }
2850
2853
 
2854
+ private commitSteeringUsers(
2855
+ mutation: Extract<LedgerMutation, { kind: "append_steering_users" }>,
2856
+ now: string,
2857
+ ): void {
2858
+ const turn = this.requireTurnRow(mutation.turn.turnId);
2859
+ if (turn.status !== "open") {
2860
+ throw new Error(`Turn ${mutation.turn.turnId} is not open.`);
2861
+ }
2862
+ if (
2863
+ mutation.frames.length === 0 ||
2864
+ mutation.frames.length !== mutation.messages.length
2865
+ ) {
2866
+ throw new Error(
2867
+ "Steering user mutation must contain matching frames and messages.",
2868
+ );
2869
+ }
2870
+ for (let index = 0; index < mutation.frames.length; index += 1) {
2871
+ insertFrame(this.database, requireItem(mutation.frames, index, "steering frame"));
2872
+ insertMessage(
2873
+ this.database,
2874
+ requireItem(mutation.messages, index, "steering message"),
2875
+ );
2876
+ }
2877
+ this.touch(now);
2878
+ }
2879
+
2851
2880
  private commitAssistant(
2852
2881
  mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
2853
2882
  now: string,
package/src/tui/app.tsx CHANGED
@@ -108,6 +108,10 @@ const STATIC_HEADER = Symbol("tui-static-header");
108
108
  const LIVE_TIMELINE_MAX_ROWS = 8;
109
109
  const LIVE_TIMELINE_WITH_TASKS_MAX_ROWS = 3;
110
110
  const BACKGROUND_TASKS_MAX_ROWS = 12;
111
+ const IDLE_PROMPT_SCHEDULER = Object.freeze({
112
+ state: "idle" as const,
113
+ pendingCount: 0,
114
+ });
111
115
 
112
116
  export function App(props: AppProps) {
113
117
  const { exit } = useApp();
@@ -137,11 +141,23 @@ export function App(props: AppProps) {
137
141
  () => binding.bashGuard(),
138
142
  () => binding.bashGuard(),
139
143
  );
144
+ const promptScheduler = useSyncExternalStore(
145
+ (listener) => binding.subscribePromptScheduler?.(listener) ?? (() => undefined),
146
+ () => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
147
+ () => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
148
+ );
140
149
  const [isRunning, setIsRunning] = useState(false);
150
+ const executionRunning = isRunning || promptScheduler.state === "running";
141
151
  const [isSessionOperation, setIsSessionOperation] = useState(false);
142
152
  const [isCopying, setIsCopying] = useState(false);
143
153
  const [isCancelling, setIsCancelling] = useState(false);
144
154
  const [notice, setNotice] = useState<string | undefined>(props.initialNotice);
155
+ const currentQueuedFollowUpNotice = `Follow-up queued for the active turn (${promptScheduler.pendingCount} pending).`;
156
+ const visibleNotice =
157
+ notice?.startsWith("Follow-up queued for the active turn (") === true &&
158
+ notice !== currentQueuedFollowUpNotice
159
+ ? undefined
160
+ : notice;
145
161
  const [showStatus, setShowStatus] = useState(false);
146
162
  const [showSkills, setShowSkills] = useState(false);
147
163
  const [showMcp, setShowMcp] = useState(false);
@@ -259,7 +275,7 @@ export function App(props: AppProps) {
259
275
  setIsCancelling(true);
260
276
  setNotice("Cancelling current turn...");
261
277
  },
262
- { isActive: isRunning },
278
+ { isActive: executionRunning },
263
279
  );
264
280
 
265
281
  const closeResumePicker = () => {
@@ -449,6 +465,32 @@ export function App(props: AppProps) {
449
465
  submission: PromptSubmission,
450
466
  admissionSignal: AbortSignal,
451
467
  ): Promise<PromptSubmissionOutcome> => {
468
+ if (promptScheduler.state === "running") {
469
+ if (submission.userMessage.attachments !== undefined) {
470
+ setNotice("Active-turn follow-ups do not support image attachments.");
471
+ return false;
472
+ }
473
+ if (submission.userMessage.content.trimStart().startsWith("/")) {
474
+ setNotice("Slash commands cannot be queued while a turn is running.");
475
+ return false;
476
+ }
477
+ try {
478
+ const queued = binding.queueFollowUp?.(submission.userMessage);
479
+ if (queued === undefined) {
480
+ throw new Error("TUI session binding does not support follow-up queuing.");
481
+ }
482
+ void props.history?.append(submission.draft).catch((error: unknown) => {
483
+ setNotice(`Prompt history write failed: ${errorMessage(error)}`);
484
+ });
485
+ setNotice(
486
+ `Follow-up queued for the active turn (${queued.pendingCount} pending).`,
487
+ );
488
+ return true;
489
+ } catch (error) {
490
+ setNotice(`Follow-up was not queued: ${errorMessage(error)}`);
491
+ return false;
492
+ }
493
+ }
452
494
  if (isRunning) return false;
453
495
  setNotice(undefined);
454
496
  setIsCancelling(false);
@@ -557,6 +599,10 @@ export function App(props: AppProps) {
557
599
  restoreStaticViewport();
558
600
  }
559
601
 
602
+ if (promptScheduler.state === "running") {
603
+ return submitAgentPrompt(submission, signal);
604
+ }
605
+
560
606
  if (userMessage.attachments === undefined && trimmed.startsWith("/")) {
561
607
  try {
562
608
  const projectCommand = resolveProjectSlashCommand(
@@ -856,9 +902,16 @@ export function App(props: AppProps) {
856
902
  </Box>
857
903
  <Box marginTop={1} flexShrink={0}>
858
904
  <Footer
859
- status={isCancelling ? "cancelling" : state.status}
905
+ status={
906
+ isCancelling
907
+ ? "cancelling"
908
+ : executionRunning
909
+ ? "running"
910
+ : state.status
911
+ }
860
912
  workedForMs={state.workedForMs}
861
913
  yolo={bashGuard.mode === "yolo"}
914
+ pendingFollowUps={promptScheduler.pendingCount}
862
915
  />
863
916
  </Box>
864
917
  <Box marginTop={1} flexDirection="column" flexShrink={0}>
@@ -891,26 +944,38 @@ export function App(props: AppProps) {
891
944
  gitBranch={gitBranch}
892
945
  contextUsage={state.contextUsage}
893
946
  isDisabled={
894
- isRunning ||
895
947
  isSessionOperation ||
896
948
  isCopying ||
949
+ isCancelling ||
897
950
  bashGuard.pending !== undefined
898
951
  }
899
952
  history={props.history}
900
953
  commands={availableCommands}
901
954
  fileLister={props.fileLister}
902
- importImage={binding.importImage}
955
+ importImage={
956
+ promptScheduler.state === "running"
957
+ ? undefined
958
+ : binding.importImage
959
+ }
903
960
  verifyImageAssets={binding.verifyImageAssets}
904
961
  onCycleReasoningEffort={
905
- hasReasoningEffort ? cycleReasoningEffort : undefined
962
+ hasReasoningEffort && promptScheduler.state !== "running"
963
+ ? cycleReasoningEffort
964
+ : undefined
906
965
  }
907
966
  onSubmit={onSubmit}
908
967
  onMaintenance={onMaintenance}
909
- placeholder='Enter a coding request, or "/" for commands'
968
+ placeholder={
969
+ promptScheduler.state === "running"
970
+ ? "Send a follow-up for the active turn…"
971
+ : 'Enter a coding request, or "/" for commands'
972
+ }
910
973
  />
911
974
  )}
912
975
  {viewError === undefined ? null : <Text color="red">{viewError}</Text>}
913
- {notice === undefined ? null : <Text color="yellow">{notice}</Text>}
976
+ {visibleNotice === undefined ? null : (
977
+ <Text color="yellow">{visibleNotice}</Text>
978
+ )}
914
979
  </Box>
915
980
  </Box>
916
981
  )}
@@ -4,6 +4,7 @@ export type FooterProps = {
4
4
  status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
5
5
  workedForMs?: number;
6
6
  yolo?: boolean;
7
+ pendingFollowUps?: number;
7
8
  };
8
9
 
9
10
  export function Footer(props: FooterProps) {
@@ -26,7 +27,11 @@ export function Footer(props: FooterProps) {
26
27
  }
27
28
 
28
29
  if (props.status === "running") {
29
- return <Spinner label={`Running${suffix}`} />;
30
+ const queued =
31
+ props.pendingFollowUps === undefined || props.pendingFollowUps === 0
32
+ ? ""
33
+ : ` · ${props.pendingFollowUps} follow-up${props.pendingFollowUps === 1 ? "" : "s"} queued`;
34
+ return <Spinner label={`Running${queued}${suffix}`} />;
30
35
  }
31
36
 
32
37
  if (props.status === "cancelling") {
@@ -155,6 +155,21 @@ export function reduceTuiProjection(
155
155
  },
156
156
  };
157
157
  }
158
+ case "turn.steering.applied": {
159
+ const userPrompt = truncateUserPromptProjection(
160
+ event.data.userPrompt,
161
+ MAX_TIMELINE_PROMPT_CODE_POINTS,
162
+ );
163
+ return updateActiveTurn(state, event, policy, (turn) =>
164
+ appendTurnItem(turn, {
165
+ id: `turn-${event.turnId}-steering-${event.eventSequence}`,
166
+ label: "follow-up",
167
+ text: userPrompt.text,
168
+ userPrompt,
169
+ status: "text",
170
+ }),
171
+ );
172
+ }
158
173
  case "model.request.started":
159
174
  return updateActiveTurn(state, event, policy, (turn) =>
160
175
  event.data.attemptNumber === 1
@@ -45,6 +45,9 @@ export type TuiSessionBinding = {
45
45
  ) => Promise<void>;
46
46
  admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
47
47
  executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
48
+ promptScheduler?: RuntimeSession["promptScheduler"];
49
+ subscribePromptScheduler?: RuntimeSession["subscribePromptScheduler"];
50
+ queueFollowUp?: RuntimeSession["queueFollowUp"];
48
51
  bashGuard(): BashGuardSnapshot;
49
52
  subscribeBashGuard(listener: () => void): () => void;
50
53
  setYoloMode(enabled: boolean): void;
@@ -250,6 +253,10 @@ export function managedTuiBinding(input: {
250
253
  userMessage,
251
254
  signal,
252
255
  } satisfies ExecuteTurnInput),
256
+ promptScheduler: () => input.runtimeSession.promptScheduler(),
257
+ subscribePromptScheduler: (listener) =>
258
+ input.runtimeSession.subscribePromptScheduler(listener),
259
+ queueFollowUp: (userMessage) => input.runtimeSession.queueFollowUp(userMessage),
253
260
  bashGuard: () => input.runtimeSession.bashGuard(),
254
261
  subscribeBashGuard: (listener) => input.runtimeSession.subscribeBashGuard(listener),
255
262
  setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),