@peakflo/agent-harness 0.2.4 → 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.
@@ -41,6 +41,10 @@ export interface McpServerConfig {
41
41
  export interface SessionConfig {
42
42
  agentId: string;
43
43
  taskId: string;
44
+ /** Leased turn identifier for timing logs. */
45
+ turnId?: string;
46
+ /** Best-effort structured log sink for turn phases. */
47
+ coldStartLog?: (line: string) => void;
44
48
  workspaceDir: string;
45
49
  /**
46
50
  * Refuse dev servers and app scaffolding in shell calls unless the turn's
@@ -182,6 +186,8 @@ export interface CodingAgentAdapter {
182
186
  * not (the caller still has the transcript note either way).
183
187
  */
184
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>;
185
191
  /**
186
192
  * Get current session status
187
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[]>;
@@ -21,6 +21,7 @@ const string_decoder_1 = require("string_decoder");
21
21
  const util_1 = require("util");
22
22
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
23
23
  const model_gateway_1 = require("./model-gateway");
24
+ const cold_start_steps_1 = require("../runtime/cold-start-steps");
24
25
  const dev_server_guard_1 = require("../runtime/dev-server-guard");
25
26
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
26
27
  const RPC_TIMEOUT_MS = 15_000;
@@ -29,6 +30,9 @@ const MINIMUM_PI_VERSION = [0, 80, 5];
29
30
  const PI_PERMISSION_MODE_ENV = 'PEAKFLO_PI_PERMISSION_MODE';
30
31
  exports.PI_DEV_SERVER_POLICY_ENV = 'PF_DEV_SERVER_POLICY_FILE';
31
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;
32
36
  const piInstanceIdCache = new Map();
33
37
  /**
34
38
  * Most model providers cap function/tool `name` at 64 characters. Pi forwards
@@ -227,6 +231,16 @@ function textFromContent(content) {
227
231
  .map((block) => String(block.text ?? ''))
228
232
  .join('');
229
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
+ }
230
244
  function resultText(result) {
231
245
  if (!result || typeof result !== 'object')
232
246
  return '';
@@ -470,6 +484,8 @@ class PiAdapter {
470
484
  status: 'idle',
471
485
  lastError: null,
472
486
  pending: new Map(),
487
+ pendingSteers: [],
488
+ steeringSupported: true,
473
489
  parts: [],
474
490
  allMessages: [],
475
491
  stdoutBuffer: '',
@@ -501,6 +517,17 @@ class PiAdapter {
501
517
  };
502
518
  }
503
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
+ }
504
531
  if (config.model && config.model !== 'default') {
505
532
  const { provider, modelId } = this.splitModel(config.model);
506
533
  if (!provider || !modelId) {
@@ -516,10 +543,20 @@ class PiAdapter {
516
543
  }
517
544
  }
518
545
  async spawnSession(config, resumeId) {
546
+ const steps = config.turnId
547
+ ? new cold_start_steps_1.ColdStartSteps(config.taskId, config.turnId, (line) => {
548
+ console.log(line);
549
+ config.coldStartLog?.(line);
550
+ })
551
+ : null;
519
552
  const executable = await this.findPiExecutable();
520
553
  const gateway = this.installPeakfloGateway();
554
+ const endMcpConfig = steps?.begin('mcp_config_write');
521
555
  const mcpConfigPath = this.buildMcpConfig(config);
556
+ endMcpConfig?.();
557
+ const endSkills = steps?.begin('skills_install');
522
558
  const skills = this.buildSkillFiles(config);
559
+ endSkills?.();
523
560
  const permissionExtension = this.installPermissionExtension();
524
561
  const args = [
525
562
  '--mode',
@@ -564,7 +601,9 @@ class PiAdapter {
564
601
  this.sessions.set(tempId, session);
565
602
  this.attachProcess(session);
566
603
  try {
604
+ const endPiReady = steps?.begin('pi_process_ready');
567
605
  const state = await this.command(session, { type: 'get_state' });
606
+ endPiReady?.();
568
607
  const realId = typeof state.data?.sessionFile === 'string'
569
608
  ? state.data.sessionFile
570
609
  : typeof state.data?.sessionId === 'string'
@@ -656,12 +695,41 @@ class PiAdapter {
656
695
  }
657
696
  }
658
697
  rejectPending(session, error) {
698
+ for (const steer of session.pendingSteers.splice(0))
699
+ steer.resolve(false);
659
700
  for (const request of session.pending.values()) {
660
701
  clearTimeout(request.timer);
661
702
  request.reject(error);
662
703
  }
663
704
  session.pending.clear();
664
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
+ }
665
733
  handleEvent(session, event) {
666
734
  if (event.type === 'response') {
667
735
  const response = event;
@@ -715,10 +783,17 @@ class PiAdapter {
715
783
  break;
716
784
  case 'agent_settled': {
717
785
  this.cancelPendingUiRequests(session, 'This request is no longer active.');
718
- if (session.pendingTurnError)
786
+ if (session.pendingTurnError) {
719
787
  this.settlePendingTurnError(session);
720
- else
721
- 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
+ }
722
797
  session.promptMayBeCommandOnly = false;
723
798
  break;
724
799
  }
@@ -726,6 +801,16 @@ class PiAdapter {
726
801
  const message = event.message;
727
802
  if (message?.role === 'assistant')
728
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
+ }
729
814
  break;
730
815
  }
731
816
  case 'message_update':
@@ -893,6 +978,11 @@ class PiAdapter {
893
978
  handleMessageUpdate(session, update) {
894
979
  if (!update)
895
980
  return;
981
+ if ((update.type === 'text_delta' || update.type === 'thinking_delta') &&
982
+ String(update.delta || '')) {
983
+ session.firstTokenEnd?.();
984
+ session.firstTokenEnd = undefined;
985
+ }
896
986
  const index = String(update.contentIndex ?? 0);
897
987
  const key = `${session.projectionId}:${session.turn}:${session.assistantMessageOrdinal}:${index}`;
898
988
  if (update.type === 'text_delta') {
@@ -1050,6 +1140,12 @@ class PiAdapter {
1050
1140
  session.lastError = null;
1051
1141
  session.sawAgentActivity = false;
1052
1142
  session.promptMayBeCommandOnly = message.trimStart().startsWith('/');
1143
+ session.firstTokenEnd = config.turnId
1144
+ ? new cold_start_steps_1.ColdStartSteps(config.taskId, config.turnId, (line) => {
1145
+ console.log(line);
1146
+ config.coldStartLog?.(line);
1147
+ }).begin('first_model_token')
1148
+ : undefined;
1053
1149
  this.sendRecord(session, {
1054
1150
  type: 'prompt',
1055
1151
  message,
@@ -1083,8 +1179,45 @@ class PiAdapter {
1083
1179
  });
1084
1180
  return true;
1085
1181
  }
1086
- 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)
1087
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);
1088
1221
  }
1089
1222
  }
1090
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
  }
@@ -31,7 +31,7 @@
31
31
  * needs to tell such a harness apart, which it does by refusing it
32
32
  * rather than silently serving a value that would misbehave.
33
33
  */
34
- export declare const PROTOCOL_VERSION = 2;
34
+ export declare const PROTOCOL_VERSION = 3;
35
35
  /**
36
36
  * The floor a harness must meet to be served `maxTurnDurationMs: null`.
37
37
  *
@@ -178,6 +178,8 @@ export declare const ROUTES: {
178
178
  readonly register: () => string;
179
179
  /** Bearer: runner credential. 30s period. */
180
180
  readonly heartbeat: (runnerId: string) => string;
181
+ /** Bearer: runner credential. Best-effort timing event; no transcript data. */
182
+ readonly coldStart: (runnerId: string) => string;
181
183
  /**
182
184
  * Bearer: runner credential. Bounded long-poll: 204 idle, 200 with work.
183
185
  *
@@ -194,6 +196,7 @@ export declare const ROUTES: {
194
196
  readonly lease: (runnerId: string, waitSeconds?: number, protocolVersion?: number) => string;
195
197
  /** Bearer: runner credential. Visibility-timeout renewal. */
196
198
  readonly leaseRenew: (runnerId: string, leaseId: string) => string;
199
+ readonly steerAck: (runnerId: string, leaseId: string, turnId: string) => string;
197
200
  /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
198
201
  readonly knowledgeFiles: (runnerId: string, leaseId: string) => string;
199
202
  /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
package/dist/contract.js CHANGED
@@ -42,7 +42,7 @@ exports.isPersistablePart = isPersistablePart;
42
42
  * needs to tell such a harness apart, which it does by refusing it
43
43
  * rather than silently serving a value that would misbehave.
44
44
  */
45
- exports.PROTOCOL_VERSION = 2;
45
+ exports.PROTOCOL_VERSION = 3;
46
46
  /**
47
47
  * The floor a harness must meet to be served `maxTurnDurationMs: null`.
48
48
  *
@@ -215,6 +215,8 @@ exports.ROUTES = {
215
215
  register: () => '/api/runners/register',
216
216
  /** Bearer: runner credential. 30s period. */
217
217
  heartbeat: (runnerId) => `/api/runners/${encodeURIComponent(runnerId)}/heartbeat`,
218
+ /** Bearer: runner credential. Best-effort timing event; no transcript data. */
219
+ coldStart: (runnerId) => `/api/runners/${encodeURIComponent(runnerId)}/cold-start`,
218
220
  /**
219
221
  * Bearer: runner credential. Bounded long-poll: 204 idle, 200 with work.
220
222
  *
@@ -234,6 +236,7 @@ exports.ROUTES = {
234
236
  },
235
237
  /** Bearer: runner credential. Visibility-timeout renewal. */
236
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`,
237
240
  /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
238
241
  knowledgeFiles: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/files`,
239
242
  /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
@@ -81,6 +81,8 @@ export declare class ControlPlaneClient {
81
81
  /** Used only for a private IPC handoff to the baked sandbox supervisor. */
82
82
  getRunnerCredential(): string | null;
83
83
  getRunnerId(): string | null;
84
+ /** Diagnostics must never delay a turn. The caller handles any failure. */
85
+ reportColdStart(line: string): Promise<void>;
84
86
  /**
85
87
  * Give the lease-renewal worker the minimum information it needs.
86
88
  *
@@ -129,6 +131,7 @@ export declare class ControlPlaneClient {
129
131
  * 25s poll under a 15s timeout would abort every single call.
130
132
  */
131
133
  lease(waitSeconds?: number): Promise<LeaseResponse | null>;
134
+ acknowledgeLease(leaseId: string): Promise<LeaseRenewResponse>;
132
135
  renewLease(leaseId: string, status?: RunnerStatus, githubTokenExpiresAt?: string, modelTokenExpiresAt?: string): Promise<LeaseRenewResponse>;
133
136
  /** Metadata of the knowledge files the lease's turn may read. Never a URL. */
134
137
  knowledgeFiles(leaseId: string, datastoreId?: string): Promise<KnowledgeFilesResponse>;
@@ -147,6 +150,9 @@ export declare class ControlPlaneClient {
147
150
  * idempotent.
148
151
  */
149
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
+ }>;
150
156
  /**
151
157
  * Read the session's model-facing conversation back from the control plane.
152
158
  *
@@ -114,6 +114,18 @@ class ControlPlaneClient {
114
114
  getRunnerId() {
115
115
  return this.runnerId;
116
116
  }
117
+ /** Diagnostics must never delay a turn. The caller handles any failure. */
118
+ async reportColdStart(line) {
119
+ if (!this.runnerId || !this.runnerCredential)
120
+ return;
121
+ await this.send({
122
+ method: 'POST',
123
+ path: contract_1.ROUTES.coldStart(this.runnerId),
124
+ token: this.runnerCredential,
125
+ body: JSON.parse(line),
126
+ timeoutMs: 2_000
127
+ });
128
+ }
117
129
  /**
118
130
  * Give the lease-renewal worker the minimum information it needs.
119
131
  *
@@ -248,6 +260,15 @@ class ControlPlaneClient {
248
260
  /* ------------------------------------------------------------------ */
249
261
  /* 4. lease renew */
250
262
  /* ------------------------------------------------------------------ */
263
+ async acknowledgeLease(leaseId) {
264
+ const runnerId = this.requireRunnerId();
265
+ return this.send({
266
+ method: 'POST',
267
+ path: contract_1.ROUTES.leaseRenew(runnerId, leaseId),
268
+ token: this.requireCredential(),
269
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, ackOnly: true }
270
+ });
271
+ }
251
272
  async renewLease(leaseId, status, githubTokenExpiresAt, modelTokenExpiresAt) {
252
273
  const runnerId = this.requireRunnerId();
253
274
  return this.send({
@@ -308,6 +329,14 @@ class ControlPlaneClient {
308
329
  body: { protocolVersion: contract_1.PROTOCOL_VERSION, ...request }
309
330
  }));
310
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
+ }
311
340
  /* ------------------------------------------------------------------ */
312
341
  /* 5b. session history — the runner's READ path */
313
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