larkin 0.3.3 → 0.4.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.
Files changed (32) hide show
  1. package/README.md +72 -33
  2. package/assets/readme/larkin-hero.svg +111 -0
  3. package/dist/agent/agent-state-store.cjs +321 -22
  4. package/dist/agent/agent-state-store.mjs +320 -22
  5. package/dist/agent/agent-transport.cjs +6 -4
  6. package/dist/agent/agent-transport.mjs +6 -4
  7. package/dist/agent/host-reminder-orchestrator.cjs +9 -3
  8. package/dist/agent/host-reminder-orchestrator.mjs +9 -3
  9. package/dist/agent/inbox-projection.cjs +114 -23
  10. package/dist/agent/inbox-projection.mjs +112 -23
  11. package/dist/app/cli.mjs +3 -1
  12. package/dist/app/local-control.mjs +108 -2
  13. package/dist/app/runtime-process.mjs +4 -0
  14. package/dist/app/session-cli.mjs +55 -20
  15. package/dist/feishu/document-comment.cjs +2 -1
  16. package/dist/feishu/document-comment.mjs +2 -1
  17. package/dist/feishu/host-business-state.cjs +7 -2
  18. package/dist/feishu/host-business-state.mjs +7 -2
  19. package/dist/feishu/host-shell.cjs +138 -32
  20. package/dist/feishu/host-shell.mjs +138 -32
  21. package/dist/runtime/pi-compaction-recovery.cjs +623 -0
  22. package/dist/runtime/pi-compaction-recovery.mjs +602 -0
  23. package/dist/runtime/pi-provider-config.cjs +2 -2
  24. package/dist/runtime/pi-provider-config.mjs +2 -2
  25. package/dist/runtime/pi-rpc-client.cjs +39 -22
  26. package/dist/runtime/pi-rpc-client.mjs +39 -22
  27. package/dist/runtime/provider-error-classifier.cjs +39 -0
  28. package/dist/runtime/provider-error-classifier.mjs +34 -0
  29. package/dist/runtime/runtime-adapters.mjs +82 -19
  30. package/dist/runtime/runtime-host.cjs +663 -12
  31. package/dist/runtime/runtime-host.mjs +661 -11
  32. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PiRpcClient = void 0;
