larkin 0.4.1 → 0.4.5

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 (42) hide show
  1. package/README.md +76 -33
  2. package/artifacts/release/THIRD_PARTY_NOTICES.txt +218 -228
  3. package/assets/readme/larkin-github-cover.png +0 -0
  4. package/assets/readme/larkin-hero.svg +111 -0
  5. package/dist/agent/agent-state-store.cjs +193 -1
  6. package/dist/agent/agent-state-store.mjs +191 -0
  7. package/dist/app/agent-config.mjs +73 -6
  8. package/dist/app/agent-readiness.cjs +22 -2
  9. package/dist/app/agent-readiness.mjs +20 -2
  10. package/dist/app/binary-entry.mjs +6 -0
  11. package/dist/app/cli.mjs +7 -1
  12. package/dist/app/local-control.mjs +108 -2
  13. package/dist/app/run.mjs +8 -0
  14. package/dist/app/runtime-process.mjs +17 -1
  15. package/dist/app/session-cli.mjs +55 -20
  16. package/dist/dashboard/dashboard-view-model.mjs +12 -3
  17. package/dist/feishu/host-shell.cjs +104 -9
  18. package/dist/feishu/host-shell.mjs +105 -10
  19. package/dist/platform/config.cjs +406 -33
  20. package/dist/platform/config.mjs +405 -33
  21. package/dist/platform/process-boundary-trace.cjs +108 -0
  22. package/dist/platform/process-boundary-trace.mjs +102 -0
  23. package/dist/runtime/pi-artifact-provenance.cjs +147 -0
  24. package/dist/runtime/pi-artifact-provenance.mjs +139 -0
  25. package/dist/runtime/pi-compaction-recovery.cjs +703 -0
  26. package/dist/runtime/pi-compaction-recovery.mjs +679 -0
  27. package/dist/runtime/pi-model-catalog.cjs +2 -2
  28. package/dist/runtime/pi-model-catalog.mjs +2 -2
  29. package/dist/runtime/pi-profile-migration.cjs +556 -0
  30. package/dist/runtime/pi-profile-migration.mjs +515 -0
  31. package/dist/runtime/pi-provider-config.cjs +3 -3
  32. package/dist/runtime/pi-provider-config.mjs +3 -3
  33. package/dist/runtime/pi-rpc-client.cjs +39 -22
  34. package/dist/runtime/pi-rpc-client.mjs +39 -22
  35. package/dist/runtime/provider-error-classifier.cjs +39 -0
  36. package/dist/runtime/provider-error-classifier.mjs +34 -0
  37. package/dist/runtime/runtime-adapters.mjs +176 -27
  38. package/dist/runtime/runtime-host.cjs +630 -19
  39. package/dist/runtime/runtime-host.mjs +628 -18
  40. package/dist/runtime/runtime-readiness.cjs +6 -4
  41. package/dist/runtime/runtime-readiness.mjs +6 -4
  42. package/package.json +3 -3
@@ -3,6 +3,19 @@ function timestamp(value) {
3
3
  const parsed = Date.parse(String(value || ""));
4
4
  return Number.isFinite(parsed) ? parsed : null;
5
5
  }
