larkin 0.4.2 → 0.4.6

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 (35) hide show
  1. package/README.md +5 -1
  2. package/artifacts/release/THIRD_PARTY_NOTICES.txt +218 -228
  3. package/assets/readme/larkin-github-cover.png +0 -0
  4. package/dist/agent/agent-state-store.cjs +1 -2
  5. package/dist/agent/agent-state-store.mjs +1 -2
  6. package/dist/app/agent-config.mjs +73 -6
  7. package/dist/app/agent-readiness.cjs +22 -2
  8. package/dist/app/agent-readiness.mjs +20 -2
  9. package/dist/app/binary-entry.mjs +6 -0
  10. package/dist/app/cli.mjs +4 -0
  11. package/dist/app/run.mjs +8 -0
  12. package/dist/app/runtime-process.mjs +13 -1
  13. package/dist/dashboard/dashboard-view-model.mjs +12 -3
  14. package/dist/feishu/host-shell.cjs +33 -10
  15. package/dist/feishu/host-shell.mjs +34 -11
  16. package/dist/platform/config.cjs +406 -33
  17. package/dist/platform/config.mjs +405 -33
  18. package/dist/platform/process-boundary-trace.cjs +108 -0
  19. package/dist/platform/process-boundary-trace.mjs +102 -0
  20. package/dist/runtime/pi-artifact-provenance.cjs +147 -0
  21. package/dist/runtime/pi-artifact-provenance.mjs +139 -0
  22. package/dist/runtime/pi-compaction-recovery.cjs +120 -40
  23. package/dist/runtime/pi-compaction-recovery.mjs +117 -40
  24. package/dist/runtime/pi-model-catalog.cjs +2 -2
  25. package/dist/runtime/pi-model-catalog.mjs +2 -2
  26. package/dist/runtime/pi-profile-migration.cjs +556 -0
  27. package/dist/runtime/pi-profile-migration.mjs +515 -0
  28. package/dist/runtime/pi-provider-config.cjs +1 -1
  29. package/dist/runtime/pi-provider-config.mjs +1 -1
  30. package/dist/runtime/runtime-adapters.mjs +97 -11
  31. package/dist/runtime/runtime-host.cjs +162 -39
  32. package/dist/runtime/runtime-host.mjs +163 -40
  33. package/dist/runtime/runtime-readiness.cjs +6 -4
  34. package/dist/runtime/runtime-readiness.mjs +6 -4
  35. package/package.json +3 -3
@@ -11,6 +11,8 @@ import { isChannelReconnecting, projectAgentReadiness } from "./agent-readiness.
11
11
  import { requestAgentUpsert } from "./local-control.mjs";
12
12
  import * as larkinConfig from "../platform/config.mjs";
13
13
  import { managedOfficialLarkCli } from "./agent-lark-cli-workspace.mjs";
