@peakflo/agent-harness 0.2.5-rc.1 → 0.2.5-rc.2

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.
@@ -186,6 +186,8 @@ export interface CodingAgentAdapter {
186
186
  * not (the caller still has the transcript note either way).
187
187
  */
188
188
  steerTurn?(sessionId: string, text: string, config: SessionConfig): Promise<boolean>;
189
+ /** A human message. Return true only once the live backend has accepted it. */
190
+ steerUserMessage?(sessionId: string, text: string, config: SessionConfig): Promise<boolean>;
189
191
  /**
190
192
  * Get current session status
191
193
  */
@@ -89,6 +89,7 @@ export declare class PiAdapter implements CodingAgentAdapter {
89
89
  private consumeStdout;
90
90
  private handleJsonlLine;
91
91
  private rejectPending;
92
+ private refreshSteeringActivityAtIdle;
92
93
  private handleEvent;
93
94
  private handleExtensionUiRequest;
94
95
  private uiRequestMessage;
@@ -110,6 +111,8 @@ export declare class PiAdapter implements CodingAgentAdapter {
110
111
  * into an already-running turn must not reset them.
111
112
  */
112
113
  steerTurn(sessionId: string, text: string, _config: SessionConfig): Promise<boolean>;
114
+ /** Resolve only when Pi emits the queued user message into the active turn. */
115
+ steerUserMessage(sessionId: string, text: string, _config: SessionConfig): Promise<boolean>;
113
116
  getStatus(sessionId: string, _config: SessionConfig): Promise<SessionStatus>;
114
117
  private settlePendingTurnError;
115
118
  pollMessages(sessionId: string): Promise<MessagePart[]>;
@@ -30,6 +30,9 @@ const MINIMUM_PI_VERSION = [0, 80, 5];
30
30
  const PI_PERMISSION_MODE_ENV = 'PEAKFLO_PI_PERMISSION_MODE';
31
31
  exports.PI_DEV_SERVER_POLICY_ENV = 'PF_DEV_SERVER_POLICY_FILE';
32
32
  const TERMINAL_ERROR_SETTLE_GRACE_MS = 1_000;
33
+ // A tool may be silent for the full progress-guard window. Turn finalization
34
+ // has its own short bound; this timer protects an active turn with no echo.
35
+ const STEER_CONSUMPTION_TIMEOUT_MS = 20 * 60_000;
33
36
  const piInstanceIdCache = new Map();
34
37
  /**
35
38
  * Most model providers cap function/tool `name` at 64 characters. Pi forwards
@@ -228,6 +231,16 @@ function textFromContent(content) {
228
231
  .map((block) => String(block.text ?? ''))
229
232
  .join('');
230
233
  }
234
+ function normalizeSteerText(text) {
235
+ return text.replace(/\r\n?/g, '\n').trim();
236
+ }
237
+ function safeSteeringError(error) {
238
+ // Pi may include the submitted prompt in an RPC error. Never log the error
239
+ // text here; even URL and token redaction cannot remove arbitrary user data.
240
+ return error instanceof Error && /timed out/i.test(error.message)
241
+ ? 'rpc-timeout'
242
+ : 'rpc-error';
243
+ }
231
244
  function resultText(result) {
232
245
  if (!result || typeof result !== 'object')
233
246
  return '';
@@ -471,6 +484,8 @@ class PiAdapter {
471
484
  status: 'idle',
472
485
  lastError: null,
473
486
  pending: new Map(),
487
+ pendingSteers: [],
488
+ steeringSupported: true,
474
489
  parts: [],
475
490
  allMessages: [],
476
491
  stdoutBuffer: '',
@@ -502,6 +517,17 @@ class PiAdapter {
502
517
  };
503
518
  }
504
519
  async applySelection(session, config) {
520
+ // An older Pi can reject this RPC. Keep ordinary turns working and let the
521
+ // control plane queue any steer that this process cannot consume.
522
+ try {
523
+ await this.command(session, { type: 'set_steering_mode', mode: 'all' });
524
+ }
525
+ catch (error) {
526
+ console.warn(`[PiAdapter/${session.id}] Pi did not accept steering mode; this session will queue new messages: ${safeSteeringError(error)}`);
527
+ // We cannot prove that Pi applied the mode after a timeout. Treat both
528
+ // explicit rejection and transport failure as a queue fallback.
529
+ session.steeringSupported = false;
530
+ }
505
531
  if (config.model && config.model !== 'default') {
506
532
  const { provider, modelId } = this.splitModel(config.model);
507
533
  if (!provider || !modelId) {
@@ -669,12 +695,41 @@ class PiAdapter {
669
695
  }
670
696
  }
671
697
  rejectPending(session, error) {
698
+ for (const steer of session.pendingSteers.splice(0))
699
+ steer.resolve(false);
672
700
  for (const request of session.pending.values()) {
673
701
  clearTimeout(request.timer);
674
702
  request.reject(error);
675
703
  }
676
704
  session.pending.clear();
677
705
  }
706
+ async refreshSteeringActivityAtIdle(session) {
707
+ const observedTurn = session.turn;
708
+ try {
709
+ const state = await this.command(session, { type: 'get_state' });
710
+ if (session.turn !== observedTurn ||
711
+ session.status === 'error' ||
712
+ session.closing)
713
+ return;
714
+ if (Number(state.data?.pendingMessageCount ?? 0) > 0 ||
715
+ state.data?.isStreaming === true) {
716
+ session.status = 'busy';
717
+ }
718
+ else {
719
+ session.status = 'idle';
720
+ }
721
+ }
722
+ catch (error) {
723
+ console.warn(`[PiAdapter/${session.id}] Could not inspect steering queue at idle: ${safeSteeringError(error)}`);
724
+ if (session.turn === observedTurn &&
725
+ session.status !== 'error' &&
726
+ !session.closing)
727
+ session.status = 'idle';
728
+ }
729
+ // An empty queue is not proof of non-consumption: Pi can remove a message
730
+ // just before emitting message_start. The turn close requeues unconfirmed
731
+ // messages after its bounded wait.
732
+ }
678
733
  handleEvent(session, event) {
679
734
  if (event.type === 'response') {
680
735
  const response = event;
@@ -728,10 +783,17 @@ class PiAdapter {
728
783
  break;
729
784
  case 'agent_settled': {
730
785
  this.cancelPendingUiRequests(session, 'This request is no longer active.');
731
- if (session.pendingTurnError)
786
+ if (session.pendingTurnError) {
732
787
  this.settlePendingTurnError(session);
733
- else
734
- session.status = 'idle';
788
+ }
789
+ else {
790
+ session.status = session.pendingSteers.length ? 'busy' : 'idle';
791
+ // Pi can settle after a tool even with a steer still queued.
792
+ // Keep the session busy until this state read completes.
793
+ if (session.pendingSteers.length) {
794
+ void this.refreshSteeringActivityAtIdle(session);
795
+ }
796
+ }
735
797
  session.promptMayBeCommandOnly = false;
736
798
  break;
737
799
  }
@@ -739,6 +801,16 @@ class PiAdapter {
739
801
  const message = event.message;
740
802
  if (message?.role === 'assistant')
741
803
  session.assistantMessageOrdinal++;
804
+ if (message?.role === 'user') {
805
+ const text = textFromContent(message.content);
806
+ // Pi gives no steer ID on this event. Match the oldest equal text.
807
+ // This assumes an equal user event came from Pi's prompt queue; an
808
+ // unrelated equal user event could confirm the steer early.
809
+ const index = session.pendingSteers.findIndex((steer) => normalizeSteerText(steer.text) === normalizeSteerText(text));
810
+ if (index >= 0) {
811
+ session.pendingSteers.splice(index, 1)[0].resolve(true);
812
+ }
813
+ }
742
814
  break;
743
815
  }
744
816
  case 'message_update':
@@ -1107,8 +1179,45 @@ class PiAdapter {
1107
1179
  });
1108
1180
  return true;
1109
1181
  }
1110
- catch {
1182
+ catch (error) {
1183
+ console.warn(`[PiAdapter/${session.id}] Pi could not accept a steer message: ${safeSteeringError(error)}`);
1184
+ return false;
1185
+ }
1186
+ }
1187
+ /** Resolve only when Pi emits the queued user message into the active turn. */
1188
+ async steerUserMessage(sessionId, text, _config) {
1189
+ const session = this.sessions.get(sessionId);
1190
+ if (!session ||
1191
+ session.status !== 'busy' ||
1192
+ session.steeringSupported === false ||
1193
+ !text)
1111
1194
  return false;
1195
+ let resolveSeen;
1196
+ const seen = new Promise((resolve) => {
1197
+ resolveSeen = resolve;
1198
+ });
1199
+ const waiter = { text, resolve: resolveSeen };
1200
+ session.pendingSteers.push(waiter);
1201
+ // A timeout can requeue text Pi already consumed if its user event was
1202
+ // lost. Durable exact-once recovery is tracked by task_1791540809105_ual6uq3.
1203
+ const timer = setTimeout(() => resolveSeen(false), STEER_CONSUMPTION_TIMEOUT_MS);
1204
+ timer.unref?.();
1205
+ try {
1206
+ await this.command(session, {
1207
+ type: 'steer',
1208
+ message: text
1209
+ });
1210
+ return await seen;
1211
+ }
1212
+ catch (error) {
1213
+ console.warn(`[PiAdapter/${session.id}] Pi could not accept a steer message: ${safeSteeringError(error)}`);
1214
+ return false;
1215
+ }
1216
+ finally {
1217
+ clearTimeout(timer);
1218
+ const index = session.pendingSteers.indexOf(waiter);
1219
+ if (index >= 0)
1220
+ session.pendingSteers.splice(index, 1);
1112
1221
  }
1113
1222
  }
1114
1223
  async getStatus(sessionId, _config) {
@@ -32,6 +32,8 @@ import { RunnerCapabilities } from './types';
32
32
  * `knowledge_files_list` / `knowledge_file_download` (P9).
33
33
  */
34
34
  export declare const HARNESS_FEATURES: readonly ["spaceFiles"];
35
+ /** Steering mode was verified with Pi 0.84.3. Older Pi keeps the queue path. */
36
+ export declare function supportsPiSteering(version: string | null): boolean;
35
37
  export interface ProbeResult {
36
38
  installed: boolean;
37
39
  version: string | null;
@@ -26,6 +26,7 @@
26
26
  */
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.HARNESS_FEATURES = void 0;
29
+ exports.supportsPiSteering = supportsPiSteering;
29
30
  exports.probe = probe;
30
31
  exports.probeCapabilities = probeCapabilities;
31
32
  const node_child_process_1 = require("node:child_process");
@@ -42,6 +43,25 @@ const PROBE_TIMEOUT_MS = 10_000;
42
43
  * `knowledge_files_list` / `knowledge_file_download` (P9).
43
44
  */
44
45
  exports.HARNESS_FEATURES = ['spaceFiles'];
46
+ /** Steering mode was verified with Pi 0.84.3. Older Pi keeps the queue path. */
47
+ function supportsPiSteering(version) {
48
+ if (!version)
49
+ return false;
50
+ // Only a stable release was verified. A prerelease or custom build can
51
+ // change the RPC contract even when its base version meets the minimum.
52
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
53
+ if (!match)
54
+ return false;
55
+ const parts = match.slice(1, 4).map(Number);
56
+ const minimum = [0, 84, 3];
57
+ for (let index = 0; index < minimum.length; index++) {
58
+ if (parts[index] > minimum[index])
59
+ return true;
60
+ if (parts[index] < minimum[index])
61
+ return false;
62
+ }
63
+ return true;
64
+ }
45
65
  /**
46
66
  * The coding-agent backends this harness knows how to drive, and the command
47
67
  * that reports each one's version.
@@ -105,7 +125,7 @@ function probe(command, args = ['--version']) {
105
125
  const raw = String(stdout).trim();
106
126
  // Same expression as detect.js: pick the first version-like token, and
107
127
  // fall back to the first line when a tool prints something exotic.
108
- const match = raw.match(/(\d+\.\d+[\w.-]*)/);
128
+ const match = raw.match(/(\d+\.\d+[\w.+-]*)/);
109
129
  finish({
110
130
  installed: true,
111
131
  version: match ? match[1] : raw.split('\n')[0] || null
@@ -165,7 +185,12 @@ async function probeCapabilities(options = {}) {
165
185
  git: git.installed,
166
186
  docker: docker.installed,
167
187
  maxConcurrentSessions: options.maxConcurrentSessions ?? 1,
168
- features: [...exports.HARNESS_FEATURES],
188
+ features: [
189
+ ...exports.HARNESS_FEATURES,
190
+ ...(backends.some((backend) => backend.name === 'pi' && supportsPiSteering(backend.version))
191
+ ? ['steerMessages']
192
+ : [])
193
+ ],
169
194
  labels: options.labels
170
195
  };
171
196
  }
@@ -196,6 +196,7 @@ export declare const ROUTES: {
196
196
  readonly lease: (runnerId: string, waitSeconds?: number, protocolVersion?: number) => string;
197
197
  /** Bearer: runner credential. Visibility-timeout renewal. */
198
198
  readonly leaseRenew: (runnerId: string, leaseId: string) => string;
199
+ readonly steerAck: (runnerId: string, leaseId: string, turnId: string) => string;
199
200
  /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
200
201
  readonly knowledgeFiles: (runnerId: string, leaseId: string) => string;
201
202
  /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
package/dist/contract.js CHANGED
@@ -236,6 +236,7 @@ exports.ROUTES = {
236
236
  },
237
237
  /** Bearer: runner credential. Visibility-timeout renewal. */
238
238
  leaseRenew: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/renew`,
239
+ steerAck: (runnerId, leaseId, turnId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/steer/${encodeURIComponent(turnId)}/ack`,
239
240
  /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
240
241
  knowledgeFiles: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/files`,
241
242
  /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
@@ -150,6 +150,9 @@ export declare class ControlPlaneClient {
150
150
  * idempotent.
151
151
  */
152
152
  ingest(sessionId: string, request: Omit<IngestRequest, 'protocolVersion'>): Promise<IngestResponse>;
153
+ acknowledgeSteer(sessionId: string, leaseId: string, turnId: string, consumed: boolean): Promise<{
154
+ delivery: 'steered' | 'queued';
155
+ }>;
153
156
  /**
154
157
  * Read the session's model-facing conversation back from the control plane.
155
158
  *
@@ -329,6 +329,14 @@ class ControlPlaneClient {
329
329
  body: { protocolVersion: contract_1.PROTOCOL_VERSION, ...request }
330
330
  }));
331
331
  }
332
+ async acknowledgeSteer(sessionId, leaseId, turnId, consumed) {
333
+ return this.withRetry('steer-ack', () => this.send({
334
+ method: 'POST',
335
+ path: contract_1.ROUTES.steerAck(this.requireRunnerId(), leaseId, turnId),
336
+ token: this.requireCredential(),
337
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, sessionId, consumed }
338
+ }));
339
+ }
332
340
  /* ------------------------------------------------------------------ */
333
341
  /* 5b. session history — the runner's READ path */
334
342
  /* ------------------------------------------------------------------ */
package/dist/harness.d.ts CHANGED
@@ -174,10 +174,15 @@ export declare class Harness {
174
174
  private releaseGitHubGuard;
175
175
  /** sessionId -> the controller that aborts its turn. */
176
176
  private readonly active;
177
+ private readonly activeLeaseIds;
177
178
  /** Renewal workers that must receive a credential rotation in flight. */
178
179
  private readonly activeRenewals;
179
180
  /** commandIds already applied. `SessionCommand` is idempotent by contract. */
180
181
  private readonly appliedCommands;
182
+ /** Prune lease-bound command IDs when their session turn ends. */
183
+ private readonly appliedCommandsBySession;
184
+ /** A steer can arrive on renewal while a leased runner is still starting. */
185
+ private readonly deferredSteers;
181
186
  /** Session-end cleanup waits for an active turn to save its dirty work. */
182
187
  private readonly pendingWorkspaceReleases;
183
188
  /**
@@ -283,14 +288,19 @@ export declare class Harness {
283
288
  private modelsNeedRefresh;
284
289
  private scheduleHeartbeat;
285
290
  private sendHeartbeat;
291
+ /** A bad command must not stop the others or fail a transcript ingest. */
292
+ private applyCommandsSafely;
293
+ private forgetAppliedCommand;
294
+ private clearAppliedCommandsForSession;
286
295
  /**
287
- * Apply a command that rode back on a heartbeat.
296
+ * Apply a command that rode back on a heartbeat or ingest response.
288
297
  *
289
- * Idempotent by `commandId`, because the contract lets the control plane
290
- * repeat a command until it observes the effect — it has no other way to
291
- * know, since it cannot dial in and ask.
298
+ * Idempotent by `commandId`, because the control plane may repeat a command
299
+ * until it observes the effect; it cannot dial the harness to ask.
292
300
  */
293
301
  private applyCommand;
302
+ /** Deliver commands received while the leased runner was still starting. */
303
+ private flushDeferredSteers;
294
304
  private releaseSessionWorkspace;
295
305
  /**
296
306
  * A user's answer did not reach the backend. Do the two things that keep it
package/dist/harness.js CHANGED
@@ -151,10 +151,15 @@ class Harness {
151
151
  releaseGitHubGuard = null;
152
152
  /** sessionId -> the controller that aborts its turn. */
153
153
  active = new Map();
154
+ activeLeaseIds = new Map();
154
155
  /** Renewal workers that must receive a credential rotation in flight. */
155
156
  activeRenewals = new Set();
156
157
  /** commandIds already applied. `SessionCommand` is idempotent by contract. */
157
158
  appliedCommands = new Set();
159
+ /** Prune lease-bound command IDs when their session turn ends. */
160
+ appliedCommandsBySession = new Map();
161
+ /** A steer can arrive on renewal while a leased runner is still starting. */
162
+ deferredSteers = new Map();
158
163
  /** Session-end cleanup waits for an active turn to save its dirty work. */
159
164
  pendingWorkspaceReleases = new Set();
160
165
  /**
@@ -339,6 +344,7 @@ class Harness {
339
344
  }
340
345
  this.stopping = true;
341
346
  this.status = 'draining';
347
+ this.deferredSteers.clear();
342
348
  if (this.heartbeatTimer) {
343
349
  clearTimeout(this.heartbeatTimer);
344
350
  this.heartbeatTimer = null;
@@ -507,9 +513,7 @@ class Harness {
507
513
  }
508
514
  this.observeHarnessPin(response.harnessVersion);
509
515
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
510
- for (const command of response.sessionCommands ?? []) {
511
- this.applyCommand(command);
512
- }
516
+ this.applyCommandsSafely(response.sessionCommands ?? []);
513
517
  log('Reused stored runner credential', {
514
518
  runnerId: stored.runnerId,
515
519
  tenantId: stored.tenantId
@@ -783,9 +787,7 @@ class Harness {
783
787
  }
784
788
  this.observeHarnessPin(response.harnessVersion);
785
789
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
786
- for (const command of response.sessionCommands ?? []) {
787
- this.applyCommand(command);
788
- }
790
+ this.applyCommandsSafely(response.sessionCommands ?? []);
789
791
  if (response.action === 'drain') {
790
792
  this.status = 'draining';
791
793
  log('Control plane asked the harness to drain');
@@ -823,21 +825,85 @@ class Harness {
823
825
  }
824
826
  this.scheduleHeartbeat();
825
827
  }
828
+ /** A bad command must not stop the others or fail a transcript ingest. */
829
+ applyCommandsSafely(commands) {
830
+ for (const command of commands) {
831
+ try {
832
+ this.applyCommand(command);
833
+ }
834
+ catch (error) {
835
+ // Commands are a side channel. An application error must not reject
836
+ // the ingest of model output or skip the commands after this one.
837
+ this.forgetAppliedCommand(command);
838
+ log('Session command failed', {
839
+ sessionId: command.sessionId,
840
+ commandId: command.commandId,
841
+ command: command.command,
842
+ error: error instanceof Error ? error.message : String(error)
843
+ });
844
+ }
845
+ }
846
+ }
847
+ forgetAppliedCommand(command) {
848
+ this.appliedCommands.delete(command.commandId);
849
+ const ids = this.appliedCommandsBySession.get(command.sessionId);
850
+ ids?.delete(command.commandId);
851
+ if (ids?.size === 0)
852
+ this.appliedCommandsBySession.delete(command.sessionId);
853
+ }
854
+ clearAppliedCommandsForSession(sessionId) {
855
+ for (const id of this.appliedCommandsBySession.get(sessionId) ?? []) {
856
+ this.appliedCommands.delete(id);
857
+ }
858
+ this.appliedCommandsBySession.delete(sessionId);
859
+ }
826
860
  /**
827
- * Apply a command that rode back on a heartbeat.
861
+ * Apply a command that rode back on a heartbeat or ingest response.
828
862
  *
829
- * Idempotent by `commandId`, because the contract lets the control plane
830
- * repeat a command until it observes the effect — it has no other way to
831
- * know, since it cannot dial in and ask.
863
+ * Idempotent by `commandId`, because the control plane may repeat a command
864
+ * until it observes the effect; it cannot dial the harness to ask.
832
865
  */
833
866
  applyCommand(command) {
834
867
  if (this.appliedCommands.has(command.commandId)) {
835
868
  return;
836
869
  }
837
870
  this.appliedCommands.add(command.commandId);
871
+ const sessionCommands = this.appliedCommandsBySession.get(command.sessionId) ?? new Set();
872
+ sessionCommands.add(command.commandId);
873
+ this.appliedCommandsBySession.set(command.sessionId, sessionCommands);
874
+ if (command.command === 'steer') {
875
+ const runner = this.runners.get(command.sessionId);
876
+ if (!runner || !command.turnId || !command.leaseId || !command.input) {
877
+ if (!runner && command.turnId && command.leaseId && command.input) {
878
+ this.deferredSteers.set(command.commandId, command);
879
+ }
880
+ this.forgetAppliedCommand(command);
881
+ // The durable turn remains queued until Pi confirms consumption.
882
+ return;
883
+ }
884
+ if (this.activeLeaseIds.get(command.sessionId) !== command.leaseId) {
885
+ this.forgetAppliedCommand(command);
886
+ return;
887
+ }
888
+ runner.steerMessage({
889
+ sessionId: command.sessionId,
890
+ turnId: command.turnId,
891
+ input: command.input,
892
+ acknowledge: async (consumed) => (await this.client.acknowledgeSteer(command.sessionId, command.leaseId, command.turnId, consumed)).delivery
893
+ });
894
+ return;
895
+ }
838
896
  if (command.command === 'abort') {
839
897
  const controller = this.active.get(command.sessionId);
840
- if (controller) {
898
+ const activeLeaseId = this.activeLeaseIds.get(command.sessionId);
899
+ if (!controller || (command.optionId && !activeLeaseId)) {
900
+ // A command can arrive between lease claim and local runner setup.
901
+ // Leave its id open so the control plane's safe abort redelivery can
902
+ // apply it once this exact lease is active.
903
+ this.forgetAppliedCommand(command);
904
+ return;
905
+ }
906
+ if (!command.optionId || activeLeaseId === command.optionId) {
841
907
  log('Aborting session by control-plane command', {
842
908
  sessionId: command.sessionId
843
909
  });
@@ -855,7 +921,7 @@ class Harness {
855
921
  });
856
922
  }
857
923
  else {
858
- void this.releaseSessionWorkspace(command.sessionId);
924
+ void this.releaseSessionWorkspace(command.sessionId).finally(() => this.forgetAppliedCommand(command));
859
925
  }
860
926
  return;
861
927
  }
@@ -896,7 +962,7 @@ class Harness {
896
962
  * all that can be said here; what this buys is that a redelivery would
897
963
  * be attempted rather than discarded as a duplicate.
898
964
  */
899
- this.appliedCommands.delete(command.commandId);
965
+ this.forgetAppliedCommand(command);
900
966
  log('Answer arrived for a session this runner is no longer running', {
901
967
  sessionId: command.sessionId,
902
968
  commandId: command.commandId
@@ -964,6 +1030,16 @@ class Harness {
964
1030
  });
965
1031
  }
966
1032
  }
1033
+ /** Deliver commands received while the leased runner was still starting. */
1034
+ flushDeferredSteers(sessionId, leaseId) {
1035
+ for (const [commandId, command] of this.deferredSteers) {
1036
+ if (command.sessionId !== sessionId)
1037
+ continue;
1038
+ this.deferredSteers.delete(commandId);
1039
+ if (command.leaseId === leaseId)
1040
+ this.applyCommandsSafely([command]);
1041
+ }
1042
+ }
967
1043
  async releaseSessionWorkspace(sessionId) {
968
1044
  try {
969
1045
  const released = await this.workspace.release(sessionId, { force: true });
@@ -993,7 +1069,7 @@ class Harness {
993
1069
  * backend.
994
1070
  */
995
1071
  recoverFailedAnswer(runner, command, reason) {
996
- this.appliedCommands.delete(command.commandId);
1072
+ this.forgetAppliedCommand(command);
997
1073
  void runner.reportApprovalFailure(command.sessionId, reason).catch(() => {
998
1074
  // If even that cannot be delivered, the log at the call site is all
999
1075
  // there is. Never let reporting a failure become a second failure.
@@ -1230,15 +1306,14 @@ class Harness {
1230
1306
  */
1231
1307
  const renewalCallbacks = {
1232
1308
  leaseId: lease.leaseId,
1309
+ // A running turn does not poll the lease endpoint. Ingest returns
1310
+ // commands at each output boundary; a silent tool relies on heartbeat
1311
+ // or this normal renewal interval, which can take up to a minute.
1233
1312
  intervalMs: this.deps.leaseRenewIntervalMs,
1234
1313
  // While a permission prompt is open, renew — and therefore collect
1235
1314
  // commands — every couple of seconds instead of every minute.
1236
1315
  urgentIntervalMs: this.deps.approvalRenewIntervalMs,
1237
- onCommands: (commands) => {
1238
- for (const command of commands) {
1239
- this.applyCommand(command);
1240
- }
1241
- },
1316
+ onCommands: (commands) => this.applyCommandsSafely(commands),
1242
1317
  githubTokenExpiresAt: lease.githubCredential?.expiresAt,
1243
1318
  onGithubCredential: (credential) => {
1244
1319
  githubWorkspace.setCredential?.(sessionId, credential);
@@ -1395,7 +1470,9 @@ class Harness {
1395
1470
  final,
1396
1471
  modelMessages
1397
1472
  })
1398
- .then(() => undefined)
1473
+ .then((response) => {
1474
+ this.applyCommandsSafely(response.sessionCommands ?? []);
1475
+ })
1399
1476
  });
1400
1477
  runnerRef = runner;
1401
1478
  // Registered BEFORE the turn starts. A permission can be raised on the
@@ -1404,6 +1481,8 @@ class Harness {
1404
1481
  // running" — which is indistinguishable, in the log, from a real
1405
1482
  // mis-routing.
1406
1483
  this.runners.set(sessionId, runner);
1484
+ this.activeLeaseIds.set(sessionId, lease.leaseId);
1485
+ this.flushDeferredSteers(sessionId, lease.leaseId);
1407
1486
  try {
1408
1487
  const result = await runner.run(lease, controller.signal);
1409
1488
  log('Turn finished', {
@@ -1446,6 +1525,14 @@ class Harness {
1446
1525
  // that arrives during the commit must defer, never delete under Git.
1447
1526
  this.active.delete(sessionId);
1448
1527
  this.runners.delete(sessionId);
1528
+ this.activeLeaseIds.delete(sessionId);
1529
+ // The control plane expires lease-bound commands at turn end. Keep IDs
1530
+ // only while their lease can redeliver them; a warm runner serves many turns.
1531
+ this.clearAppliedCommandsForSession(sessionId);
1532
+ for (const [commandId, command] of this.deferredSteers) {
1533
+ if (command.sessionId === sessionId)
1534
+ this.deferredSteers.delete(commandId);
1535
+ }
1449
1536
  // Revoke ONLY this session's tokens. A process-wide revoke here blanked
1450
1537
  // the credential of every concurrent turn still running.
1451
1538
  this.tokens.revokeTargets(mcpTargetUrls);
@@ -21,7 +21,7 @@
21
21
  import type { CodingAgentAdapter } from '../adapters';
22
22
  import { SessionModelGateway } from '../adapters/model-gateway';
23
23
  import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, Notifier, SecretProvider, Telemetry, TranscriptStore, WorkspaceProvider } from '../ports';
24
- import { LeaseResponse, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
24
+ import { LeaseResponse, LeasedTurnInput, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
25
25
  import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
26
26
  import { McpAuthProxy } from './mcp-auth-proxy';
27
27
  export interface SessionRunnerDeps {
@@ -260,6 +260,25 @@ export declare class SessionRunner {
260
260
  * three are what `answerPendingApproval` needs and cannot otherwise reach.
261
261
  */
262
262
  private turnContext;
263
+ private readonly pendingSteers;
264
+ private readonly deferredSteers;
265
+ private steerDispatchChain;
266
+ private nextSteerOrder;
267
+ private steeredPrompts;
268
+ private readonly steerEchoes;
269
+ private steerTurnGeneration;
270
+ private steerAcknowledgementsClosed;
271
+ private waitForSteerAcknowledgements;
272
+ /** A command can arrive on heartbeat or renewal while run() is in drain(). */
273
+ steerMessage(command: {
274
+ sessionId: string;
275
+ turnId: string;
276
+ input: LeasedTurnInput;
277
+ acknowledge: (consumed: boolean) => Promise<'steered' | 'queued'>;
278
+ }): void;
279
+ private rejectLateSteer;
280
+ private forgetSteerEcho;
281
+ private consumesSteerEcho;
263
282
  /** The approval currently shown to the user, if any. */
264
283
  private openApproval;
265
284
  /** The transcript identity of an ordinary agent question, if one is open. */
@@ -325,6 +344,7 @@ export declare class SessionRunner {
325
344
  * from the echoed text must not slip past the prefix check on that alone.
326
345
  */
327
346
  private isEchoOfTurnPrompt;
347
+ private isExactSteerEcho;
328
348
  /**
329
349
  * Poll the adapter until the turn goes quiet or the signal aborts.
330
350
  *
@@ -37,6 +37,8 @@ const knowledge_tools_1 = require("./knowledge-tools");
37
37
  const permission_mode_1 = require("./permission-mode");
38
38
  const projection_1 = require("./projection");
39
39
  const space_files_1 = require("./space-files");
40
+ /** A missing Pi consumption event must not hold the lease open for its full RPC timeout. */
41
+ const FINAL_STEER_ACK_WAIT_MS = 5_000;
40
42
  /** How often the runner asks the adapter for new output when nothing pushes. */
41
43
  const POLL_INTERVAL_MS = 2_000;
42
44
  /**
@@ -382,6 +384,150 @@ class SessionRunner {
382
384
  * three are what `answerPendingApproval` needs and cannot otherwise reach.
383
385
  */
384
386
  turnContext = null;
387
+ pendingSteers = new Set();
388
+ deferredSteers = [];
389
+ steerDispatchChain = Promise.resolve();
390
+ nextSteerOrder = 0;
391
+ steeredPrompts = [];
392
+ steerEchoes = new Map();
393
+ steerTurnGeneration = 0;
394
+ steerAcknowledgementsClosed = false;
395
+ async waitForSteerAcknowledgements() {
396
+ const pending = [...this.pendingSteers];
397
+ if (pending.length === 0) {
398
+ this.steerAcknowledgementsClosed = true;
399
+ return;
400
+ }
401
+ let timeout;
402
+ try {
403
+ const settled = await Promise.race([
404
+ Promise.allSettled(pending).then(() => true),
405
+ new Promise((resolve) => {
406
+ timeout = setTimeout(() => resolve(false), FINAL_STEER_ACK_WAIT_MS);
407
+ })
408
+ ]);
409
+ if (!settled) {
410
+ // Final ingest releases the lease and requeues every steer that has
411
+ // not been acknowledged. A late acknowledgement cannot claim it.
412
+ this.logger.warn(`[SessionRunner] steer-final-wait-timeout session=${this.turnContext?.sessionId ?? 'unknown'} pending=${pending.length}`);
413
+ }
414
+ }
415
+ finally {
416
+ this.steerAcknowledgementsClosed = true;
417
+ if (timeout)
418
+ clearTimeout(timeout);
419
+ }
420
+ }
421
+ /** A command can arrive on heartbeat or renewal while run() is in drain(). */
422
+ steerMessage(command) {
423
+ const context = this.turnContext;
424
+ if (!context) {
425
+ if (this.steerAcknowledgementsClosed) {
426
+ this.rejectLateSteer(command);
427
+ }
428
+ else {
429
+ this.deferredSteers.push(command);
430
+ }
431
+ return;
432
+ }
433
+ if (context.sessionId !== command.sessionId ||
434
+ !context.adapter.steerUserMessage) {
435
+ this.rejectLateSteer(command);
436
+ return;
437
+ }
438
+ if (this.steerAcknowledgementsClosed) {
439
+ this.rejectLateSteer(command);
440
+ return;
441
+ }
442
+ const generation = this.steerTurnGeneration;
443
+ const order = this.nextSteerOrder++;
444
+ // Preparation (not Pi consumption) is serial. Fast sends enter Pi's queue
445
+ // in transcript order, so steeringMode=all can use all at one boundary.
446
+ const prepared = this.steerDispatchChain.then(async () => {
447
+ let prompt = command.input.content;
448
+ if (context.workspaceDir &&
449
+ (command.input.attachments?.length ||
450
+ command.input.droppedAttachments?.length)) {
451
+ const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(command.input.attachments, context.workspaceDir, {
452
+ fetchImpl: this.deps.fetchAttachment,
453
+ logger: this.logger,
454
+ turnId: command.turnId
455
+ });
456
+ for (const dropped of command.input.droppedAttachments ?? []) {
457
+ sync.failed.push({
458
+ name: (0, attachments_1.sanitizeAttachmentName)(dropped.name),
459
+ reason: dropped.reason
460
+ });
461
+ }
462
+ const note = (0, attachments_1.attachmentPromptNote)(sync);
463
+ if (note)
464
+ prompt = `${prompt}\n\n${note}`;
465
+ }
466
+ const seen = this.turnContext === context
467
+ ? context.adapter.steerUserMessage(context.backendSessionId, prompt, context.config)
468
+ : Promise.resolve(false);
469
+ // Pi can emit the user echo before the consumption acknowledgement
470
+ // returns. Filter that echo as soon as the prompt enters its queue.
471
+ const echoes = this.steerEchoes.get(prompt) ?? [];
472
+ echoes.push(command.turnId);
473
+ this.steerEchoes.set(prompt, echoes);
474
+ return { prompt, seen };
475
+ });
476
+ this.steerDispatchChain = prepared.then(() => undefined, () => undefined);
477
+ const task = prepared
478
+ .then(async ({ prompt, seen: consumed }) => {
479
+ const seen = await consumed;
480
+ if (!seen ||
481
+ this.steerAcknowledgementsClosed ||
482
+ generation !== this.steerTurnGeneration) {
483
+ if (!seen)
484
+ this.logger.warn(`[SessionRunner] steer unconfirmed; queued for next turn (session=${command.sessionId}, turn=${command.turnId})`);
485
+ this.forgetSteerEcho(prompt, command.turnId);
486
+ // Pi rejected the steer, or its queue did not consume it. Reset the
487
+ // durable message now; release remains the fallback if this fails.
488
+ await command.acknowledge(false);
489
+ return;
490
+ }
491
+ // Record Pi's consumption before the network ack. If the ack hangs,
492
+ // final ingest must still log what Pi saw in this turn. A queued
493
+ // fallback can replay that text under the at-least-once crash rule.
494
+ this.steeredPrompts.push({ order, text: prompt });
495
+ await command.acknowledge(true);
496
+ })
497
+ .catch((error) => {
498
+ this.logger.warn(`[SessionRunner] steer failed for ${command.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
499
+ });
500
+ this.pendingSteers.add(task);
501
+ void task.finally(() => this.pendingSteers.delete(task));
502
+ }
503
+ rejectLateSteer(command) {
504
+ void command.acknowledge(false).catch((error) => {
505
+ this.logger.warn(`[SessionRunner] late steer reset failed: ${error instanceof Error ? error.message : String(error)}`);
506
+ });
507
+ }
508
+ forgetSteerEcho(text, turnId) {
509
+ const echoes = this.steerEchoes.get(text);
510
+ if (!echoes)
511
+ return;
512
+ const index = echoes.indexOf(turnId);
513
+ if (index >= 0)
514
+ echoes.splice(index, 1);
515
+ if (echoes.length === 0)
516
+ this.steerEchoes.delete(text);
517
+ }
518
+ consumesSteerEcho(part) {
519
+ for (const [text, echoes] of this.steerEchoes) {
520
+ if (!this.isExactSteerEcho(part, text))
521
+ continue;
522
+ // Each matching user part consumes one steer, even when several steers
523
+ // have exactly the same text.
524
+ echoes.shift();
525
+ if (echoes.length === 0)
526
+ this.steerEchoes.delete(text);
527
+ return true;
528
+ }
529
+ return false;
530
+ }
385
531
  /** The approval currently shown to the user, if any. */
386
532
  openApproval = null;
387
533
  /** The transcript identity of an ordinary agent question, if one is open. */
@@ -469,6 +615,13 @@ class SessionRunner {
469
615
  */
470
616
  let turnContent = session.input.content;
471
617
  this.turnParts = [];
618
+ this.steerTurnGeneration++;
619
+ this.steerAcknowledgementsClosed = false;
620
+ this.pendingSteers.clear();
621
+ this.steeredPrompts = [];
622
+ this.steerDispatchChain = Promise.resolve();
623
+ this.nextSteerOrder = 0;
624
+ this.steerEchoes.clear();
472
625
  this.modelTokenExpiresAt = lease.modelTokenExpiresAt ?? null;
473
626
  this.modelKeyExpiryNoticeShown = false;
474
627
  try {
@@ -847,8 +1000,12 @@ class SessionRunner {
847
1000
  sessionId: session.sessionId,
848
1001
  backendSessionId,
849
1002
  adapter,
850
- config
1003
+ config,
1004
+ workspaceDir
851
1005
  };
1006
+ for (const command of this.deferredSteers.splice(0)) {
1007
+ this.steerMessage(command);
1008
+ }
852
1009
  const drained = await this.drain(adapter, backendSessionId, config, session, signal,
853
1010
  // RESUMING REPLAYS THE WHOLE BACKEND SESSION. `pollMessages` is asked
854
1011
  // for "everything not in these sets", and on a resumed session that is
@@ -946,6 +1103,7 @@ class SessionRunner {
946
1103
  * turn happened to reset it.
947
1104
  */
948
1105
  this.turnContext = null;
1106
+ this.deferredSteers.length = 0;
949
1107
  this.closeApproval(session.sessionId);
950
1108
  this.deps.knowledge?.host.bindTurn(session.sessionId, null);
951
1109
  this.closeQuestion(session.sessionId);
@@ -1040,9 +1198,18 @@ class SessionRunner {
1040
1198
  * but a conversation that silently doubles in size every turn, which is the
1041
1199
  * kind of defect that looks fine for a week.
1042
1200
  */
1201
+ // Give Pi a short time to confirm consumption. Final ingest requeues any
1202
+ // unconfirmed steer; an absent event must not keep the lease alive.
1203
+ await this.waitForSteerAcknowledgements();
1043
1204
  const spoken = (0, conversation_1.assistantTextFrom)(this.turnParts.filter((part) => !priorPartIdsForLog.has(part.partId)));
1044
1205
  const modelMessages = [
1045
1206
  { role: 'user', content: turnContent },
1207
+ // This store keeps one assistant message per turn. Steers retain their
1208
+ // order, but their exact interleaving with streamed assistant text is
1209
+ // not represented in this compact replay log.
1210
+ ...this.steeredPrompts
1211
+ .sort((a, b) => a.order - b.order)
1212
+ .map(({ text }) => ({ role: 'user', content: text })),
1046
1213
  ...(spoken ? [{ role: 'assistant', content: spoken }] : [])
1047
1214
  ];
1048
1215
  // Store locally FIRST, for the same reason parts are: the local copy is
@@ -1118,6 +1285,12 @@ class SessionRunner {
1118
1285
  return false;
1119
1286
  return sentPromptText.trim().startsWith(echoed);
1120
1287
  }
1288
+ isExactSteerEcho(part, steeredPrompt) {
1289
+ if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user')
1290
+ return false;
1291
+ const text = part.content ?? part.text;
1292
+ return typeof text === 'string' && text.trim() === steeredPrompt.trim();
1293
+ }
1121
1294
  /**
1122
1295
  * Poll the adapter until the turn goes quiet or the signal aborts.
1123
1296
  *
@@ -1269,7 +1442,11 @@ class SessionRunner {
1269
1442
  * are recorded by the answer path itself, with the `answer-…` part id.
1270
1443
  */
1271
1444
  const freshParts = sentPromptText && sentPromptText.length > 0
1272
- ? parts.filter((part) => !this.isEchoOfTurnPrompt(part, sentPromptText))
1445
+ ? parts.filter((part) => {
1446
+ if (this.isEchoOfTurnPrompt(part, sentPromptText))
1447
+ return false;
1448
+ return !this.consumesSteerEcho(part);
1449
+ })
1273
1450
  : parts;
1274
1451
  /*
1275
1452
  * WIRE 2: A PENDING PERMISSION BECOMES A TRANSCRIPT PART.
package/dist/types.d.ts CHANGED
@@ -333,7 +333,7 @@ export interface SessionCommand {
333
333
  * 'abort' stops the turn; 'answer' resolves a pending question part;
334
334
  * 'release-workspace' removes a checkout after the session ends.
335
335
  */
336
- command: 'abort' | 'answer' | 'release-workspace';
336
+ command: 'abort' | 'answer' | 'release-workspace' | 'steer';
337
337
  /** Set when command is 'answer' — the partId of the question being answered. */
338
338
  partId?: string;
339
339
  /** Set when command is 'answer' — the user's reply. */
@@ -350,6 +350,12 @@ export interface SessionCommand {
350
350
  * questions, and ids are none of those things.
351
351
  */
352
352
  optionId?: string;
353
+ /** A steer targets only this live lease. */
354
+ leaseId?: string;
355
+ /** The durable queued turn that becomes the fallback if steering fails. */
356
+ turnId?: string;
357
+ /** The message, with signed attachment URLs, from the control plane. */
358
+ input?: LeasedTurnInput;
353
359
  /** De-duplication key. The same commandId must be applied at most once. */
354
360
  commandId: string;
355
361
  }
@@ -788,6 +794,8 @@ export interface IngestResponse {
788
794
  * "the lease expired while it was working" bug.
789
795
  */
790
796
  leaseExpiresAt: string;
797
+ /** Commands collected at this model/tool boundary. Older APIs omit it. */
798
+ sessionCommands?: SessionCommand[];
791
799
  }
792
800
  export interface IngestRejection {
793
801
  partId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.5-rc.1",
3
+ "version": "0.2.5-rc.2",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",