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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -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;
@@ -516,10 +517,20 @@ class PiAdapter {
516
517
  }
517
518
  }
518
519
  async spawnSession(config, resumeId) {
520
+ const steps = config.turnId
521
+ ? new cold_start_steps_1.ColdStartSteps(config.taskId, config.turnId, (line) => {
522
+ console.log(line);
523
+ config.coldStartLog?.(line);
524
+ })
525
+ : null;
519
526
  const executable = await this.findPiExecutable();
520
527
  const gateway = this.installPeakfloGateway();
528
+ const endMcpConfig = steps?.begin('mcp_config_write');
521
529
  const mcpConfigPath = this.buildMcpConfig(config);
530
+ endMcpConfig?.();
531
+ const endSkills = steps?.begin('skills_install');
522
532
  const skills = this.buildSkillFiles(config);
533
+ endSkills?.();
523
534
  const permissionExtension = this.installPermissionExtension();
524
535
  const args = [
525
536
  '--mode',
@@ -564,7 +575,9 @@ class PiAdapter {
564
575
  this.sessions.set(tempId, session);
565
576
  this.attachProcess(session);
566
577
  try {
578
+ const endPiReady = steps?.begin('pi_process_ready');
567
579
  const state = await this.command(session, { type: 'get_state' });
580
+ endPiReady?.();
568
581
  const realId = typeof state.data?.sessionFile === 'string'
569
582
  ? state.data.sessionFile
570
583
  : typeof state.data?.sessionId === 'string'
@@ -893,6 +906,11 @@ class PiAdapter {
893
906
  handleMessageUpdate(session, update) {
894
907
  if (!update)
895
908
  return;
909
+ if ((update.type === 'text_delta' || update.type === 'thinking_delta') &&
910
+ String(update.delta || '')) {
911
+ session.firstTokenEnd?.();
912
+ session.firstTokenEnd = undefined;
913
+ }
896
914
  const index = String(update.contentIndex ?? 0);
897
915
  const key = `${session.projectionId}:${session.turn}:${session.assistantMessageOrdinal}:${index}`;
898
916
  if (update.type === 'text_delta') {
@@ -1050,6 +1068,12 @@ class PiAdapter {
1050
1068
  session.lastError = null;
1051
1069
  session.sawAgentActivity = false;
1052
1070
  session.promptMayBeCommandOnly = message.trimStart().startsWith('/');
1071
+ session.firstTokenEnd = config.turnId
1072
+ ? new cold_start_steps_1.ColdStartSteps(config.taskId, config.turnId, (line) => {
1073
+ console.log(line);
1074
+ config.coldStartLog?.(line);
1075
+ }).begin('first_model_token')
1076
+ : undefined;
1053
1077
  this.sendRecord(session, {
1054
1078
  type: 'prompt',
1055
1079
  message,
@@ -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
  *
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
  *
@@ -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>;
@@ -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({
package/dist/harness.js CHANGED
@@ -21,6 +21,7 @@ const sqlite_store_1 = require("./store/sqlite-store");
21
21
  const version_1 = require("./version");
22
22
  /** Backoff after a lease poll fails, so an unreachable control plane is not hammered. */
23
23
  const LEASE_ERROR_BACKOFF_MS = 5_000;
24
+ const LEASE_MIN_EMPTY_POLL_MS = 250;
24
25
  /**
25
26
  * How often the model catalogue is re-probed once the harness is running.
26
27
  *
@@ -1031,6 +1032,7 @@ class Harness {
1031
1032
  continue;
1032
1033
  }
1033
1034
  let lease = null;
1035
+ const pollStartedAt = Date.now();
1034
1036
  try {
1035
1037
  lease = await this.client.lease(this.leaseWaitSeconds);
1036
1038
  }
@@ -1050,12 +1052,54 @@ class Harness {
1050
1052
  continue;
1051
1053
  }
1052
1054
  if (!lease) {
1053
- // 204: no work. Poll again immediately — the wait already happened
1054
- // server-side, which is the entire point of a bounded long-poll.
1055
+ // The server normally long-polls. A 204 returned immediately during
1056
+ // a fence or by a faulty proxy must not make this process spin.
1057
+ const waitLeft = LEASE_MIN_EMPTY_POLL_MS - (Date.now() - pollStartedAt);
1058
+ if (waitLeft > 0)
1059
+ await sleep(waitLeft);
1055
1060
  continue;
1056
1061
  }
1062
+ const leaseReceived = JSON.stringify({
1063
+ event: 'cold_start_step',
1064
+ sessionId: lease.session.sessionId,
1065
+ turnId: lease.session.turnId,
1066
+ step: 'lease_poll_to_pickup',
1067
+ startedAt: new Date(pollStartedAt).toISOString(),
1068
+ endedAt: new Date().toISOString(),
1069
+ durationMs: Date.now() - pollStartedAt
1070
+ });
1071
+ log(leaseReceived);
1072
+ void this.client.reportColdStart(leaseReceived).catch(() => undefined);
1057
1073
  this.observeHarnessPin(lease.harnessVersion);
1058
1074
  this.observeSandboxToolsPin(lease.sandboxToolsVersion);
1075
+ // Protocol 3 uses a short visibility window until the runner confirms
1076
+ // receipt. A dropped GET response is then reclaimed within nine seconds.
1077
+ // Do not start work unless the acknowledgement extends this lease.
1078
+ let leaseAcknowledged = false;
1079
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1080
+ try {
1081
+ const acknowledged = await this.client.acknowledgeLease(lease.leaseId);
1082
+ if (!acknowledged.renewed) {
1083
+ log('Lease acknowledgement refused', { leaseId: lease.leaseId });
1084
+ break;
1085
+ }
1086
+ leaseAcknowledged = true;
1087
+ break;
1088
+ }
1089
+ catch (error) {
1090
+ log('Lease acknowledgement failed', {
1091
+ leaseId: lease.leaseId,
1092
+ attempt: attempt + 1,
1093
+ error: error instanceof Error ? error.message : String(error)
1094
+ });
1095
+ if (attempt < 2)
1096
+ await sleep(500);
1097
+ }
1098
+ }
1099
+ if (!leaseAcknowledged) {
1100
+ await sleep(LEASE_ERROR_BACKOFF_MS);
1101
+ continue;
1102
+ }
1059
1103
  void this.runLeasedTurn(lease);
1060
1104
  }
1061
1105
  await this.stop();
@@ -1306,6 +1350,9 @@ class Harness {
1306
1350
  secrets: this.secrets,
1307
1351
  events: this.events,
1308
1352
  telemetry: this.telemetry,
1353
+ coldStartLog: (line) => {
1354
+ void this.client.reportColdStart(line).catch(() => undefined);
1355
+ },
1309
1356
  notifier: this.notifier,
1310
1357
  artifacts: this.artifacts,
1311
1358
  conversation: this.conversation,
@@ -0,0 +1,11 @@
1
+ /** Timing for one leased turn. Values in this log contain no credentials. */
2
+ export declare class ColdStartSteps {
3
+ private readonly sessionId;
4
+ private readonly turnId;
5
+ private readonly log;
6
+ private readonly startedAt;
7
+ private readonly steps;
8
+ constructor(sessionId: string, turnId: string, log: (line: string) => void);
9
+ begin(step: string): () => void;
10
+ summary(): void;
11
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ColdStartSteps = void 0;
4
+ /** Timing for one leased turn. Values in this log contain no credentials. */
5
+ class ColdStartSteps {
6
+ sessionId;
7
+ turnId;
8
+ log;
9
+ startedAt = Date.now();
10
+ steps = {};
11
+ constructor(sessionId, turnId, log) {
12
+ this.sessionId = sessionId;
13
+ this.turnId = turnId;
14
+ this.log = log;
15
+ }
16
+ begin(step) {
17
+ const startedAt = Date.now();
18
+ let ended = false;
19
+ return () => {
20
+ if (ended)
21
+ return;
22
+ ended = true;
23
+ const endedAt = Date.now();
24
+ const durationMs = Math.max(0, endedAt - startedAt);
25
+ this.steps[step] = durationMs;
26
+ try {
27
+ this.log(JSON.stringify({
28
+ event: 'cold_start_step',
29
+ sessionId: this.sessionId,
30
+ turnId: this.turnId,
31
+ step,
32
+ startedAt: new Date(startedAt).toISOString(),
33
+ endedAt: new Date(endedAt).toISOString(),
34
+ durationMs
35
+ }));
36
+ }
37
+ catch {
38
+ // A diagnostic sink must never stop an agent turn.
39
+ }
40
+ };
41
+ }
42
+ summary() {
43
+ try {
44
+ this.log(JSON.stringify({
45
+ event: 'cold_start_summary',
46
+ sessionId: this.sessionId,
47
+ turnId: this.turnId,
48
+ durationMs: Math.max(0, Date.now() - this.startedAt),
49
+ steps: this.steps
50
+ }));
51
+ }
52
+ catch {
53
+ // A diagnostic sink must never stop an agent turn.
54
+ }
55
+ }
56
+ }
57
+ exports.ColdStartSteps = ColdStartSteps;
@@ -40,6 +40,8 @@ export interface SessionRunnerDeps {
40
40
  secrets: SecretProvider;
41
41
  events: EventSink;
42
42
  telemetry: Telemetry;
43
+ /** Send a structured timing event to the control plane without waiting. */
44
+ coldStartLog?: (line: string) => void;
43
45
  notifier: Notifier;
44
46
  artifacts: ArtifactInspector;
45
47
  /**
@@ -31,6 +31,7 @@ const node_crypto_1 = require("node:crypto");
31
31
  const model_gateway_1 = require("../adapters/model-gateway");
32
32
  const contract_1 = require("../contract");
33
33
  const attachments_1 = require("./attachments");
34
+ const cold_start_steps_1 = require("./cold-start-steps");
34
35
  const conversation_1 = require("./conversation");
35
36
  const knowledge_tools_1 = require("./knowledge-tools");
36
37
  const permission_mode_1 = require("./permission-mode");
@@ -438,6 +439,10 @@ class SessionRunner {
438
439
  */
439
440
  async run(lease, signal) {
440
441
  const { session } = lease;
442
+ const coldStart = new cold_start_steps_1.ColdStartSteps(session.sessionId, session.turnId, (line) => {
443
+ this.logger.log(line);
444
+ this.deps.coldStartLog?.(line);
445
+ });
441
446
  const startedAt = new Date().toISOString();
442
447
  const release = this.deps.notifier.keepAwake();
443
448
  let workspaceDir = null;
@@ -470,6 +475,18 @@ class SessionRunner {
470
475
  const refusal = modelGatewayRefusal(lease);
471
476
  if (refusal)
472
477
  throw new Error(refusal);
478
+ // The catalogue is advisory. Fetch it while the independent workspace
479
+ // is prepared, then install it before the backend process starts.
480
+ const endModels = coldStart.begin('model_catalogue');
481
+ const gatewayModels = session.agent.model.gatewayUrl
482
+ ? (0, model_gateway_1.fetchGatewayModels)({
483
+ baseUrl: session.agent.model.gatewayUrl,
484
+ apiKey: lease.modelToken
485
+ }).finally(endModels)
486
+ : Promise.resolve([]);
487
+ if (!session.agent.model.gatewayUrl)
488
+ endModels();
489
+ const endWorkspace = coldStart.begin('workspace_prepare');
473
490
  try {
474
491
  workspaceDir = await this.deps.workspace.prepare({
475
492
  sessionId: session.sessionId,
@@ -486,6 +503,7 @@ class SessionRunner {
486
503
  },
487
504
  refusalReason: session.workspace.refusalReason
488
505
  });
506
+ endWorkspace();
489
507
  const repositoryNames = session.workspace.requestedRepositories ??
490
508
  (session.workspace.repositories ?? []).map(({ fullName }) => fullName);
491
509
  for (const fullName of repositoryNames) {
@@ -495,6 +513,7 @@ class SessionRunner {
495
513
  }
496
514
  }
497
515
  catch (caught) {
516
+ endWorkspace();
498
517
  const reason = caught instanceof Error ? caught.message : String(caught);
499
518
  const status = session.workspace.refusalReason ? 'refused' : 'failed';
500
519
  const names = session.workspace.requestedRepositories ??
@@ -530,7 +549,13 @@ class SessionRunner {
530
549
  */
531
550
  const hasDroppedAttachments = (session.input.droppedAttachments?.length ?? 0) > 0;
532
551
  if (workspaceDir) {
533
- await this.syncSpaceFilesForTurn(session, workspaceDir);
552
+ const endSpaceFiles = coldStart.begin('space_files_sync');
553
+ try {
554
+ await this.syncSpaceFilesForTurn(session, workspaceDir);
555
+ }
556
+ finally {
557
+ endSpaceFiles();
558
+ }
534
559
  }
535
560
  if (workspaceDir && (hasLeasedAttachments || hasDroppedAttachments)) {
536
561
  const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(session.input.attachments, workspaceDir, {
@@ -585,10 +610,7 @@ class SessionRunner {
585
610
  * stop a turn whose model is already known, so we fall back to exactly
586
611
  * the previous behaviour.
587
612
  */
588
- const fetched = await (0, model_gateway_1.fetchGatewayModels)({
589
- baseUrl: session.agent.model.gatewayUrl,
590
- apiKey: lease.modelToken
591
- });
613
+ const fetched = await gatewayModels;
592
614
  const named = session.agent.model.model
593
615
  ? [
594
616
  {
@@ -622,7 +644,13 @@ class SessionRunner {
622
644
  this.deps.modelGateway?.set(null);
623
645
  }
624
646
  adapter = this.deps.getAdapter(session.agent.backend);
625
- await adapter.initialize();
647
+ const endAdapter = coldStart.begin('adapter_initialize');
648
+ try {
649
+ await adapter.initialize();
650
+ }
651
+ finally {
652
+ endAdapter();
653
+ }
626
654
  secretToken = this.setUpSecrets(session);
627
655
  /*
628
656
  * THE KNOWLEDGE TOOLS FOR THIS TURN (P9). Bound to this lease and this
@@ -638,6 +666,7 @@ class SessionRunner {
638
666
  knowledgeUrl = await this.deps.knowledge.host.endpointFor(session.sessionId);
639
667
  }
640
668
  const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl);
669
+ config.coldStartLog = this.deps.coldStartLog;
641
670
  /*
642
671
  * REMEMBER THE CONVERSATION. This block is the fix for the defect that
643
672
  * made this a chat product that could not hold a conversation: the
@@ -749,7 +778,13 @@ class SessionRunner {
749
778
  }
750
779
  }
751
780
  if (!backendSessionId) {
752
- backendSessionId = await adapter.createSession(config);
781
+ const endBackend = coldStart.begin('backend_process_start');
782
+ try {
783
+ backendSessionId = await adapter.createSession(config);
784
+ }
785
+ finally {
786
+ endBackend();
787
+ }
753
788
  this.deps.telemetry.record('provider.session.started', {
754
789
  backend: session.agent.backend,
755
790
  runtime: session.runtime
@@ -789,10 +824,18 @@ class SessionRunner {
789
824
  : promptText;
790
825
  replayed = !resumed && memory.messages.length > 0;
791
826
  priorPartIdsForLog = priorPartIds;
792
- await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
827
+ const endPrompt = coldStart.begin('model_prompt_send');
828
+ try {
829
+ await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
830
+ }
831
+ finally {
832
+ endPrompt();
833
+ }
793
834
  this.deps.telemetry.record('provider.turn.sent', {
794
835
  backend: session.agent.backend
795
836
  });
837
+ const endFirstOutput = coldStart.begin('first_agent_output');
838
+ const endFirstIngest = coldStart.begin('first_transcript_ingest');
796
839
  /*
797
840
  * WIRE 3 NEEDS A WAY IN. `run` is one long await, and the user's answer
798
841
  * arrives on a control-plane command that the harness handles on a
@@ -817,7 +860,7 @@ class SessionRunner {
817
860
  resumed ? priorPartIds : new Set(),
818
861
  // THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
819
862
  // backend's own echo of it — see `drain`.
820
- promptWithHostNote);
863
+ promptWithHostNote, () => endFirstOutput(), () => endFirstIngest());
821
864
  partsProduced = drained.parts;
822
865
  if (signal.aborted) {
823
866
  stopReason = 'aborted';
@@ -1031,6 +1074,7 @@ class SessionRunner {
1031
1074
  * the drain. That has not been true since the error path was added.
1032
1075
  */
1033
1076
  await this.flush(session.sessionId, this.pending.splice(0), turn, true, modelMessages);
1077
+ coldStart.summary();
1034
1078
  this.deps.telemetry.record('provider.turn.memory', {
1035
1079
  resumed,
1036
1080
  replayed,
@@ -1101,7 +1145,7 @@ class SessionRunner {
1101
1145
  */
1102
1146
  async drain(adapter, backendSessionId, config, session, signal, alreadySeenPartIds = new Set(),
1103
1147
  /** The exact text `sendPrompt` was given this turn, for echo filtering. */
1104
- sentPromptText = null) {
1148
+ sentPromptText = null, onFirstOutput, onFirstIngest) {
1105
1149
  let providerError;
1106
1150
  let timedOut = false;
1107
1151
  let idleTimedOut = false;
@@ -1110,6 +1154,7 @@ class SessionRunner {
1110
1154
  const seenPartIds = new Set(alreadySeenPartIds);
1111
1155
  const partContentLengths = new Map();
1112
1156
  let produced = 0;
1157
+ let firstOutputSeen = false;
1113
1158
  let idleSeen = 0;
1114
1159
  let wake = null;
1115
1160
  adapter.onDataAvailable = () => {
@@ -1236,8 +1281,14 @@ class SessionRunner {
1236
1281
  */
1237
1282
  produced += await this.emitPendingApproval(adapter, backendSessionId, session);
1238
1283
  if (freshParts.length > 0) {
1284
+ if (!firstOutputSeen) {
1285
+ firstOutputSeen = true;
1286
+ onFirstOutput?.();
1287
+ }
1239
1288
  produced += this.record(session.sessionId, freshParts);
1240
1289
  await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
1290
+ if (firstOutputSeen)
1291
+ onFirstIngest?.();
1241
1292
  // Real output resets the quiet counter. A backend is idle BETWEEN
1242
1293
  // tool calls as well as at the end of a turn, so ending on the first
1243
1294
  // idle would truncate almost every transcript at the first tool.
@@ -2142,6 +2193,7 @@ class SessionRunner {
2142
2193
  // would have made them no longer "extracted unchanged", which is worth
2143
2194
  // more than the tidier name.
2144
2195
  taskId: session.sessionId,
2196
+ turnId: session.turnId,
2145
2197
  workspaceDir,
2146
2198
  devServerPolicy: session.agent.devServerPolicy,
2147
2199
  model: qualifiedModelId(session.agent.model),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.4-rc.2",
3
+ "version": "0.2.5-rc.1",
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",