14
+ import { assertBuiltinPiAgentDirectory, piAgentDirectory } from "../runtime/pi-provider-config.mjs";
15
+ import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
14
16
  function safeDocumentCommentCapabilities(configDir, profile) {
15
17
  try {
16
18
  return {
@@ -49,15 +51,15 @@ const die = (message) => {
49
51
  console.error(" 下一步: larkin config --help");
50
52
  process.exit(1);
51
53
  };
52
- if (!kind || !["agents", "model", "runtime", "effort", "chats", "config"].includes(kind)) {
53
- die("用法: larkin agents | larkin config <show|mention|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
54
+ if (!kind || !["agents", "model", "runtime", "effort", "pi-distribution", "chats", "config"].includes(kind)) {
55
+ die("用法: larkin agents | larkin config <show|mention|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin pi-distribution [show|builtin|external] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
54
56
  }
55
57
  const allowedValueFlags = {
56
58
  agents: new Set(), model: new Set(["--agent"]), runtime: new Set(["--agent", "--model"]),
57
- effort: new Set(["--agent"]), chats: new Set(["--agent"]), config: new Set(["--agent", "--chat"]),
59
+ effort: new Set(["--agent"]), "pi-distribution": new Set(["--agent", "--snapshot"]), chats: new Set(["--agent"]), config: new Set(["--agent", "--chat"]),
58
60
  };
59
61
  const allowedBooleanFlags = {
60
- agents: new Set(["--json"]), model: new Set(), runtime: new Set(), effort: new Set(), chats: new Set(), config: new Set(["--json"]),
62
+ agents: new Set(["--json"]), model: new Set(), runtime: new Set(), effort: new Set(), "pi-distribution": new Set(["--import-external-profile"]), chats: new Set(), config: new Set(["--json"]),
61
63
  };
62
64
  const parsedValues = new Map();
63
65
  const parsedBooleans = new Set();
@@ -91,6 +93,7 @@ const flagAgent = parsedValues.get("--agent");
91
93
  const flagModel = parsedValues.get("--model");
92
94
  const flagChat = parsedValues.get("--chat");
93
95
  const flagJson = parsedBooleans.has("--json");
96
+ const importExternalProfile = parsedBooleans.has("--import-external-profile");
94
97
  const value = positionals[0];
95
98
  function assertOnlyFlags(valueFlags, booleanFlags = []) {
96
99
  const allowedValues = new Set(valueFlags);
@@ -122,6 +125,11 @@ else if (kind === "effort") {
122
125
  if (positionals.length > 1)
123
126
  die("用法: larkin effort [<level>|clear|default] [--agent <App ID>]");
124
127
  }
128
+ else if (kind === "pi-distribution") {
129
+ assertOnlyFlags(["--agent", "--snapshot"], ["--import-external-profile"]);
130
+ if (positionals.length > 1)
131
+ die("用法: larkin pi-distribution [show|builtin|external] [--agent <App ID>] [--snapshot <private-file>]");
132
+ }
125
133
  else if (kind === "chats") {
126
134
  assertOnlyFlags(["--agent"]);
127
135
  if (![0, 2].includes(positionals.length))
@@ -169,6 +177,21 @@ const loaded = (() => {
169
177
  const { configDir, file, config } = loaded;
170
178
  if (!fs.existsSync(file))
171
179
  die("未找到 Larkin 配置,请运行 larkin setup");
180
+ const snapshotFile = parsedValues.get("--snapshot");
181
+ if (kind === "pi-distribution" && value === "rollback") {
182
+ if (importExternalProfile)
183
+ die("--import-external-profile 只允许与 builtin 一起使用");
184
+ if (!snapshotFile || flagAgent || positionals.length !== 1)
185
+ die("用法: larkin pi-distribution rollback --snapshot <private-file>");
186
+ try {
187
+ const result = larkinConfig.rollbackConfig(process.env, snapshotFile);
188
+ say(JSON.stringify({ ok: true, rolledBackAgent: result.agentId, revision: result.revision }));
189
+ }
190
+ catch (error) {
191
+ die(`回滚失败:${error instanceof Error ? error.message : String(error)}`);
192
+ }
193
+ process.exit(0);
194
+ }
172
195
  if (kind === "agents") {
173
196
  const list = Object.values(config.agents || {});
174
197
  const daemon = readProcessState(config.larkinHome).daemon;
@@ -266,12 +289,19 @@ if (kind === "agents") {
266
289
  && !!status?.inboundVerifiedAt
267
290
  && Date.parse(status.inboundVerifiedAt) >= Date.parse(String(daemon.startedAt || 0));
268
291
  const commentDiagnostic = projectDocumentCommentDiagnostic(commentCapability, status);
292
+ const projectedReadiness = projectAgentReadiness({ agentId: agent.agentId, daemon, status });
269
293
  say(` ${agent.name}${agent.name === config.activeAgent ? " [active]" : ""}`);
270
294
  const effectiveModel = status?.session?.runtime === agent.runtime && status.session.model ? status.session.model : agent.model;
271
295
  const effectiveEffort = status?.session?.runtime === agent.runtime && status.session.reasoningEffort ? status.session.reasoningEffort : agent.effort;
272
296
  say(` runtime=${agent.runtime} model=${effectiveModel}${effectiveModel !== agent.model ? ` stored=${agent.model}` : ""}${effectiveEffort ? ` effort=${effectiveEffort}` : ""}`);
273
- if (status?.runtimeReadiness)
274
- say(` runtime readiness=${status.runtimeReadiness.state || "incompatible"}${status.runtimeReadiness.reason ? `:${status.runtimeReadiness.reason}` : ""}${status.runtimeReadiness.nextAction ? `;下一步:${status.runtimeReadiness.nextAction}` : ""}`);
297
+ if (status?.runtimeReadiness) {
298
+ const current = projectedReadiness.readiness.runtime_ready;
299
+ const state = status.runtimeReadiness.state === "ready" && !current ? "unavailable" : status.runtimeReadiness.state || "incompatible";
300
+ const staleReason = status.runtimeReadiness.state === "ready" && !current
301
+ ? ":ready 证据不属于当前 owned daemon epoch 或当前 session,已拒绝展示为 ready"
302
+ : status.runtimeReadiness.reason ? `:${status.runtimeReadiness.reason}` : "";
303
+ say(` runtime readiness=${state}${staleReason}${status.runtimeReadiness.nextAction ? `;下一步:${status.runtimeReadiness.nextAction}` : ""}`);
304
+ }
275
305
  if (status?.runtimeReadiness?.executable)
276
306
  say(` runtime executable=${status.runtimeReadiness.executable}${status.runtimeReadiness.version ? ` version=${status.runtimeReadiness.version}` : ""}`);
277
307
  say(` bot=${bot ? `${bot.name}(${bot.open_id})` : "(未连接过,无身份缓存)"}`);
@@ -405,6 +435,43 @@ if (!key) {
405
435
  }
406
436
  const selectedKey = key;
407
437
  const agent = config.agents[selectedKey];
438
+ if (kind === "pi-distribution") {
439
+ traceProcessBoundary(process.env, "agent-config:pi-distribution-loaded", { configDir, agentId: selectedKey, targetDir: piAgentDirectory(configDir, selectedKey), requested: value || "show", importExternalProfile });
440
+ const requested = value || "show";
441
+ if (importExternalProfile && requested !== "builtin")
442
+ die("--import-external-profile 只允许与 builtin 一起使用");
443
+ if (requested === "show") {
444
+ say(JSON.stringify({ agentId: selectedKey, runtime: agent.runtime, piDistribution: agent.piDistribution ?? "external" }));
445
+ process.exit(0);
446
+ }
447
+ if (requested !== "builtin" && requested !== "external")
448
+ die("Pi distribution 只允许 show/builtin/external");
449
+ if (agent.runtime !== "pi")
450
+ die(`Agent ${selectedKey} 不是 Pi runtime`);
451
+ if (!snapshotFile)
452
+ die("修改 Pi distribution 必须提供 --snapshot <private-file>");
453
+ if (requested === "builtin" && !importExternalProfile) {
454
+ try {
455
+ assertBuiltinPiAgentDirectory(piAgentDirectory(configDir, selectedKey));
456
+ }
457
+ catch {
458
+ die("内置 Pi provider 尚未为该 Agent 配置;请先运行 larkin setup 完成内置 Pi provider 配置,或使用 --import-external-profile");
459
+ }
460
+ }
461
+ try {
462
+ const result = larkinConfig.mutateConfig(process.env, { kind: "set-agent-pi-distribution", agentId: selectedKey, distribution: requested }, { kind: "user" }, {
463
+ snapshotFile: snapshotFile, ...(importExternalProfile ? { importExternalProfile: true } : {}),
464
+ });
465
+ traceProcessBoundary(process.env, "agent-config:pi-distribution-persisted", { configDir, agentId: selectedKey, targetDir: piAgentDirectory(configDir, selectedKey), requested, applyState: result.applyState });
466
+ say(JSON.stringify({ ok: true, agentId: selectedKey, piDistribution: requested, revision: result.revision, applyState: result.applyState }));
467
+ }
468
+ catch (error) {
469
+ if (importExternalProfile)
470
+ die("Pi external profile import failed; no secret or private path was disclosed");
471
+ die(`Pi distribution 修改失败:${error instanceof Error ? error.message : String(error)}`);
472
+ }
473
+ process.exit(0);
474
+ }
408
475
  const catalog = larkinConfig.loadRuntimeModels();
409
476
  if ((["model", "effort"].includes(kind || "") && agent.runtime === "codex") || (kind === "runtime" && value === "codex")) {
410
477
  try {
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isRuntimeReadinessCurrent = isRuntimeReadinessCurrent;
4
+ exports.isCurrentOwnedDaemon = isCurrentOwnedDaemon;
3
5
  exports.isChannelReconnecting = isChannelReconnecting;
4
6
  exports.projectAgentReadiness = projectAgentReadiness;
5
7
  const process_state_js_1 = require("../platform/process-state.cjs");
@@ -7,6 +9,19 @@ function timestamp(value) {
7
9
  const parsed = Date.parse(String(value || ""));
8
10
  return Number.isFinite(parsed) ? parsed : null;
9
11
  }
12
+ function isRuntimeReadinessCurrent(readiness, daemonStartedAt) {
13
+ if (readiness?.state !== "ready")
14
+ return false;
15
+ const observedAt = timestamp(readiness.observedAt);
16
+ const epoch = timestamp(daemonStartedAt);
17
+ return observedAt !== null && epoch !== null && observedAt >= epoch;
18
+ }
19
+ function isCurrentOwnedDaemon(daemon) {
20
+ return daemon?.state === "owned"
21
+ && Number(daemon.pid) > 0
22
+ && typeof daemon.processStartToken === "string" && daemon.processStartToken.length > 0
23
+ && timestamp(daemon.startedAt) !== null;
24
+ }
10
25
  function isChannelReconnecting(status) {
11
26
  const reconnectingAt = timestamp(status?.reconnectingAt);
12
27
  const connectedAt = timestamp(status?.connectedAt);
@@ -16,9 +31,14 @@ function isChannelReconnecting(status) {
16
31
  }
17
32
  function projectAgentReadiness(input) {
18
33
  const status = input.status ?? null;
19
- const daemonOwned = (0, process_state_js_1.daemonHasAgent)(input.daemon, input.agentId);
34
+ const daemonOwned = isCurrentOwnedDaemon(input.daemon) && (0, process_state_js_1.daemonHasAgent)(input.daemon, input.agentId);
20
35
  const daemonStartedAt = timestamp(input.daemon.startedAt);
21
36
  const connectedAt = timestamp(status?.connectedAt);
37
+ // A resumed session deliberately retains its original creation time. Its
38
+ // current daemon-epoch proof is the fresh session observation, not that
39
+ // historic creation timestamp.
40
+ const sessionObservedAt = timestamp(status?.session?.lastSeenAt) ?? timestamp(status?.session?.startedAt);
41
+ const sessionCurrent = daemonStartedAt !== null && sessionObservedAt !== null && sessionObservedAt >= daemonStartedAt;
22
42
  const channelConnected = daemonOwned
23
43
  && status?.connectedVia === "channel"
24
44
  && daemonStartedAt !== null
@@ -26,7 +46,7 @@ function projectAgentReadiness(input) {
26
46
  && connectedAt >= daemonStartedAt;
27
47
  const readiness = {
28
48
  daemon_owned: daemonOwned,
29
- runtime_ready: status?.runtimeReadiness?.state === "ready",
49
+ runtime_ready: daemonOwned && sessionCurrent && isRuntimeReadinessCurrent(status?.runtimeReadiness, input.daemon.startedAt),
30
50
  channel_connected: channelConnected,
31
51
  channel_not_reconnecting: !isChannelReconnecting(status),
32
52
  };
@@ -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>]
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,
@@ -110,7 +115,14 @@ export async function main(env = process.env, overrides = {}) {
110
115
  },
111
116
  });
112
117
  await controlServer.start();
113
- 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);
114
126
  }
115
127
  if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) {
116
128
  main().catch((error) => { console.error(`✗ ${error instanceof Error ? error.message : String(error)}`); process.exit(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 {
@@ -200,6 +200,11 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
200
200
  return store;
201
201
  };
202
202
  const hostState = new host_business_state_js_1.HostStateProjection(stateStore, log);
203
+ const markRuntimeTransition = (agent, reason) => {
204
+ hostState.updateStatus(agent, {
205
+ runtimeReadiness: { runtime: agent.runtime, state: "unavailable", reason, nextAction: "Wait for the current daemon epoch to publish Runtime readiness.", observedAt: new Date().toISOString() },
206
+ });
207
+ };
203
208
  const recordInboundDeliveryFailure = (agent, code) => {
204
209
  const at = new Date().toISOString();
205
210
  const reason = "Inbound Runtime delivery failed non-retryably; durable Inbox/ledger state is retained for recovery.";
@@ -1231,19 +1236,21 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1231
1236
  return;
1232
1237
  if (message.type === "agent-status") {
1233
1238
  log("agent:status", message.agentId, message.status);
1239
+ const observedAt = new Date().toISOString();
1234
1240
  if (message.status === "error" || message.status === "inactive")
1235
1241
  hostState.updateStatus(agent, {
1236
- runtimeReadiness: message.readiness?.state && message.readiness.state !== "ready" ? message.readiness : {
1242
+ runtimeReadiness: message.readiness?.state && message.readiness.state !== "ready" ? { ...message.readiness, observedAt } : {
1237
1243
  runtime: agent.runtime,
1238
1244
  state: message.status === "error" ? "incompatible" : "missing",
1239
1245
  reason: message.status === "error" ? message.error || "Runtime entered an error state" : "Runtime is inactive",
1246
+ observedAt,
1240
1247
  },
1241
1248
  });
1242
1249
  else if (message.readiness)
1243
- hostState.updateStatus(agent, { runtimeReadiness: message.readiness });
1250
+ hostState.updateStatus(agent, { runtimeReadiness: { ...message.readiness, observedAt } });
1244
1251
  else if (message.status === "active")
1245
1252
  hostState.updateStatus(agent, { runtimeReadiness: {
1246
- runtime: agent.runtime, state: "ready",
1253
+ runtime: agent.runtime, state: "ready", observedAt,
1247
1254
  } });
1248
1255
  if (message.status === "active") {
1249
1256
  const redeliveryTimer = setTimeout(() => {
@@ -1375,8 +1382,10 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1375
1382
  const status = hostState.readStatus(agent);
1376
1383
  const turns = status.session && typeof status.session === "object"
1377
1384
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1378
- const runtimeReady = Boolean(status.runtimeReadiness && typeof status.runtimeReadiness === "object"
1379
- && status.runtimeReadiness.state === "ready");
1385
+ const sessionStartedAt = status.session && typeof status.session === "object"
1386
+ ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1387
+ const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1388
+ const runtimeReady = sessionCurrent && (0, agent_readiness_js_1.isRuntimeReadinessCurrent)(status.runtimeReadiness, daemonStartedAt);
1380
1389
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1381
1390
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
1382
1391
  };
@@ -1385,6 +1394,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1385
1394
  throw Object.assign(new Error(`Agent ${agentId} channel is not connected`), { code: "channel_unavailable", ...initialProjection });
1386
1395
  if (initialProjection.reconnecting)
1387
1396
  throw Object.assign(new Error(`Agent ${agentId} channel is reconnecting`), { code: "channel_reconnecting", ...initialProjection });
1397
+ markRuntimeTransition(agent, "Runtime session reset in progress");
1388
1398
  let reset;
1389
1399
  try {
1390
1400
  reset = await runtimeHost.resetSession(agentId);
@@ -1401,11 +1411,17 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1401
1411
  delete record.state.sessions[agent.runtime];
1402
1412
  record.store.writeJson("agentState", record.state);
1403
1413
  }
1404
- if (!reset.sessionId)
1414
+ if (!reset.sessionId) {
1415
+ const observedAt = new Date().toISOString();
1405
1416
  hostState.updateStatus(agent, {
1406
- session: { runtime: agent.runtime, id: null, launchId: null, startedAt: new Date().toISOString(),
1407
- lastSeenAt: null, lastTurnAt: null, turns: 0 }, runtimeReadiness: { runtime: agent.runtime, state: "ready" },
1417
+ session: { runtime: agent.runtime, id: null, launchId: null, startedAt: observedAt,
1418
+ lastSeenAt: null, lastTurnAt: null, turns: 0 },
1419
+ runtimeReadiness: { runtime: agent.runtime, state: "ready", observedAt },
1408
1420
  });
1421
+ }
1422
+ else if (reset.runtimeReady) {
1423
+ hostState.updateStatus(agent, { runtimeReadiness: { runtime: agent.runtime, state: "ready", observedAt: new Date().toISOString() } });
1424
+ }
1409
1425
  }
1410
1426
  catch (error) {
1411
1427
  const projection = readinessProjection();
@@ -1452,8 +1468,10 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1452
1468
  const status = hostState.readStatus(agent);
1453
1469
  const turns = status.session && typeof status.session === "object"
1454
1470
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1455
- const runtimeReady = Boolean(status.runtimeReadiness && typeof status.runtimeReadiness === "object"
1456
- && status.runtimeReadiness.state === "ready");
1471
+ const sessionStartedAt = status.session && typeof status.session === "object"
1472
+ ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1473
+ const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1474
+ const runtimeReady = sessionCurrent && (0, agent_readiness_js_1.isRuntimeReadinessCurrent)(status.runtimeReadiness, daemonStartedAt);
1457
1475
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1458
1476
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
1459
1477
  };
@@ -1469,6 +1487,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1469
1487
  catch (error) {
1470
1488
  throw Object.assign(error instanceof Error ? error : new Error(String(error)), readinessProjection());
1471
1489
  }
1490
+ if (recovery.runtimeReady) {
1491
+ hostState.updateStatus(agent, { runtimeReadiness: { runtime: agent.runtime, state: "ready", observedAt: new Date().toISOString() } });
1492
+ }
1472
1493
  const record = agentStates.get(agentId);
1473
1494
  try {
1474
1495
  if (record) {
@@ -1506,6 +1527,8 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1506
1527
  async start() {
1507
1528
  try {
1508
1529
  node_fs_1.default.mkdirSync(larkinHome, { recursive: true });
1530
+ for (const agent of agents)
1531
+ markRuntimeTransition(agent, "Current daemon epoch starting");
1509
1532
  node_fs_1.default.writeFileSync(node_path_1.default.join(larkinHome, "daemon-status.json"), JSON.stringify({
1510
1533
  ...(0, process_inspect_cjs_1.currentProcessMetadata)((0, internal_command_js_1.processCommandToken)("daemon", "app/runtime-process.mjs")), pid: process.pid, startedAt: daemonStartedAt,
1511
1534
  agents: agents.map((agent) => agent.agentId),
@@ -19,7 +19,7 @@ import { readDocumentCommentSubscription, verifyCallbackProbe } from "../platfor
19
19
  import { loadConfig, resolveMentionPolicy } from "../platform/config.mjs";
20
20
  import { processCommandToken } from "../app/internal-command.mjs";
21
21
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
22
- import { isChannelReconnecting } from "../app/agent-readiness.mjs";
22
+ import { isChannelReconnecting, isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
23
23
  import { documentCommentMessageId, documentCommentNoticeType, projectDocumentCommentEnvelope, resolveDocumentCommentContext, } from "./document-comment.mjs";
24
24
  const PENDING_DOCUMENT_COMMENT_LIMIT = 256;
25
25
  function isRecord(value) {
@@ -160,6 +160,11 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
160
160
  return store;
161
161
  };
162
162
  const hostState = new HostStateProjection(stateStore, log);
163
+ const markRuntimeTransition = (agent, reason) => {
164
+ hostState.updateStatus(agent, {
165
+ runtimeReadiness: { runtime: agent.runtime, state: "unavailable", reason, nextAction: "Wait for the current daemon epoch to publish Runtime readiness.", observedAt: new Date().toISOString() },
166
+ });
167
+ };
163
168
  const recordInboundDeliveryFailure = (agent, code) => {
164
169
  const at = new Date().toISOString();
165
170
  const reason = "Inbound Runtime delivery failed non-retryably; durable Inbox/ledger state is retained for recovery.";
@@ -1191,19 +1196,21 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1191
1196
  return;
1192
1197
  if (message.type === "agent-status") {
1193
1198
  log("agent:status", message.agentId, message.status);
1199
+ const observedAt = new Date().toISOString();
1194
1200
  if (message.status === "error" || message.status === "inactive")
1195
1201
  hostState.updateStatus(agent, {
1196
- runtimeReadiness: message.readiness?.state && message.readiness.state !== "ready" ? message.readiness : {
1202
+ runtimeReadiness: message.readiness?.state && message.readiness.state !== "ready" ? { ...message.readiness, observedAt } : {
1197
1203
  runtime: agent.runtime,
1198
1204
  state: message.status === "error" ? "incompatible" : "missing",
1199
1205
  reason: message.status === "error" ? message.error || "Runtime entered an error state" : "Runtime is inactive",
1206
+ observedAt,
1200
1207
  },
1201
1208
  });
1202
1209
  else if (message.readiness)
1203
- hostState.updateStatus(agent, { runtimeReadiness: message.readiness });
1210
+ hostState.updateStatus(agent, { runtimeReadiness: { ...message.readiness, observedAt } });
1204
1211
  else if (message.status === "active")
1205
1212
  hostState.updateStatus(agent, { runtimeReadiness: {
1206
- runtime: agent.runtime, state: "ready",
1213
+ runtime: agent.runtime, state: "ready", observedAt,
1207
1214
  } });
1208
1215
  if (message.status === "active") {
1209
1216
  const redeliveryTimer = setTimeout(() => {
@@ -1335,8 +1342,10 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1335
1342
  const status = hostState.readStatus(agent);
1336
1343
  const turns = status.session && typeof status.session === "object"
1337
1344
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1338
- const runtimeReady = Boolean(status.runtimeReadiness && typeof status.runtimeReadiness === "object"
1339
- && status.runtimeReadiness.state === "ready");
1345
+ const sessionStartedAt = status.session && typeof status.session === "object"
1346
+ ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1347
+ const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1348
+ const runtimeReady = sessionCurrent && isRuntimeReadinessCurrent(status.runtimeReadiness, daemonStartedAt);
1340
1349
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1341
1350
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
1342
1351
  };
@@ -1345,6 +1354,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1345
1354
  throw Object.assign(new Error(`Agent ${agentId} channel is not connected`), { code: "channel_unavailable", ...initialProjection });
1346
1355
  if (initialProjection.reconnecting)
1347
1356
  throw Object.assign(new Error(`Agent ${agentId} channel is reconnecting`), { code: "channel_reconnecting", ...initialProjection });
1357
+ markRuntimeTransition(agent, "Runtime session reset in progress");
1348
1358
  let reset;
1349
1359
  try {
1350
1360
  reset = await runtimeHost.resetSession(agentId);
@@ -1361,11 +1371,17 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1361
1371
  delete record.state.sessions[agent.runtime];
1362
1372
  record.store.writeJson("agentState", record.state);
1363
1373
  }
1364
- if (!reset.sessionId)
1374
+ if (!reset.sessionId) {
1375
+ const observedAt = new Date().toISOString();
1365
1376
  hostState.updateStatus(agent, {
1366
- session: { runtime: agent.runtime, id: null, launchId: null, startedAt: new Date().toISOString(),
1367
- lastSeenAt: null, lastTurnAt: null, turns: 0 }, runtimeReadiness: { runtime: agent.runtime, state: "ready" },
1377
+ session: { runtime: agent.runtime, id: null, launchId: null, startedAt: observedAt,
1378
+ lastSeenAt: null, lastTurnAt: null, turns: 0 },
1379
+ runtimeReadiness: { runtime: agent.runtime, state: "ready", observedAt },
1368
1380
  });
1381
+ }
1382
+ else if (reset.runtimeReady) {
1383
+ hostState.updateStatus(agent, { runtimeReadiness: { runtime: agent.runtime, state: "ready", observedAt: new Date().toISOString() } });
1384
+ }
1369
1385
  }
1370
1386
  catch (error) {
1371
1387
  const projection = readinessProjection();
@@ -1412,8 +1428,10 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1412
1428
  const status = hostState.readStatus(agent);
1413
1429
  const turns = status.session && typeof status.session === "object"
1414
1430
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1415
- const runtimeReady = Boolean(status.runtimeReadiness && typeof status.runtimeReadiness === "object"
1416
- && status.runtimeReadiness.state === "ready");
1431
+ const sessionStartedAt = status.session && typeof status.session === "object"
1432
+ ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1433
+ const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1434
+ const runtimeReady = sessionCurrent && isRuntimeReadinessCurrent(status.runtimeReadiness, daemonStartedAt);
1417
1435
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1418
1436
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
1419
1437
  };
@@ -1429,6 +1447,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1429
1447
  catch (error) {
1430
1448
  throw Object.assign(error instanceof Error ? error : new Error(String(error)), readinessProjection());
1431
1449
  }
1450
+ if (recovery.runtimeReady) {
1451
+ hostState.updateStatus(agent, { runtimeReadiness: { runtime: agent.runtime, state: "ready", observedAt: new Date().toISOString() } });
1452
+ }
1432
1453
  const record = agentStates.get(agentId);
1433
1454
  try {
1434
1455
  if (record) {
@@ -1466,6 +1487,8 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1466
1487
  async start() {
1467
1488
  try {
1468
1489
  fs.mkdirSync(larkinHome, { recursive: true });
1490
+ for (const agent of agents)
1491
+ markRuntimeTransition(agent, "Current daemon epoch starting");
1469
1492
  fs.writeFileSync(path.join(larkinHome, "daemon-status.json"), JSON.stringify({
1470
1493
  ...currentProcessMetadata(processCommandToken("daemon", "app/runtime-process.mjs")), pid: process.pid, startedAt: daemonStartedAt,
1471
1494
  agents: agents.map((agent) => agent.agentId),