4
- /** A strict, bounded LF-delimited client for Pi's public RPC protocol. */
4
+ /** A strict LF-delimited client for Pi's public RPC protocol. */
5
5
  class PiRpcClient {
6
6
  process;
7
7
  pending = new Map();
@@ -11,9 +11,10 @@ class PiRpcClient {
11
11
  inputTimeoutMs;
12
12
  inputProgressTimeoutMs;
13
13
  inputMaxTimeoutMs;
14
- maxFrameBytes;
15
14
  maxStderrBytes;
16
15
  shutdownGraceMs;
16
+ compactTimeoutMs;
17
+ expiredRequestIds = new Set();
17
18
  stdoutBuffer = Buffer.alloc(0);
18
19
  stderrBuffer = Buffer.alloc(0);
19
20
  exited = false;
@@ -29,9 +30,11 @@ class PiRpcClient {
29
30
  if (this.requestTimeoutMs <= 0 || this.inputTimeoutMs <= 0 || this.inputProgressTimeoutMs <= 0
30
31
  || this.inputMaxTimeoutMs < this.inputTimeoutMs)
31
32
  throw new Error("Pi RPC timeout options are invalid");
32
- this.maxFrameBytes = options.maxFrameBytes ?? 1024 * 1024;
33
33
  this.maxStderrBytes = options.maxStderrBytes ?? 64 * 1024;
34
34
  this.shutdownGraceMs = options.shutdownGraceMs ?? 2_000;
35
+ this.compactTimeoutMs = options.compactTimeoutMs ?? 120_000;
36
+ if (this.compactTimeoutMs <= 0)
37
+ throw new Error("Pi compact timeout must be positive");
35
38
  process.stdout?.on("data", (chunk) => this.consume(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
36
39
  process.stdout?.on("end", () => {
37
40
  if (this.stdoutBuffer.length)
@@ -61,19 +64,26 @@ class PiRpcClient {
61
64
  return () => this.failureListeners.delete(listener);
62
65
  }
63
66
  request(command, fields = {}) {
67
+ const input = isInputCommand(command);
68
+ return this.requestInternal(command, fields, input ? this.inputTimeoutMs : this.requestTimeoutMs, input ? Date.now() + this.inputMaxTimeoutMs : undefined, true);
69
+ }
70
+ /** Send the documented compact RPC with a Larkin-owned absolute deadline. */
71
+ requestCompact(customInstructions) {
72
+ const startedAt = Date.now();
73
+ return this.requestInternal("compact", customInstructions === undefined ? {} : { customInstructions }, this.compactTimeoutMs, startedAt + this.compactTimeoutMs, false);
74
+ }
75
+ requestInternal(command, fields, timeoutMs, absoluteDeadlineAt, failClientOnTimeout) {
64
76
  if (this.failed)
65
77
  return Promise.reject(this.failed);
66
78
  const id = `larkin-${++this.nextId}`;
67
79
  return new Promise((resolve, reject) => {
68
80
  const startedAt = Date.now();
69
- const input = isInputCommand(command);
70
81
  const pending = {
71
- command, startedAt,
72
- ...(input ? { absoluteDeadlineAt: startedAt + this.inputMaxTimeoutMs } : {}),
73
- resolve: resolve, reject,
82
+ command, startedAt, ...(absoluteDeadlineAt !== undefined ? { absoluteDeadlineAt } : {}),
83
+ failClientOnTimeout, resolve: resolve, reject,
74
84
  };
75
85
  this.pending.set(id, pending);
76
- this.armTimeout(id, pending, input ? this.inputTimeoutMs : this.requestTimeoutMs);
86
+ this.armTimeout(id, pending, timeoutMs);
77
87
  try {
78
88
  if (!this.process.stdin || this.process.stdin.destroyed)
79
89
  throw new Error("stdin is unavailable");
@@ -103,15 +113,8 @@ class PiRpcClient {
103
113
  this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
104
114
  while (true) {
105
115
  const newline = this.stdoutBuffer.indexOf(0x0a);
106
- if (newline < 0) {
107
- if (this.stdoutBuffer.length > this.maxFrameBytes)
108
- this.protocolFailure(`frame exceeds ${this.maxFrameBytes} bytes`);
109
- return;
110
- }
111
- if (newline > this.maxFrameBytes) {
112
- this.protocolFailure(`frame exceeds ${this.maxFrameBytes} bytes`);
116
+ if (newline < 0)
113
117
  return;
114
- }
115
118
  let frame = this.stdoutBuffer.subarray(0, newline);
116
119
  this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1);
117
120
  if (frame.at(-1) === 0x0d)
@@ -140,6 +143,8 @@ class PiRpcClient {
140
143
  if (message.type === "response" && typeof message.id === "string") {
141
144
  const pending = this.pending.get(message.id);
142
145
  if (!pending) {
146
+ if (this.expiredRequestIds.delete(message.id))
147
+ return;
143
148
  this.protocolFailure(`unexpected response id ${message.id}`);
144
149
  return;
145
150
  }
@@ -181,12 +186,24 @@ class PiRpcClient {
181
186
  return;
182
187
  const elapsed = Date.now() - pending.startedAt;
183
188
  const absolute = pending.absoluteDeadlineAt !== undefined && Date.now() >= pending.absoluteDeadlineAt;
184
- const error = isInputCommand(pending.command)
185
- ? new Error(absolute
186
- ? `Pi RPC ${pending.command} preflight timed out at absolute ${this.inputMaxTimeoutMs}ms limit`
187
- : `Pi RPC ${pending.command} preflight timed out after ${elapsed}ms without progress`)
188
- : new Error(`Pi RPC ${pending.command} timed out after ${this.requestTimeoutMs}ms`);
189
- this.fail(error, true);
189
+ this.pending.delete(id);
190
+ this.expiredRequestIds.add(id);
191
+ while (this.expiredRequestIds.size > 256) {
192
+ const oldest = this.expiredRequestIds.values().next().value;
193
+ if (!oldest)
194
+ break;
195
+ this.expiredRequestIds.delete(oldest);
196
+ }
197
+ const error = pending.command === "compact"
198
+ ? new Error(`Pi RPC compact timed out at absolute compact deadline ${elapsed}ms`)
199
+ : isInputCommand(pending.command)
200
+ ? new Error(absolute
201
+ ? `Pi RPC ${pending.command} preflight timed out at absolute ${this.inputMaxTimeoutMs}ms limit`
202
+ : `Pi RPC ${pending.command} preflight timed out after ${elapsed}ms without progress`)
203
+ : new Error(`Pi RPC ${pending.command} timed out after ${this.requestTimeoutMs}ms`);
204
+ pending.reject(error);
205
+ if (pending.failClientOnTimeout !== false)
206
+ this.fail(error, true);
190
207
  }, delay);
191
208
  pending.timer.unref?.();
192
209
  }
@@ -1,4 +1,4 @@
1
- /** A strict, bounded LF-delimited client for Pi's public RPC protocol. */
1
+ /** A strict LF-delimited client for Pi's public RPC protocol. */
2
2
  export class PiRpcClient {
3
3
  process;
4
4
  pending = new Map();
@@ -8,9 +8,10 @@ export class PiRpcClient {
8
8
  inputTimeoutMs;
9
9
  inputProgressTimeoutMs;
10
10
  inputMaxTimeoutMs;
11
- maxFrameBytes;
12
11
  maxStderrBytes;
13
12
  shutdownGraceMs;
13
+ compactTimeoutMs;
14
+ expiredRequestIds = new Set();
14
15
  stdoutBuffer = Buffer.alloc(0);
15
16
  stderrBuffer = Buffer.alloc(0);
16
17
  exited = false;
@@ -26,9 +27,11 @@ export class PiRpcClient {
26
27
  if (this.requestTimeoutMs <= 0 || this.inputTimeoutMs <= 0 || this.inputProgressTimeoutMs <= 0
27
28
  || this.inputMaxTimeoutMs < this.inputTimeoutMs)
28
29
  throw new Error("Pi RPC timeout options are invalid");
29
- this.maxFrameBytes = options.maxFrameBytes ?? 1024 * 1024;
30
30
  this.maxStderrBytes = options.maxStderrBytes ?? 64 * 1024;
31
31
  this.shutdownGraceMs = options.shutdownGraceMs ?? 2_000;
32
+ this.compactTimeoutMs = options.compactTimeoutMs ?? 120_000;
33
+ if (this.compactTimeoutMs <= 0)
34
+ throw new Error("Pi compact timeout must be positive");
32
35
  process.stdout?.on("data", (chunk) => this.consume(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
33
36
  process.stdout?.on("end", () => {
34
37
  if (this.stdoutBuffer.length)
@@ -58,19 +61,26 @@ export class PiRpcClient {
58
61
  return () => this.failureListeners.delete(listener);
59
62
  }
60
63
  request(command, fields = {}) {
64
+ const input = isInputCommand(command);
65
+ return this.requestInternal(command, fields, input ? this.inputTimeoutMs : this.requestTimeoutMs, input ? Date.now() + this.inputMaxTimeoutMs : undefined, true);
66
+ }
67
+ /** Send the documented compact RPC with a Larkin-owned absolute deadline. */
68
+ requestCompact(customInstructions) {
69
+ const startedAt = Date.now();
70
+ return this.requestInternal("compact", customInstructions === undefined ? {} : { customInstructions }, this.compactTimeoutMs, startedAt + this.compactTimeoutMs, false);
71
+ }
72
+ requestInternal(command, fields, timeoutMs, absoluteDeadlineAt, failClientOnTimeout) {
61
73
  if (this.failed)
62
74
  return Promise.reject(this.failed);
63
75
  const id = `larkin-${++this.nextId}`;
64
76
  return new Promise((resolve, reject) => {
65
77
  const startedAt = Date.now();
66
- const input = isInputCommand(command);
67
78
  const pending = {
68
- command, startedAt,
69
- ...(input ? { absoluteDeadlineAt: startedAt + this.inputMaxTimeoutMs } : {}),
70
- resolve: resolve, reject,
79
+ command, startedAt, ...(absoluteDeadlineAt !== undefined ? { absoluteDeadlineAt } : {}),
80
+ failClientOnTimeout, resolve: resolve, reject,
71
81
  };
72
82
  this.pending.set(id, pending);
73
- this.armTimeout(id, pending, input ? this.inputTimeoutMs : this.requestTimeoutMs);
83
+ this.armTimeout(id, pending, timeoutMs);
74
84
  try {
75
85
  if (!this.process.stdin || this.process.stdin.destroyed)
76
86
  throw new Error("stdin is unavailable");
@@ -100,15 +110,8 @@ export class PiRpcClient {
100
110
  this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
101
111
  while (true) {
102
112
  const newline = this.stdoutBuffer.indexOf(0x0a);
103
- if (newline < 0) {
104
- if (this.stdoutBuffer.length > this.maxFrameBytes)
105
- this.protocolFailure(`frame exceeds ${this.maxFrameBytes} bytes`);
106
- return;
107
- }
108
- if (newline > this.maxFrameBytes) {
109
- this.protocolFailure(`frame exceeds ${this.maxFrameBytes} bytes`);
113
+ if (newline < 0)
110
114
  return;
111
- }
112
115
  let frame = this.stdoutBuffer.subarray(0, newline);
113
116
  this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1);
114
117
  if (frame.at(-1) === 0x0d)
@@ -137,6 +140,8 @@ export class PiRpcClient {
137
140
  if (message.type === "response" && typeof message.id === "string") {
138
141
  const pending = this.pending.get(message.id);
139
142
  if (!pending) {
143
+ if (this.expiredRequestIds.delete(message.id))
144
+ return;
140
145
  this.protocolFailure(`unexpected response id ${message.id}`);
141
146
  return;
142
147
  }
@@ -178,12 +183,24 @@ export class PiRpcClient {
178
183
  return;
179
184
  const elapsed = Date.now() - pending.startedAt;
180
185
  const absolute = pending.absoluteDeadlineAt !== undefined && Date.now() >= pending.absoluteDeadlineAt;
181
- const error = isInputCommand(pending.command)
182
- ? new Error(absolute
183
- ? `Pi RPC ${pending.command} preflight timed out at absolute ${this.inputMaxTimeoutMs}ms limit`
184
- : `Pi RPC ${pending.command} preflight timed out after ${elapsed}ms without progress`)
185
- : new Error(`Pi RPC ${pending.command} timed out after ${this.requestTimeoutMs}ms`);
186
- this.fail(error, true);
186
+ this.pending.delete(id);
187
+ this.expiredRequestIds.add(id);
188
+ while (this.expiredRequestIds.size > 256) {
189
+ const oldest = this.expiredRequestIds.values().next().value;
190
+ if (!oldest)
191
+ break;
192
+ this.expiredRequestIds.delete(oldest);
193
+ }
194
+ const error = pending.command === "compact"
195
+ ? new Error(`Pi RPC compact timed out at absolute compact deadline ${elapsed}ms`)
196
+ : isInputCommand(pending.command)
197
+ ? new Error(absolute
198
+ ? `Pi RPC ${pending.command} preflight timed out at absolute ${this.inputMaxTimeoutMs}ms limit`
199
+ : `Pi RPC ${pending.command} preflight timed out after ${elapsed}ms without progress`)
200
+ : new Error(`Pi RPC ${pending.command} timed out after ${this.requestTimeoutMs}ms`);
201
+ pending.reject(error);
202
+ if (pending.failClientOnTimeout !== false)
203
+ this.fail(error, true);
187
204
  }, delay);
188
205
  pending.timer.unref?.();
189
206
  }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON = exports.CANONICAL_CONTEXT_WINDOW_MESSAGE = exports.CODEX_ERROR_PREFIX = void 0;
4
+ exports.buildStrictProviderErrorInput = buildStrictProviderErrorInput;
5
+ exports.classifyStrictProviderError = classifyStrictProviderError;
6
+ exports.CODEX_ERROR_PREFIX = "Codex error: ";
7
+ exports.CANONICAL_CONTEXT_WINDOW_MESSAGE = "Your input exceeds the context window of this model. Please adjust your input and try again.";
8
+ exports.INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON = "provider rejected the input because the context window was exceeded";
9
+ /** Build the one structured provider-error shape shared by ingestion and recovery. */
10
+ function buildStrictProviderErrorInput(source) {
11
+ const upstream = source.upstream && typeof source.upstream === "object" && !Array.isArray(source.upstream)
12
+ ? source.upstream : {};
13
+ return {
14
+ message: source.reason !== undefined ? source.reason : source.message,
15
+ code: source.code ?? upstream.code,
16
+ type: source.type ?? upstream.type,
17
+ errorCategory: source.errorCategory,
18
+ };
19
+ }
20
+ /**
21
+ * The recovery path intentionally recognizes only the complete canonical
22
+ * provider message, or the exact internal projection when its category is
23
+ * persisted alongside it. The Codex wrapper is the only normalization
24
+ * permitted for the provider message.
25
+ */
26
+ function classifyStrictProviderError(error) {
27
+ if (typeof error.message !== "string")
28
+ return undefined;
29
+ if (error.errorCategory !== undefined && error.errorCategory !== "context_window")
30
+ return undefined;
31
+ const message = error.message.startsWith(exports.CODEX_ERROR_PREFIX)
32
+ ? error.message.slice(exports.CODEX_ERROR_PREFIX.length) : error.message;
33
+ if (message === exports.CANONICAL_CONTEXT_WINDOW_MESSAGE)
34
+ return "context_window";
35
+ if (error.errorCategory === "context_window" && error.message === exports.INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON) {
36
+ return "context_window";
37
+ }
38
+ return undefined;
39
+ }
@@ -0,0 +1,34 @@
1
+ export const CODEX_ERROR_PREFIX = "Codex error: ";
2
+ export const CANONICAL_CONTEXT_WINDOW_MESSAGE = "Your input exceeds the context window of this model. Please adjust your input and try again.";
3
+ export const INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON = "provider rejected the input because the context window was exceeded";
4
+ /** Build the one structured provider-error shape shared by ingestion and recovery. */
5
+ export function buildStrictProviderErrorInput(source) {
6
+ const upstream = source.upstream && typeof source.upstream === "object" && !Array.isArray(source.upstream)
7
+ ? source.upstream : {};
8
+ return {
9
+ message: source.reason !== undefined ? source.reason : source.message,
10
+ code: source.code ?? upstream.code,
11
+ type: source.type ?? upstream.type,
12
+ errorCategory: source.errorCategory,
13
+ };
14
+ }
15
+ /**
16
+ * The recovery path intentionally recognizes only the complete canonical
17
+ * provider message, or the exact internal projection when its category is
18
+ * persisted alongside it. The Codex wrapper is the only normalization
19
+ * permitted for the provider message.
20
+ */
21
+ export function classifyStrictProviderError(error) {
22
+ if (typeof error.message !== "string")
23
+ return undefined;
24
+ if (error.errorCategory !== undefined && error.errorCategory !== "context_window")
25
+ return undefined;
26
+ const message = error.message.startsWith(CODEX_ERROR_PREFIX)
27
+ ? error.message.slice(CODEX_ERROR_PREFIX.length) : error.message;
28
+ if (message === CANONICAL_CONTEXT_WINDOW_MESSAGE)
29
+ return "context_window";
30
+ if (error.errorCategory === "context_window" && error.message === INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON) {
31
+ return "context_window";
32
+ }
33
+ return undefined;
34
+ }
@@ -1,4 +1,4 @@
1
- import { spawn as nodeSpawn } from "node:child_process";
1
+ import { spawn as nodeSpawn, spawnSync } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
@@ -10,6 +10,8 @@ import { piAgentDirectory } from "./pi-provider-config.mjs";
10
10
  import { resolvePiSubagentExtensionArg } from "./pi-subagent-injection.mjs";
11
11
  import { resolvePiBashTimeoutExtensionArg } from "./pi-bash-timeout-injection.mjs";
12
12
  import { classifyRuntimePrerequisite, probeNativeRuntimeReadiness, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
13
+ import { buildStrictProviderErrorInput, classifyStrictProviderError, INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON, } from "./provider-error-classifier.mjs";
14
+ import { assertEffectivePiCompactionSettings, assertNoProjectPiCompactionOverride, prepareOwnedPiDirectory, readOwnedPiSettings, verifyPiCapabilities, } from "./pi-compaction-recovery.mjs";
13
15
  class EventSession {
14
16
  listeners = new Set();
15
17
  subscribe(listener) {
@@ -447,6 +449,7 @@ class PiSession extends EventSession {
447
449
  observedSubmitEpochs = new Set();
448
450
  observedAcceptedEpochs = new Set();
449
451
  observedCompletedEpochs = new Set();
452
+ observedAgentEndEpochs = new Set();
450
453
  firstOutputObserved = false;
451
454
  toolCallOpen = false;
452
455
  constructor(sdk, distribution) {
@@ -470,6 +473,11 @@ class PiSession extends EventSession {
470
473
  async prompt(input) { return this.enqueue(input, () => this.sdk.prompt(input.text)); }
471
474
  async busyInput(input) { return this.enqueue(input, () => this.sdk.steer(input.text)); }
472
475
  async cancel(_reason) { await this.sdk.abort(); }
476
+ async compact(customInstructions) {
477
+ if (!this.sdk.compact)
478
+ throw new Error("Pi compact RPC is unavailable");
479
+ return this.sdk.compact(customInstructions);
480
+ }
473
481
  async close(_reason) { this.unsubscribe?.(); await this.sdk.dispose?.(); }
474
482
  async enqueue(input, operation) {
475
483
  if (this.ownedInputIds.size === 0)
@@ -481,14 +489,14 @@ class PiSession extends EventSession {
481
489
  const ownsRpcObservation = !this.observedSubmitEpochs.has(epoch);
482
490
  if (ownsRpcObservation) {
483
491
  this.observedSubmitEpochs.add(epoch);
484
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "rpc_submit" });
492
+ this.emitObservation("rpc_submit");
485
493
  }
486
494
  try {
487
495
  await operation();
488
496
  this.awaitingAcknowledgement.delete(input.inputId);
489
497
  if (ownsRpcObservation && !this.observedAcceptedEpochs.has(epoch)) {
490
498
  this.observedAcceptedEpochs.add(epoch);
491
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "rpc_accepted" });
499
+ this.emitObservation("rpc_accepted");
492
500
  }
493
501
  return { status: "accepted", inputId: input.inputId };
494
502
  }
@@ -524,19 +532,23 @@ class PiSession extends EventSession {
524
532
  this.observedSubmitEpochs.clear();
525
533
  this.observedAcceptedEpochs.clear();
526
534
  this.observedCompletedEpochs.clear();
535
+ this.observedAgentEndEpochs.clear();
527
536
  this.activeEpoch = null;
528
537
  this.settleArmedEpoch = null;
529
538
  this.emit({ type: "error", message });
530
539
  }
531
- else if (event?.type === "compaction_start" && this.awaitingAcknowledgement.size > 0) {
532
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "compaction_start" });
540
+ else if (event?.type === "compaction_start") {
541
+ this.emitObservation("compaction_start", event.reason === "manual" || event.reason === "threshold" || event.reason === "overflow" ? { reason: event.reason } : {});
533
542
  }
534
- else if (event?.type === "compaction_end" && this.awaitingAcknowledgement.size > 0) {
535
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "compaction_end" });
543
+ else if (event?.type === "compaction_end") {
544
+ this.emitObservation("compaction_end", {
545
+ ...(event.reason === "manual" || event.reason === "threshold" || event.reason === "overflow" ? { reason: event.reason } : {}),
546
+ willRetry: event.willRetry === true, success: event.aborted !== true && event.result !== undefined,
547
+ });
536
548
  }
537
549
  else if ((event?.type === "auto_retry_start" || event?.type === "auto_retry_end"
538
550
  || String(event?.type || "").startsWith("summarization_retry_")) && this.awaitingAcknowledgement.size > 0) {
539
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "retry_progress" });
551
+ this.emitObservation("retry_progress");
540
552
  }
541
553
  else if (event?.type === "turn_start") {
542
554
  const epoch = this.oldestOwnedEpoch();
@@ -546,7 +558,7 @@ class PiSession extends EventSession {
546
558
  this.settleArmedEpoch = null;
547
559
  this.firstOutputObserved = false;
548
560
  this.toolCallOpen = false;
549
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "turn_start" });
561
+ this.emitObservation("turn_start");
550
562
  this.emit({ type: "turn-start", ...(Number.isInteger(event.turnIndex) ? { turnId: `pi-${event.turnIndex}` } : {}) });
551
563
  }
552
564
  else if (event?.type === "agent_end") {
@@ -557,9 +569,17 @@ class PiSession extends EventSession {
557
569
  ? piAssistantProviderError(assistant)
558
570
  : null;
559
571
  this.settleArmedEpoch = this.activeEpoch;
572
+ if (this.activeEpoch !== null && !this.observedAgentEndEpochs.has(this.activeEpoch)) {
573
+ this.observedAgentEndEpochs.add(this.activeEpoch);
574
+ this.emitObservation("agent_end", { willRetry: event.willRetry === true });
575
+ }
576
+ if (event.willRetry === true && this.finalAssistantError?.upstream.message === "Your input exceeds the context window of this model. Please adjust your input and try again.") {
577
+ this.emit({ type: "input-error", inputId: this.oldestOwnedInput(), retryable: true, willRetry: true,
578
+ message: "Pi owns the context-overflow retry", errorCategory: "context_window" });
579
+ }
560
580
  if (event.willRetry !== true && this.activeEpoch !== null && !this.observedCompletedEpochs.has(this.activeEpoch)) {
561
581
  this.observedCompletedEpochs.add(this.activeEpoch);
562
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "completed" });
582
+ this.emitObservation("completed");
563
583
  }
564
584
  }
565
585
  else if (event?.type === "agent_settled") {
@@ -576,7 +596,7 @@ class PiSession extends EventSession {
576
596
  this.toolCallOpen = false;
577
597
  this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "tool_result" });
578
598
  }
579
- this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "settled" });
599
+ this.emitObservation("settled");
580
600
  const owned = [...this.ownedInputIds].filter((inputId) => this.inputEpochs.get(inputId) === epoch);
581
601
  if (error) {
582
602
  const classified = classifyPiProviderError(error);
@@ -605,6 +625,7 @@ class PiSession extends EventSession {
605
625
  this.observedSubmitEpochs.delete(epoch);
606
626
  this.observedAcceptedEpochs.delete(epoch);
607
627
  this.observedCompletedEpochs.delete(epoch);
628
+ this.observedAgentEndEpochs.delete(epoch);
608
629
  }
609
630
  else if (event?.type === "tool_execution_start") {
610
631
  if (!this.firstOutputObserved) {
@@ -632,6 +653,20 @@ class PiSession extends EventSession {
632
653
  this.emit({ type: "activity", activity: kind, text: String(event.assistantMessageEvent.delta) });
633
654
  }
634
655
  }
656
+ emitObservation(phase, fields = {}) {
657
+ const inputId = this.oldestOwnedInput();
658
+ const observation = { type: "runtime-observation", runtime: "pi",
659
+ distribution: this.distribution, phase, ...fields };
660
+ // Correlation is host-internal metadata, not telemetry payload.
661
+ if (this.sessionId)
662
+ Object.defineProperty(observation, "sessionId", { value: this.sessionId, enumerable: false });
663
+ if (inputId)
664
+ Object.defineProperty(observation, "inputId", { value: inputId, enumerable: false });
665
+ this.emit(observation);
666
+ }
667
+ oldestOwnedInput() {
668
+ return this.ownedInputIds.values().next().value;
669
+ }
635
670
  oldestOwnedEpoch() {
636
671
  for (const inputId of this.ownedInputIds) {
637
672
  const epoch = this.inputEpochs.get(inputId);
@@ -675,6 +710,13 @@ export function classifyPiProviderError(error) {
675
710
  const errorType = "type" in error ? error.type : undefined;
676
711
  const reason = safeProviderText(upstream.message, "Pi provider request failed");
677
712
  const signal = new Set([String(upstream.code ?? "").trim().toLowerCase(), String(errorType ?? "").trim().toLowerCase()].filter(Boolean));
713
+ if (classifyStrictProviderError(buildStrictProviderErrorInput({
714
+ message: reason, type: errorType, upstream,
715
+ })) === "context_window")
716
+ return {
717
+ category: "context_window", reason: INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON,
718
+ nextAction: "Start a fresh Runtime session, then replay the retained Inbox deliveries.",
719
+ };
678
720
  if (upstream.status === 402 || [...signal].some((value) => ["payment_required", "billing_hard_limit_reached", "insufficient_balance"].includes(value)))
679
721
  return {
680
722
  category: "billing", reason, nextAction: "Update the provider billing method or balance, then retry.",
@@ -787,6 +829,8 @@ class PiRpcBackend {
787
829
  prompt(text) { return this.client.request("prompt", { message: text }); }
788
830
  steer(text) { return this.client.request("steer", { message: text }); }
789
831
  abort() { return this.client.request("abort"); }
832
+ compact(customInstructions) { return this.client.requestCompact(customInstructions); }
833
+ getState() { return this.client.request("get_state"); }
790
834
  dispose() { return this.client.close(); }
791
835
  subscribe(listener) {
792
836
  const offEvent = this.client.subscribe(listener);
@@ -809,8 +853,14 @@ export function resolvePiProcessExtensionArgs(input, resolvers = {}) {
809
853
  args.push("-e", bashTimeout);
810
854
  return args;
811
855
  }
812
- async function createPiRpcBackend(input, dependencies, spawn) {
856
+ async function createPiRpcBackend(input, dependencies, spawn, productionSpawn = true) {
813
857
  const stateRoot = input.stateDir ?? path.join(input.workspaceDir, ".larkin");
858
+ const mergedEnv = { ...globalThis.process.env, ...dependencies.env, ...input.env, NO_COLOR: "1" };
859
+ assertNoProjectPiCompactionOverride(input.workspaceDir);
860
+ const ownedPiDirectory = mergedEnv.LARKIN_CONFIG_DIR
861
+ ? piAgentDirectory(mergedEnv.LARKIN_CONFIG_DIR, input.agentId)
862
+ : path.join(stateRoot, "pi-agent");
863
+ prepareOwnedPiDirectory(ownedPiDirectory);
814
864
  const runtimeDir = path.join(stateRoot, "runtime");
815
865
  fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
816
866
  const promptFile = path.join(runtimeDir, "pi-standing-prompt.md");
@@ -824,17 +874,16 @@ async function createPiRpcBackend(input, dependencies, spawn) {
824
874
  ...(session.sessionFile ? ["--session", session.sessionFile] : []),
825
875
  ...(requestedModel ? ["--model", requestedModel] : []),
826
876
  ...(requestedEffort ? ["--thinking", requestedEffort] : [])];
827
- const mergedEnv = { ...globalThis.process.env, ...dependencies.env, ...input.env, NO_COLOR: "1" };
828
877
  const builtin = mergedEnv.LARKIN_PI_DISTRIBUTION === "builtin";
829
878
  const builtinSpec = builtin ? internalCommandSpec("pi-rpc", [], mergedEnv) : null;
830
879
  const command = builtinSpec?.command ?? dependencies.piCommand ?? dependencies.env?.LARKIN_PI_COMMAND ?? process.env.LARKIN_PI_COMMAND ?? "pi";
831
880
  const commandArgs = [...(builtinSpec?.args ?? []), ...args];
832
- if (builtin) {
833
- if (!mergedEnv.LARKIN_CONFIG_DIR)
834
- throw new Error("内置 Pi 缺少 LARKIN_CONFIG_DIR");
835
- mergedEnv.PI_CODING_AGENT_DIR = piAgentDirectory(mergedEnv.LARKIN_CONFIG_DIR, input.agentId);
881
+ const reportedVersion = productionSpawn && !builtin
882
+ ? String(spawnSync(command, ["--version"], { cwd: input.workspaceDir, env: mergedEnv, encoding: "utf8", timeout: 5_000 }).stdout || "").trim()
883
+ : "0.83.0";
884
+ mergedEnv.PI_CODING_AGENT_DIR = ownedPiDirectory;
885
+ if (builtin)
836
886
  mergedEnv.PI_TELEMETRY = "0";
837
- }
838
887
  commandArgs.push(...(dependencies.resolvePiProcessExtensionArgs ?? resolvePiProcessExtensionArgs)({
839
888
  distribution: builtin ? "builtin" : "external",
840
889
  piCommand: command,
@@ -855,6 +904,20 @@ async function createPiRpcBackend(input, dependencies, spawn) {
855
904
  if (!available?.models?.length)
856
905
  throw new Error("Pi has no authenticated available models. Run the official `pi` login flow or configure provider credentials; Larkin will not create a fallback session.");
857
906
  const effectiveModel = state.model?.provider && state.model.id ? `${state.model.provider}/${state.model.id}` : null;
907
+ if (productionSpawn) {
908
+ const ownedSettings = readOwnedPiSettings(ownedPiDirectory);
909
+ assertEffectivePiCompactionSettings({ contextWindow: state.model?.contextWindow, compaction: {
910
+ enabled: state.autoCompactionEnabled,
911
+ reserveTokens: ownedSettings.compaction?.reserveTokens,
912
+ keepRecentTokens: ownedSettings.compaction?.keepRecentTokens,
913
+ } });
914
+ const handshake = state.compactionCapabilities;
915
+ verifyPiCapabilities({ distribution: builtin ? "builtin" : "external", version: reportedVersion,
916
+ contextWindow: state.model?.contextWindow, autoCompactionEnabled: state.autoCompactionEnabled,
917
+ reserveTokens: handshake?.reserveTokens, keepRecentTokens: handshake?.keepRecentTokens,
918
+ compactRpc: true, events: handshake?.events,
919
+ trustedProtocol: builtin });
920
+ }
858
921
  // 严格对账(Owner 决策):模型必须来自 pi 的权威可用列表,不做后缀猜测——
859
922
  // 配置的模型与 pi 可用列表不符即报错,并在错误里列出可用模型。
860
923
  const availableEntries = (available.models ?? []).map((entry) => ({
@@ -988,7 +1051,7 @@ export function createNativeRuntimeAdapter(id, dependencies = {}) {
988
1051
  ? "builtin" : "external";
989
1052
  return new PiSession(await (dependencies.createPiSession
990
1053
  ? dependencies.createPiSession(input)
991
- : createPiRpcBackend(input, { ...dependencies, piCommand: resolvedExecutable }, spawn)), distribution);
1054
+ : createPiRpcBackend(input, { ...dependencies, piCommand: resolvedExecutable }, spawn, productionSpawn)), distribution);
992
1055
  }
993
1056
  if (id === "codex") {
994
1057
  const codexInput = dependencies.codexModelOverride?.trim()