6
+ export function isRuntimeReadinessCurrent(readiness, daemonStartedAt) {
7
+ if (readiness?.state !== "ready")
8
+ return false;
9
+ const observedAt = timestamp(readiness.observedAt);
10
+ const epoch = timestamp(daemonStartedAt);
11
+ return observedAt !== null && epoch !== null && observedAt >= epoch;
12
+ }
13
+ export function isCurrentOwnedDaemon(daemon) {
14
+ return daemon?.state === "owned"
15
+ && Number(daemon.pid) > 0
16
+ && typeof daemon.processStartToken === "string" && daemon.processStartToken.length > 0
17
+ && timestamp(daemon.startedAt) !== null;
18
+ }
6
19
  export function isChannelReconnecting(status) {
7
20
  const reconnectingAt = timestamp(status?.reconnectingAt);
8
21
  const connectedAt = timestamp(status?.connectedAt);
@@ -12,9 +25,14 @@ export function isChannelReconnecting(status) {
12
25
  }
13
26
  export function projectAgentReadiness(input) {
14
27
  const status = input.status ?? null;
15
- const daemonOwned = daemonHasAgent(input.daemon, input.agentId);
28
+ const daemonOwned = isCurrentOwnedDaemon(input.daemon) && daemonHasAgent(input.daemon, input.agentId);
16
29
  const daemonStartedAt = timestamp(input.daemon.startedAt);
17
30
  const connectedAt = timestamp(status?.connectedAt);
31
+ // A resumed session deliberately retains its original creation time. Its
32
+ // current daemon-epoch proof is the fresh session observation, not that
33
+ // historic creation timestamp.
34
+ const sessionObservedAt = timestamp(status?.session?.lastSeenAt) ?? timestamp(status?.session?.startedAt);
35
+ const sessionCurrent = daemonStartedAt !== null && sessionObservedAt !== null && sessionObservedAt >= daemonStartedAt;
18
36
  const channelConnected = daemonOwned
19
37
  && status?.connectedVia === "channel"
20
38
  && daemonStartedAt !== null
@@ -22,7 +40,7 @@ export function projectAgentReadiness(input) {
22
40
  && connectedAt >= daemonStartedAt;
23
41
  const readiness = {
24
42
  daemon_owned: daemonOwned,
25
- runtime_ready: status?.runtimeReadiness?.state === "ready",
43
+ runtime_ready: daemonOwned && sessionCurrent && isRuntimeReadinessCurrent(status?.runtimeReadiness, input.daemon.startedAt),
26
44
  channel_connected: channelConnected,
27
45
  channel_not_reconnecting: !isChannelReconnecting(status),
28
46
  };
@@ -55,6 +55,12 @@ async function dispatchInternal(mode, rest) {
55
55
  process.title = "pi-rpc";
56
56
  process.env.PI_CODING_AGENT = "true";
57
57
  process.emitWarning = (() => { });
58
+ // The Pi package's normal Bun CLI registers OAuth loaders before starting.
59
+ // Larkin invokes its RPC entrypoint directly, so do the same here; otherwise
60
+ // an OAuth-backed compact falls back to a dynamic module import unavailable in
61
+ // a compiled standalone binary.
62
+ const { registerBunOAuthFlows } = await import("@earendil-works/pi-ai/bun-oauth");
63
+ registerBunOAuthFlows();
58
64
  const { main: piMain } = await import("@earendil-works/pi-coding-agent");
59
65
  const { invokeBuiltinPiRpc } = await import("../runtime/pi-inline-extensions.mjs");
60
66
  await invokeBuiltinPiRpc(piMain, rest);
package/dist/app/cli.mjs CHANGED
@@ -29,6 +29,7 @@ const routes = {
29
29
  model: ["agent-config", "model"],
30
30
  runtime: ["agent-config", "runtime"],
31
31
  effort: ["agent-config", "effort"],
32
+ "pi-distribution": ["agent-config", "pi-distribution"],
32
33
  chats: ["agent-config", "chats"],
33
34
  config: ["agent-config", "config"],
34
35
  session: ["session-cli"],
@@ -58,6 +59,9 @@ List every configured Agent and its current local status. Use --json for daemon/
58
59
  Show or change an Agent model.`,
59
60
  runtime: `Usage: larkin runtime [<runtime>] [--agent <App ID>] [--model <model>]
60
61
  Show or change an Agent runtime.`,
62
+ "pi-distribution": `Usage: larkin pi-distribution [show|builtin|external] [--agent <App ID>] [--snapshot <private-file>] [--import-external-profile]
63
+ larkin pi-distribution rollback --snapshot <private-file>
64
+ Show or change one Pi Agent distribution. builtin requires configured provider state, or explicitly imports the external Pi 0.84.2 profile with --import-external-profile; all changes support config-lock CAS rollback.`,
61
65
  effort: `Usage: larkin effort [<level>|clear|default] [--agent <App ID>]
62
66
  Show or change an Agent reasoning effort; clear/default restores the Runtime default.`,
63
67
  chats: `Usage: larkin chats [--agent <App ID>]
@@ -77,7 +81,8 @@ Examples:
77
81
 
78
82
  Credentials, internal paths, serverId, activeAgent, and raw config are never exposed here.`,
79
83
  session: `Usage: larkin session reset --agent <App ID> --json [--wait-ready <seconds>]
80
- Atomically replace one idle, zero-backlog Agent Runtime session through authenticated local control.`,
84
+ larkin session recover --agent <App ID> --reason context-overflow --json [--wait-ready <seconds>]
85
+ Reset replaces one idle, zero-backlog Runtime session. Recover is an explicit operator-only context-window recovery that preserves and replays canonical Inbox deliveries.`,
81
86
  "pi-auth": `Usage: larkin pi-auth status [--agent <App ID>] [--json]
82
87
  larkin pi-auth logout <provider> [--agent <App ID>]
83
88
  Show non-sensitive official Pi credential metadata or remove one target provider credential.`,
@@ -124,6 +129,7 @@ Usage: larkin <command>
124
129
  chats List known chats; use free/strict <oc_id> to configure mention requirements
125
130
  config Inspect effective config/source, edit mention inheritance, or explicitly apply runtime changes
126
131
  session reset Replace one idle, zero-backlog Agent Runtime session for a fresh scenario
132
+ session recover Explicitly recover a context-overflowed session and replay retained Inbox deliveries
127
133
  pi-auth Show non-sensitive built-in Pi auth status or logout one provider
128
134
  comment reply Reply to the exact cloud-document comment bound by a polled Inbox message
129
135
  telemetry Inspect, export, import, or flush the durable OpenTelemetry trace queue
@@ -15,6 +15,48 @@ const AGENT_ID = /^cli_[A-Za-z0-9]+$/;
15
15
  const OPERATION_ID = /^[A-Za-z0-9_-]{8,128}$/;
16
16
  const AUTHORIZATION = /^[A-Za-z0-9_-]{43,128}$/;
17
17
  const UNIX_SOCKET_PATH_MAX_BYTES = process.platform === "darwin" ? 103 : 107;
18
+ function recoveryErrorText(code) {
19
+ switch (code) {
20
+ case "unknown_agent": return "unknown Agent";
21
+ case "agent_busy": return "Agent is not idle";
22
+ case "channel_unavailable": return "Runtime channel is unavailable";
23
+ case "channel_reconnecting": return "Runtime channel is reconnecting";
24
+ case "runtime_unavailable": return "Runtime readiness is unavailable";
25
+ case "recovery_unavailable": return "context-overflow recovery is unavailable";
26
+ case "recovery_staged_not_committed": return "context-overflow recovery was staged but not committed";
27
+ case "state_persistence_failed": return "context-overflow recovery committed but state persistence failed";
28
+ case "recovery_timeout": return "context-overflow recovery committed but readiness did not converge";
29
+ case "recovery_refused": return "context-overflow recovery was refused";
30
+ default: return "context-overflow recovery failed";
31
+ }
32
+ }
33
+ function sanitizeRecoveryReadiness(value) {
34
+ if (!value || typeof value !== "object" || Array.isArray(value))
35
+ return undefined;
36
+ const candidate = value;
37
+ if (!["codex", "claude", "pi"].includes(String(candidate.runtime))
38
+ || !["missing", "unauthenticated", "unavailable", "incompatible", "ready"].includes(String(candidate.state)))
39
+ return undefined;
40
+ const runtime = candidate.runtime;
41
+ const state = candidate.state;
42
+ return { runtime, state,
43
+ ...(state === "ready" ? {} : { reason: `Runtime readiness is ${state}.`, nextAction: "Inspect Runtime/provider configuration, then retry." }) };
44
+ }
45
+ function sanitizeSessionRecoveryResponse(response, agentId) {
46
+ const code = typeof response.code === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(response.code) ? response.code : undefined;
47
+ return {
48
+ ok: response.ok === true, agentId, ...(code ? { code } : {}),
49
+ ...(code || response.error ? { error: recoveryErrorText(code) } : {}),
50
+ recoveryCommitted: response.recoveryCommitted === true, generationChanged: response.generationChanged === true,
51
+ sessionChanged: response.sessionChanged === true, turns: 0, runtimeReady: response.runtimeReady === true,
52
+ channelConnected: response.channelConnected === true, reconnecting: response.reconnecting === true,
53
+ pendingCount: Math.max(0, Number(response.pendingCount) || 0), rearmedCount: Math.max(0, Number(response.rearmedCount) || 0),
54
+ replayStatus: ["scheduled", "pending", "consumed", "not_started"].includes(response.replayStatus) ? response.replayStatus : "not_started",
55
+ remainingPendingCount: Math.max(0, Number(response.remainingPendingCount) || 0),
56
+ readyForFreshScenario: response.readyForFreshScenario === true, inboundObserved: false,
57
+ ...(sanitizeRecoveryReadiness(response.readiness) ? { readiness: sanitizeRecoveryReadiness(response.readiness) } : {}),
58
+ };
59
+ }
18
60
  function socketPathsFit(root) {
19
61
  if (isWindows)
20
62
  return true; // Windows named-pipe socket paths use a much larger limit
@@ -92,7 +134,7 @@ function parseRequest(line) {
92
134
  throw new Error("invalid agent control request");
93
135
  }
94
136
  if (value.operation === "session-reset") {
95
- if (value.waitReadyMs !== undefined && (!Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
137
+ if (value.waitReadyMs !== undefined && (typeof value.waitReadyMs !== "number" || !Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
96
138
  throw new Error("invalid session reset waitReadyMs");
97
139
  }
98
140
  if (Object.keys(value).some((key) => !["agentId", "authorization", "operation", "waitReadyMs"].includes(key))) {
@@ -100,6 +142,17 @@ function parseRequest(line) {
100
142
  }
101
143
  return value;
102
144
  }
145
+ if (value.operation === "session-recover") {
146
+ if (value.reason !== "context-overflow")
147
+ throw new Error("session recovery requires reason=context-overflow");
148
+ if (value.waitReadyMs !== undefined && (typeof value.waitReadyMs !== "number" || !Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
149
+ throw new Error("invalid session recovery waitReadyMs");
150
+ }
151
+ if (Object.keys(value).some((key) => !["agentId", "authorization", "operation", "reason", "waitReadyMs"].includes(key))) {
152
+ throw new Error("session recovery control request 包含未知字段");
153
+ }
154
+ return value;
155
+ }
103
156
  if (value.operation !== undefined || !OPERATION_ID.test(String(value.operationId || ""))) {
104
157
  throw new Error("invalid agent upsert request");
105
158
  }
@@ -498,13 +551,14 @@ export function createSupervisorControlServer({ larkinHome, authorityToken, ensu
498
551
  },
499
552
  };
500
553
  }
501
- export function createAgentControlServer({ larkinHome, authorityToken, upsert, resetSession, maxRememberedOperations = 256, }) {
554
+ export function createAgentControlServer({ larkinHome, authorityToken, upsert, resetSession, recoverSession, maxRememberedOperations = 256, }) {
502
555
  let socket = "";
503
556
  let socketRoot = "";
504
557
  let socketIdentity = null;
505
558
  const completed = new Map();
506
559
  const inFlight = new Map();
507
560
  const resetInFlight = new Map();
561
+ const recoveryInFlight = new Map();
508
562
  const agentQueues = new Map();
509
563
  const upsertConflict = (operationId, agentId) => ({
510
564
  ok: false, operationId, agentId, code: "operation_conflict", error: "operationId 已绑定其他 Agent 或操作",
@@ -564,6 +618,52 @@ export function createAgentControlServer({ larkinHome, authorityToken, upsert, r
564
618
  return;
565
619
  }
566
620
  if ("operation" in request) {
621
+ if (request.operation === "session-recover") {
622
+ const recoveryRequest = request;
623
+ let operation = recoveryInFlight.get(recoveryRequest.agentId);
624
+ if (!operation) {
625
+ const executeRecovery = async () => {
626
+ try {
627
+ if (!recoverSession)
628
+ throw new Error("session recovery control unavailable");
629
+ const result = await recoverSession({ agentId: recoveryRequest.agentId, reason: recoveryRequest.reason,
630
+ waitReadyMs: recoveryRequest.waitReadyMs ?? 30_000 });
631
+ return sanitizeSessionRecoveryResponse(result, recoveryRequest.agentId);
632
+ }
633
+ catch (error) {
634
+ const code = typeof error.code === "string"
635
+ ? String(error.code)
636
+ : error instanceof RuntimePrerequisiteError && error.readiness.state === "unavailable"
637
+ ? "runtime_unavailable" : "recovery_refused";
638
+ const projection = error;
639
+ return { ok: false, agentId: recoveryRequest.agentId, code,
640
+ error: recoveryErrorText(code), recoveryCommitted: false,
641
+ generationChanged: false, sessionChanged: false, turns: Math.max(0, Number(projection.turns) || 0),
642
+ runtimeReady: projection.runtimeReady === true, channelConnected: projection.channelConnected === true,
643
+ reconnecting: projection.reconnecting === true, pendingCount: Math.max(0, Number(projection.pendingCount) || 0),
644
+ rearmedCount: Math.max(0, Number(projection.rearmedCount) || 0), replayStatus: "not_started",
645
+ remainingPendingCount: Math.max(0, Number(projection.remainingPendingCount) || 0),
646
+ readyForFreshScenario: false, inboundObserved: false,
647
+ ...(error instanceof RuntimePrerequisiteError ? { readiness: sanitizeRecoveryReadiness(error.readiness) } : {}) };
648
+ }
649
+ };
650
+ const prior = agentQueues.get(recoveryRequest.agentId) ?? Promise.resolve();
651
+ const executing = prior.catch(() => { }).then(executeRecovery);
652
+ let queued;
653
+ queued = executing.finally(() => {
654
+ if (agentQueues.get(recoveryRequest.agentId) === queued)
655
+ agentQueues.delete(recoveryRequest.agentId);
656
+ if (recoveryInFlight.get(recoveryRequest.agentId) === queued)
657
+ recoveryInFlight.delete(recoveryRequest.agentId);
658
+ });
659
+ agentQueues.set(recoveryRequest.agentId, queued);
660
+ recoveryInFlight.set(recoveryRequest.agentId, queued);
661
+ operation = queued;
662
+ }
663
+ const response = await operation;
664
+ connection.end(`${JSON.stringify(response)}\n`);
665
+ return;
666
+ }
567
667
  const resetRequest = request;
568
668
  let operation = resetInFlight.get(resetRequest.agentId);
569
669
  if (!operation) {
@@ -776,6 +876,12 @@ export async function requestSessionReset({ larkinHome, agentId, waitReadyMs = 3
776
876
  request: { operation: "session-reset", agentId, waitReadyMs },
777
877
  });
778
878
  }
879
+ export async function requestSessionRecovery({ larkinHome, agentId, reason = "context-overflow", waitReadyMs = 30_000, }) {
880
+ return requestAgentControl({
881
+ larkinHome, timeoutMs: Math.max(1_000, waitReadyMs + 1_000),
882
+ request: { operation: "session-recover", agentId, reason, waitReadyMs },
883
+ });
884
+ }
779
885
  export function cleanupStaleAgentControlSocket(larkinHome, expectedToken) {
780
886
  const authority = secureAuthority(larkinHome);
781
887
  if (!sameSecret(authority.token, expectedToken))
package/dist/app/run.mjs CHANGED
@@ -11,6 +11,7 @@ import { acquireProcessLock, readProcessState, terminateOwnedProcess, waitForPro
11
11
  import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
12
12
  import { cleanupStaleAgentControlSocket, createSupervisorControlServer, initializeControlAuthority, removeControlAuthority, requestDashboardRecovery, } from "./local-control.mjs";
13
13
  import { internalCommandSpec, processCommandToken } from "./internal-command.mjs";
14
+ import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
14
15
  const HERE = path.dirname(fileURLToPath(import.meta.url));
15
16
  const die = (message) => { console.error(`✗ ${message}`); process.exit(1); };
16
17
  export function selectedAgentIds(argv, available) {
@@ -63,8 +64,11 @@ export async function stopDashboardWithinBound(child, configDir, signal = "SIGTE
63
64
  return await waitForChildExit(child, Math.max(250, Math.min(2_000, timeoutMs))) ? "killed" : "unowned";
64
65
  }
65
66
  export async function main() {
67
+ process.env.LARKIN_DAEMON_EPOCH ||= new Date().toISOString();
66
68
  const argv = process.argv.slice(2);
67
69
  const { configDir, file: configFile, config } = larkinConfig.loadConfig(process.env);
70
+ for (const agent of Object.values(config.agents))
71
+ traceProcessBoundary(process.env, "supervisor:config-loaded", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
68
72
  if (!fs.existsSync(configFile))
69
73
  die(`没找到配置 ${configFile},先跑 larkin setup`);
70
74
  if (!config.serverId || !Object.keys(config.agents).length)
@@ -193,6 +197,8 @@ export async function main() {
193
197
  };
194
198
  if (argv.includes("--dry-run"))
195
199
  runtimeEnv.LARKIN_FEISHU_DRYRUN = "1";
200
+ for (const agent of agents)
201
+ traceProcessBoundary(runtimeEnv, "supervisor:daemon-env-prepared", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
196
202
  let stopping = false;
197
203
  let dashboard = null;
198
204
  // Set when the daemon-restart path stops the dashboard on purpose (port
@@ -218,6 +224,8 @@ export async function main() {
218
224
  // external process manager (launchd) takes over as the final respawn layer.
219
225
  const launchDaemon = () => {
220
226
  daemon = spawn(daemonSpec.command, daemonSpec.args, { env: runtimeEnv, stdio: "inherit" });
227
+ for (const agent of agents)
228
+ traceProcessBoundary(runtimeEnv, "supervisor:daemon-spawned", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId), childPid: daemon.pid ?? null });
221
229
  daemon.once("exit", (code, signal) => {
222
230
  if (stopping) {
223
231
  resolveDaemonFinal?.({ code, signal });
@@ -7,6 +7,7 @@ import { createNativeRuntimeAdapter } from "../runtime/runtime-adapters.mjs";
7
7
  import { createRuntimeHost } from "../runtime/runtime-host.mjs";
8
8
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
9
9
  import { loadConfig, markConfigApplied, runtimeConfigSignature } from "../platform/config.mjs";
10
+ import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
10
11
  import { createAgentControlServer } from "./local-control.mjs";
11
12
  import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
12
13
  import { loadTelemetryConfig } from "../platform/telemetry-config.mjs";
@@ -53,6 +54,7 @@ export async function main(env = process.env, overrides = {}) {
53
54
  channelPackage = await import(pathToFileURL(path.resolve(env.LARKIN_TEST_CHANNEL_MODULE)).href);
54
55
  }
55
56
  const adapters = new Map();
57
+ env.LARKIN_DAEMON_EPOCH ||= new Date().toISOString();
56
58
  const runtimeHost = overrides.runtimeHost ?? createRuntimeHost({
57
59
  promptBuilder: new ContextPromptBuilder(),
58
60
  stateStoreFor(agentId) {
@@ -91,6 +93,9 @@ export async function main(env = process.env, overrides = {}) {
91
93
  throw new Error("LARKIN_HOME/LARKIN_CONFIG_DIR required");
92
94
  if (!env.LARKIN_CONTROL_AUTHORIZATION)
93
95
  throw new Error("LARKIN_CONTROL_AUTHORIZATION required");
96
+ const loaded = loadConfig(env);
97
+ for (const agent of Object.values(loaded.config.agents))
98
+ traceProcessBoundary(env, "daemon:config-loaded", { configDir: loaded.configDir, agentId: agent.agentId, targetDir: path.join(loaded.configDir, "providers", "pi", agent.agentId) });
94
99
  controlServer = createAgentControlServer({
95
100
  larkinHome: env.LARKIN_HOME,
96
101
  authorityToken: env.LARKIN_CONTROL_AUTHORIZATION,
@@ -104,9 +109,20 @@ export async function main(env = process.env, overrides = {}) {
104
109
  const result = await hostShell.resetSession(request.agentId, request.waitReadyMs);
105
110
  return { ok: result.readyForFreshScenario, agentId: request.agentId, ...result };
106
111
  },
112
+ async recoverSession(request) {
113
+ const result = await hostShell.recoverSession(request.agentId, request.reason, request.waitReadyMs);
114
+ return { ok: result.recoveryCommitted, agentId: request.agentId, ...result };
115
+ },
107
116
  });
108
117
  await controlServer.start();
109
- await markConfigAppliedAfterRuntimeReady(env, hostShell.agents, hostShell.start());
118
+ for (const agent of hostShell.agents)
119
+ traceProcessBoundary(env, "daemon:before-runtime-ready", { configDir: loaded.configDir, agentId: agent.agentId, targetDir: path.join(loaded.configDir, "providers", "pi", agent.agentId) });
120
+ const runtimeStart = hostShell.start().catch((error) => {
121
+ for (const agent of hostShell.agents)
122
+ traceProcessBoundary(env, "daemon:runtime-ready-failure", { configDir: loaded.configDir, agentId: agent.agentId, targetDir: path.join(loaded.configDir, "providers", "pi", agent.agentId), error });
123
+ throw error;
124
+ });
125
+ await markConfigAppliedAfterRuntimeReady(env, hostShell.agents, runtimeStart);
110
126
  }
111
127
  if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) {
112
128
  main().catch((error) => { console.error(`✗ ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });
@@ -1,8 +1,19 @@
1
1
  import { loadConfig } from "../platform/config.mjs";
2
- import { requestSessionReset } from "./local-control.mjs";
3
- function parseResetArguments(args) {
4
- if (args[0] !== "reset")
2
+ import { requestSessionRecovery, requestSessionReset } from "./local-control.mjs";
3
+ function sanitizeRecoveryReadiness(value) {
4
+ if (!value || typeof value !== "object" || Array.isArray(value))
5
+ return undefined;
6
+ const candidate = value;
7
+ if (!["codex", "claude", "pi"].includes(String(candidate.runtime))
8
+ || !["missing", "unauthenticated", "unavailable", "incompatible", "ready"].includes(String(candidate.state)))
9
+ return undefined;
10
+ return { runtime: candidate.runtime, state: candidate.state,
11
+ ...(candidate.state === "ready" ? {} : { reason: `Runtime readiness is ${String(candidate.state)}.`, nextAction: "Inspect Runtime/provider configuration, then retry." }) };
12
+ }
13
+ function parseSessionArguments(args) {
14
+ if (args[0] !== "reset" && args[0] !== "recover")
5
15
  throw new Error("unsupported session subcommand");
16
+ const operation = args[0];
6
17
  const values = new Map();
7
18
  let json = false;
8
19
  for (let index = 1; index < args.length; index += 1) {
@@ -13,7 +24,7 @@ function parseResetArguments(args) {
13
24
  json = true;
14
25
  continue;
15
26
  }
16
- if (!["--agent", "--wait-ready"].includes(token)) {
27
+ if (!["--agent", "--wait-ready", "--reason"].includes(token)) {
17
28
  throw new Error(token.startsWith("-") ? `unknown flag: ${token}` : `unexpected positional: ${token}`);
18
29
  }
19
30
  if (values.has(token))
@@ -32,7 +43,12 @@ function parseResetArguments(args) {
32
43
  const waitSeconds = Number(values.get("--wait-ready") ?? "30");
33
44
  if (!Number.isFinite(waitSeconds) || waitSeconds < 0 || waitSeconds > 300)
34
45
  throw new Error("--wait-ready must be 0..300 seconds");
35
- return { agentId, waitReadyMs: Math.round(waitSeconds * 1000) };
46
+ const reason = values.get("--reason");
47
+ if (operation === "recover" && reason !== "context-overflow")
48
+ throw new Error("--reason context-overflow is required for session recover");
49
+ if (operation === "reset" && reason !== undefined)
50
+ throw new Error("--reason is only valid for session recover");
51
+ return { operation, agentId, waitReadyMs: Math.round(waitSeconds * 1000), ...(reason ? { reason: reason } : {}) };
36
52
  }
37
53
  export async function runSessionCli(args, env = process.env, dependencies = {}) {
38
54
  const io = dependencies.io ?? { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value) };
@@ -42,29 +58,46 @@ export async function runSessionCli(args, env = process.env, dependencies = {})
42
58
  };
43
59
  let parsed;
44
60
  try {
45
- parsed = parseResetArguments(args);
61
+ parsed = parseSessionArguments(args);
46
62
  }
47
63
  catch (error) {
48
64
  return fail("invalid_arguments", error instanceof Error ? error.message : String(error));
49
65
  }
50
66
  const agentId = parsed.agentId;
51
67
  if (typeof env.LARKIN_AGENT_ID === "string" && env.LARKIN_AGENT_ID.trim()) {
52
- return fail("user_authority_required", "session reset is available only from a user terminal", agentId);
68
+ return fail("user_authority_required", parsed.operation === "reset"
69
+ ? "session reset is available only from a user terminal"
70
+ : "session recovery is available only from a user terminal", agentId);
53
71
  }
54
72
  try {
55
73
  const loaded = loadConfig(env);
56
- const result = await (dependencies.request ?? requestSessionReset)({
57
- larkinHome: loaded.config.larkinHome, agentId, waitReadyMs: parsed.waitReadyMs,
58
- });
59
- const output = {
60
- ok: result.ok, agent_id: result.agentId,
61
- reset_committed: result.resetCommitted, generation_changed: result.generationChanged,
62
- session_changed: result.sessionChanged, turns: result.turns, runtime_ready: result.runtimeReady,
63
- channel_connected: result.channelConnected, reconnecting: result.reconnecting,
64
- pending_count: result.pendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
65
- inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
66
- ...(result.error ? { error: result.error } : {}), ...(result.readiness ? { readiness: result.readiness } : {}),
67
- };
74
+ const result = parsed.operation === "recover"
75
+ ? await (dependencies.requestRecovery ?? requestSessionRecovery)({
76
+ larkinHome: loaded.config.larkinHome, agentId, reason: parsed.reason, waitReadyMs: parsed.waitReadyMs,
77
+ })
78
+ : await (dependencies.request ?? requestSessionReset)({
79
+ larkinHome: loaded.config.larkinHome, agentId, waitReadyMs: parsed.waitReadyMs,
80
+ });
81
+ const output = "recoveryCommitted" in result
82
+ ? {
83
+ ok: result.ok, agent_id: result.agentId, recovery_committed: result.recoveryCommitted,
84
+ generation_changed: result.generationChanged, session_changed: result.sessionChanged, turns: result.turns,
85
+ runtime_ready: result.runtimeReady, channel_connected: result.channelConnected, reconnecting: result.reconnecting,
86
+ pending_count: result.pendingCount, rearmed_count: result.rearmedCount, replay_status: result.replayStatus,
87
+ remaining_pending_count: result.remainingPendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
88
+ inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
89
+ ...(result.error ? { error: "context-overflow recovery failed" } : {}),
90
+ ...(sanitizeRecoveryReadiness(result.readiness) ? { readiness: sanitizeRecoveryReadiness(result.readiness) } : {}),
91
+ }
92
+ : {
93
+ ok: result.ok, agent_id: result.agentId,
94
+ reset_committed: result.resetCommitted, generation_changed: result.generationChanged,
95
+ session_changed: result.sessionChanged, turns: result.turns, runtime_ready: result.runtimeReady,
96
+ channel_connected: result.channelConnected, reconnecting: result.reconnecting,
97
+ pending_count: result.pendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
98
+ inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
99
+ ...(result.error ? { error: result.error } : {}), ...(result.readiness ? { readiness: result.readiness } : {}),
100
+ };
68
101
  io.stdout(`${JSON.stringify(output, null, 2)}\n`);
69
102
  return result.ok ? 0 : 1;
70
103
  }
@@ -72,7 +105,9 @@ export async function runSessionCli(args, env = process.env, dependencies = {})
72
105
  const message = error instanceof Error ? error.message : String(error);
73
106
  const output = {
74
107
  ok: false, agent_id: agentId,
75
- code: /timeout/i.test(message) ? "control_timeout" : "control_unavailable", error: message,
108
+ code: /timeout/i.test(message) ? "control_timeout" : "control_unavailable",
109
+ error: parsed.operation === "recover"
110
+ ? (/timeout/i.test(message) ? "session recovery control timed out" : "session recovery control unavailable") : message,
76
111
  };
77
112
  io.stdout(`${JSON.stringify(output, null, 2)}\n`);
78
113
  return 1;
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { StringDecoder } from "node:string_decoder";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { readJson, readProcessState } from "../platform/process-state.mjs";
10
+ import { isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
10
11
  import { isAllowedDashboardAvatarUrl } from "./dashboard-avatar.mjs";
11
12
  import { collectWorkspaceEntry as collectTypedWorkspaceEntry } from "./dashboard-workspace.mjs";
12
13
  import { buildFingerprint, packageVersion } from "../platform/build-info.mjs";
@@ -429,7 +430,7 @@ export function projectStatusTimeline(status) {
429
430
  ].sort((x, y) => new Date(String(y.at)).getTime() - new Date(String(x.at)).getTime()).slice(0, 30);
430
431
  return { activityLog, deliverLog, recentErrors, lastActivity, lastDeliver, feed };
431
432
  }
432
- async function collectAgentStatus(a, configDir, piModelResolver) {
433
+ async function collectAgentStatus(a, configDir, daemonStartedAt, piModelResolver) {
433
434
  const dir = a.stateDir;
434
435
  const botIdentity = readJson(path.join(dir, "bot-identity.json"), null);
435
436
  const status = readJson(path.join(dir, "status.json"), {});
@@ -476,6 +477,14 @@ async function collectAgentStatus(a, configDir, piModelResolver) {
476
477
  }
477
478
  // 本 agent 专属时间线:三路历史合并按时间倒序,不是全局一条汇总——每个 agent 只看自己的故事。
478
479
  const { recentErrors, lastActivity, lastDeliver, feed } = projectStatusTimeline(status);
480
+ const statusReadiness = status.runtimeReadiness;
481
+ const sessionStartedAt = finiteTime(status.session?.startedAt);
482
+ const daemonEpoch = finiteTime(daemonStartedAt);
483
+ const sessionCurrent = sessionStartedAt !== null && daemonEpoch !== null && sessionStartedAt >= daemonEpoch;
484
+ const runtimeReadiness = sessionCurrent && isRuntimeReadinessCurrent(statusReadiness, daemonStartedAt)
485
+ || statusReadiness?.state !== "ready"
486
+ ? statusReadiness || null
487
+ : { ...statusReadiness, state: "unavailable", reason: "Runtime readiness evidence is stale for the current daemon epoch." };
479
488
  // 提醒:不只给数量,给完整列表——待触发按最近到期优先排前面,已触发/已取消按最近的排在后面。
480
489
  const pendingReminders = reminders.filter((r) => r.status === "scheduled" || r.status === "pending")
481
490
  .sort((x, y) => new Date(String(x.fireAt)).getTime() - new Date(String(y.fireAt)).getTime());
@@ -492,7 +501,7 @@ async function collectAgentStatus(a, configDir, piModelResolver) {
492
501
  runtime: a.runtime,
493
502
  model: status.session?.runtime === a.runtime && status.session?.model ? String(status.session.model) : a.model,
494
503
  effort: status.session?.runtime === a.runtime && status.session?.reasoningEffort ? String(status.session.reasoningEffort) : a.effort || null,
495
- runtimeReadiness: status.runtimeReadiness || null,
504
+ runtimeReadiness,
496
505
  credentialReady: fs.existsSync(path.join(configDir, "bots", `${a.feishuProfile}.json`)),
497
506
  bot: botIdentity ? {
498
507
  name: botIdentity.name,
@@ -542,7 +551,7 @@ export async function collectStatus(options = {}) {
542
551
  uptimeSec: daemonStatus?.startedAt ? ageSec(daemonStatus.startedAt) : null,
543
552
  agents: Array.isArray(daemonStatus?.agents) ? daemonStatus.agents.filter((id) => typeof id === "string") : [],
544
553
  };
545
- const statuses = (await Promise.all(agents.map((a) => collectAgentStatus(a, configDir, options.piModelResolver)))).map((agent) => {
554
+ const statuses = (await Promise.all(agents.map((a) => collectAgentStatus(a, configDir, daemon.startedAt, options.piModelResolver)))).map((agent) => {
546
555
  return { ...agent, ...projectAgentHealth(agent, daemon) };
547
556
  });
548
557
  return {