larkin 0.2.70
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.
- package/CONTRIBUTING.md +48 -0
- package/LICENSE +201 -0
- package/README.md +122 -0
- package/SECURITY.md +14 -0
- package/artifacts/release/THIRD_PARTY_NOTICES.txt +5488 -0
- package/dist/agent/agent-cli-capabilities.cjs +43 -0
- package/dist/agent/agent-cli-capabilities.mjs +35 -0
- package/dist/agent/agent-state-store.cjs +961 -0
- package/dist/agent/agent-state-store.mjs +923 -0
- package/dist/agent/agent-transport.cjs +201 -0
- package/dist/agent/agent-transport.mjs +194 -0
- package/dist/agent/config-cli-contract.cjs +19 -0
- package/dist/agent/config-cli-contract.mjs +16 -0
- package/dist/agent/context-prompt.cjs +159 -0
- package/dist/agent/context-prompt.mjs +155 -0
- package/dist/agent/freshness-gate.mjs +24 -0
- package/dist/agent/history-projection.cjs +72 -0
- package/dist/agent/history-projection.mjs +65 -0
- package/dist/agent/host-reminder-orchestrator.cjs +289 -0
- package/dist/agent/host-reminder-orchestrator.mjs +252 -0
- package/dist/agent/inbox-projection.cjs +92 -0
- package/dist/agent/inbox-projection.mjs +85 -0
- package/dist/agent/interaction-state-machine.cjs +710 -0
- package/dist/agent/interaction-state-machine.mjs +705 -0
- package/dist/agent/reminder-routes.cjs +192 -0
- package/dist/agent/reminder-routes.mjs +156 -0
- package/dist/agent/reminder-store.cjs +220 -0
- package/dist/agent/reminder-store.mjs +205 -0
- package/dist/agent/transport-business-context.cjs +486 -0
- package/dist/agent/transport-business-context.mjs +446 -0
- package/dist/agent/transport-shell.cjs +45 -0
- package/dist/agent/transport-shell.mjs +39 -0
- package/dist/app/agent-cli.mjs +596 -0
- package/dist/app/agent-config.mjs +633 -0
- package/dist/app/agent-lark-cli-workspace.cjs +95 -0
- package/dist/app/agent-lark-cli-workspace.mjs +85 -0
- package/dist/app/agent-readiness.cjs +42 -0
- package/dist/app/agent-readiness.mjs +38 -0
- package/dist/app/binary-entry.mjs +86 -0
- package/dist/app/cli.mjs +176 -0
- package/dist/app/dashboard.mjs +205 -0
- package/dist/app/internal-command.cjs +57 -0
- package/dist/app/internal-command.mjs +48 -0
- package/dist/app/lark-channel-secret.mjs +22 -0
- package/dist/app/lark-cli.mjs +887 -0
- package/dist/app/lark.mjs +45 -0
- package/dist/app/local-control.mjs +728 -0
- package/dist/app/official-lark-cli.cjs +138 -0
- package/dist/app/official-lark-cli.mjs +128 -0
- package/dist/app/pi-auth-cli.mjs +67 -0
- package/dist/app/run.mjs +364 -0
- package/dist/app/runtime-agent-config.mjs +416 -0
- package/dist/app/runtime-model-directory.mjs +78 -0
- package/dist/app/runtime-process.mjs +113 -0
- package/dist/app/session-cli.mjs +83 -0
- package/dist/app/setup-dashboard.mjs +26 -0
- package/dist/app/setup.mjs +166 -0
- package/dist/app/telemetry.mjs +83 -0
- package/dist/dashboard/dashboard-assets.mjs +21 -0
- package/dist/dashboard/dashboard-avatar.mjs +128 -0
- package/dist/dashboard/dashboard-config-controller.mjs +525 -0
- package/dist/dashboard/dashboard-lifecycle.cjs +97 -0
- package/dist/dashboard/dashboard-lifecycle.mjs +88 -0
- package/dist/dashboard/dashboard-template.mjs +29 -0
- package/dist/dashboard/dashboard-view-model.mjs +556 -0
- package/dist/dashboard/dashboard-workspace.mjs +104 -0
- package/dist/dashboard/web/assets/dashboard.css +3 -0
- package/dist/dashboard/web/assets/dashboard.js +49 -0
- package/dist/feishu/channel-transport.cjs +151 -0
- package/dist/feishu/channel-transport.mjs +148 -0
- package/dist/feishu/document-comment.cjs +120 -0
- package/dist/feishu/document-comment.mjs +109 -0
- package/dist/feishu/host-business-state.cjs +477 -0
- package/dist/feishu/host-business-state.mjs +462 -0
- package/dist/feishu/host-channel-business.cjs +161 -0
- package/dist/feishu/host-channel-business.mjs +157 -0
- package/dist/feishu/host-processing-eye.cjs +185 -0
- package/dist/feishu/host-processing-eye.mjs +181 -0
- package/dist/feishu/host-shell.cjs +1425 -0
- package/dist/feishu/host-shell.mjs +1385 -0
- package/dist/feishu/im-freshness-adapter.mjs +89 -0
- package/dist/feishu/interaction-orchestrator.cjs +279 -0
- package/dist/feishu/interaction-orchestrator.mjs +242 -0
- package/dist/feishu/lark-passthrough.mjs +70 -0
- package/dist/feishu/message-policy.cjs +111 -0
- package/dist/feishu/message-policy.mjs +98 -0
- package/dist/feishu/outbound-transport.cjs +221 -0
- package/dist/feishu/outbound-transport.mjs +215 -0
- package/dist/platform/build-info.cjs +25 -0
- package/dist/platform/build-info.mjs +18 -0
- package/dist/platform/callback-capability.cjs +301 -0
- package/dist/platform/callback-capability.mjs +252 -0
- package/dist/platform/check-bun-version.cjs +12 -0
- package/dist/platform/config.cjs +751 -0
- package/dist/platform/config.mjs +694 -0
- package/dist/platform/process-inspect.cjs +89 -0
- package/dist/platform/process-state.cjs +237 -0
- package/dist/platform/process-state.mjs +223 -0
- package/dist/platform/release-artifacts.mjs +61 -0
- package/dist/platform/root-layout.cjs +111 -0
- package/dist/platform/root-layout.mjs +72 -0
- package/dist/platform/telemetry-config.cjs +64 -0
- package/dist/platform/telemetry-config.mjs +56 -0
- package/dist/platform/telemetry-spool.cjs +661 -0
- package/dist/platform/telemetry-spool.mjs +653 -0
- package/dist/platform/telemetry-tracing.cjs +602 -0
- package/dist/platform/telemetry-tracing.mjs +596 -0
- package/dist/platform/telemetry-uploader.cjs +150 -0
- package/dist/platform/telemetry-uploader.mjs +146 -0
- package/dist/platform/workspace-service.cjs +469 -0
- package/dist/platform/workspace-service.mjs +430 -0
- package/dist/runtime/builtin-pi-assets.mjs +33 -0
- package/dist/runtime/claude-model-catalog.cjs +102 -0
- package/dist/runtime/claude-model-catalog.mjs +99 -0
- package/dist/runtime/codex-model-catalog.cjs +108 -0
- package/dist/runtime/codex-model-catalog.mjs +105 -0
- package/dist/runtime/pi-bash-timeout-extension.mjs +60 -0
- package/dist/runtime/pi-bash-timeout-injection.mjs +60 -0
- package/dist/runtime/pi-bash-timeout.bundle.js +43 -0
- package/dist/runtime/pi-model-catalog.cjs +116 -0
- package/dist/runtime/pi-model-catalog.mjs +75 -0
- package/dist/runtime/pi-official-auth.mjs +480 -0
- package/dist/runtime/pi-provider-config.cjs +252 -0
- package/dist/runtime/pi-provider-config.mjs +239 -0
- package/dist/runtime/pi-rpc-client.cjs +255 -0
- package/dist/runtime/pi-rpc-client.mjs +251 -0
- package/dist/runtime/pi-subagent-injection.mjs +129 -0
- package/dist/runtime/pi-subagents.bundle.js +8990 -0
- package/dist/runtime/runtime-adapters.mjs +1019 -0
- package/dist/runtime/runtime-contracts.cjs +2 -0
- package/dist/runtime/runtime-contracts.mjs +1 -0
- package/dist/runtime/runtime-host.cjs +930 -0
- package/dist/runtime/runtime-host.mjs +922 -0
- package/dist/runtime/runtime-model-catalog.cjs +43 -0
- package/dist/runtime/runtime-model-catalog.mjs +40 -0
- package/dist/runtime/runtime-readiness.cjs +177 -0
- package/dist/runtime/runtime-readiness.mjs +133 -0
- package/dist/setup/bot-register.mjs +655 -0
- package/dist/setup/grant-scopes.mjs +133 -0
- package/dist/setup/run-credential-preflight.mjs +89 -0
- package/dist/setup/setup-agent-choice.mjs +172 -0
- package/dist/setup/setup-bind.mjs +337 -0
- package/dist/setup/setup-binding.mjs +52 -0
- package/package.json +112 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { acquireProcessLock, readProcessState, terminateOwnedProcess, waitForProcessExit, } from "../platform/process-state.mjs";
|
|
5
|
+
import { processCommandToken } from "../app/internal-command.mjs";
|
|
6
|
+
const PUBLISHED_RUNTIME_DIRS = ["dist", "assets"];
|
|
7
|
+
const RUNTIME_EXTENSIONS = new Set([".cjs", ".mjs", ".js", ".css", ".json", ".node", ".svg", ".wasm"]);
|
|
8
|
+
function publishedRuntimeFiles(root) {
|
|
9
|
+
const files = [];
|
|
10
|
+
const visit = (directory) => {
|
|
11
|
+
let entries;
|
|
12
|
+
try {
|
|
13
|
+
entries = fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (entry.name.startsWith(".") || entry.name.endsWith(".tmp") || entry.name.endsWith(".tgz"))
|
|
20
|
+
continue;
|
|
21
|
+
const absolute = path.join(directory, entry.name);
|
|
22
|
+
if (entry.isDirectory())
|
|
23
|
+
visit(absolute);
|
|
24
|
+
else if (entry.isFile() && RUNTIME_EXTENSIONS.has(path.extname(entry.name)))
|
|
25
|
+
files.push(path.relative(root, absolute));
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
for (const directory of PUBLISHED_RUNTIME_DIRS)
|
|
29
|
+
visit(path.join(root, directory));
|
|
30
|
+
return files.sort((a, b) => a.localeCompare(b, "en"));
|
|
31
|
+
}
|
|
32
|
+
/** Same semver can contain a different local build, so hash every published shell runtime byte. */
|
|
33
|
+
export function dashboardBuildFingerprint(root) {
|
|
34
|
+
const hash = crypto.createHash("sha256");
|
|
35
|
+
for (const relative of publishedRuntimeFiles(root)) {
|
|
36
|
+
hash.update(relative);
|
|
37
|
+
hash.update("\0");
|
|
38
|
+
hash.update(fs.readFileSync(path.join(root, relative)));
|
|
39
|
+
hash.update("\0");
|
|
40
|
+
}
|
|
41
|
+
return `sha256:${hash.digest("hex")}`;
|
|
42
|
+
}
|
|
43
|
+
export function dashboardReuseDecision(record, currentFingerprint) {
|
|
44
|
+
if (record.state === "dead")
|
|
45
|
+
return { action: "start", reason: "dashboard not running" };
|
|
46
|
+
if (record.state === "unknown")
|
|
47
|
+
return { action: "refuse", reason: "dashboard process ownership unknown" };
|
|
48
|
+
if (record.state === "mismatch")
|
|
49
|
+
return { action: "refuse", reason: "dashboard process ownership mismatch" };
|
|
50
|
+
if (record.buildFingerprint === currentFingerprint)
|
|
51
|
+
return { action: "reuse", reason: "owned dashboard build matches" };
|
|
52
|
+
return {
|
|
53
|
+
action: "replace",
|
|
54
|
+
reason: typeof record.buildFingerprint === "string"
|
|
55
|
+
? "owned dashboard build fingerprint differs"
|
|
56
|
+
: "owned dashboard has no build fingerprint",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export async function reconcileDashboardRecord(record, currentFingerprint, dependencies = {}) {
|
|
60
|
+
const decision = dashboardReuseDecision(record, currentFingerprint);
|
|
61
|
+
if (decision.action === "refuse") {
|
|
62
|
+
throw new Error(`dashboard PID ${record.pid ?? "?"} ${decision.reason}(${record.reason || "no detail"});拒绝复用、替换或并行启动`);
|
|
63
|
+
}
|
|
64
|
+
if (decision.action !== "replace")
|
|
65
|
+
return decision;
|
|
66
|
+
const terminate = dependencies.terminate || ((owned) => terminateOwnedProcess(owned, "SIGTERM"));
|
|
67
|
+
const wait = dependencies.wait || ((owned) => waitForProcessExit(owned));
|
|
68
|
+
terminate(record);
|
|
69
|
+
if (!await wait(record))
|
|
70
|
+
throw new Error(`旧 dashboard(pid ${record.pid})10 秒内未退出;拒绝并行启动`);
|
|
71
|
+
return { action: "start", reason: decision.reason };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Serialize inspect/replace/start decisions. The caller keeps the lock through listen(),
|
|
75
|
+
* preventing two internal dashboard children from both passing the first-run gap.
|
|
76
|
+
*/
|
|
77
|
+
export async function prepareDashboardLaunch(larkinHome, currentFingerprint, lockCommandToken = processCommandToken("dashboard", "app/dashboard.mjs")) {
|
|
78
|
+
const lock = acquireProcessLock(path.join(larkinHome, "dashboard-launch.lock.json"), lockCommandToken);
|
|
79
|
+
try {
|
|
80
|
+
const record = readProcessState(larkinHome).dashboard;
|
|
81
|
+
const result = await reconcileDashboardRecord(record, currentFingerprint);
|
|
82
|
+
return { action: result.action, reason: result.reason, record, release: lock.release };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
lock.release();
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Dashboard HTML shell only. Product UI lives in src/dashboard/web and is built by Vite;
|
|
2
|
+
// this template injects per-process public bootstrap values without duplicating frontend state.
|
|
3
|
+
function safeJson(value) {
|
|
4
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
5
|
+
}
|
|
6
|
+
export function renderDashboardHtml(packageVersion, dashboardVersion = packageVersion, buildFingerprint = null, csrfCapability = null) {
|
|
7
|
+
const bootstrap = safeJson({
|
|
8
|
+
packageVersion: String(packageVersion || "dev"),
|
|
9
|
+
dashboardVersion: String(dashboardVersion || packageVersion || "dev"),
|
|
10
|
+
buildFingerprint: String(buildFingerprint || ""),
|
|
11
|
+
csrfCapability: String(csrfCapability || ""),
|
|
12
|
+
});
|
|
13
|
+
return `<!doctype html>
|
|
14
|
+
<html lang="zh-CN">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
18
|
+
<meta name="theme-color" content="#A3402C">
|
|
19
|
+
<title>LARKIN · Dashboard</title>
|
|
20
|
+
<link rel="icon" href="/assets/larkin-mark.svg" type="image/svg+xml">
|
|
21
|
+
<link rel="stylesheet" href="/dashboard-assets/dashboard.css">
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<div id="root"><p style="padding:24px;font-family:system-ui">Loading Larkin Agent workbench…</p></div>
|
|
25
|
+
<script>window.__LARKIN_DASHBOARD__=${bootstrap};</script>
|
|
26
|
+
<script type="module" src="/dashboard-assets/dashboard.js"></script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// dashboard 状态投影层:只读本地状态文件(config.json + 各 agent 的 status.json / workspace /
|
|
2
|
+
// reminders.json 等),不打飞书 API,产出 /api/status 与 /api/workspace 的 view model。
|
|
3
|
+
// HTTP server 与页面模板分别在 dashboard.ts / dashboard-template.ts。
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { StringDecoder } from "node:string_decoder";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { readJson, readProcessState } from "../platform/process-state.mjs";
|
|
10
|
+
import { isAllowedDashboardAvatarUrl } from "./dashboard-avatar.mjs";
|
|
11
|
+
import { collectWorkspaceEntry as collectTypedWorkspaceEntry } from "./dashboard-workspace.mjs";
|
|
12
|
+
import { buildFingerprint, packageVersion } from "../platform/build-info.mjs";
|
|
13
|
+
import * as larkinConfig from "../platform/config.mjs";
|
|
14
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const ROOT = path.resolve(HERE, "../..");
|
|
16
|
+
export const PACKAGE_VERSION = packageVersion(ROOT);
|
|
17
|
+
export const DASHBOARD_BUILD_FINGERPRINT = buildFingerprint(ROOT);
|
|
18
|
+
export const DASHBOARD_BUILD_VERSION = `${PACKAGE_VERSION}+${DASHBOARD_BUILD_FINGERPRINT.slice("sha256:".length, "sha256:".length + 12)}`;
|
|
19
|
+
function finiteTime(value) {
|
|
20
|
+
const parsed = Date.parse(String(value || ""));
|
|
21
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
22
|
+
}
|
|
23
|
+
export function projectAgentHealth(agent, daemon) {
|
|
24
|
+
const connectedAt = finiteTime(agent.connectedAt);
|
|
25
|
+
const daemonStartedAt = finiteTime(daemon.startedAt);
|
|
26
|
+
const assigned = daemon.agents.includes(agent.agentId);
|
|
27
|
+
if (daemon.state === "unknown" || daemon.state === "mismatch") {
|
|
28
|
+
return {
|
|
29
|
+
running: false,
|
|
30
|
+
connectedInThisRun: false,
|
|
31
|
+
inboundVerifiedInThisRun: false,
|
|
32
|
+
inboundVerifiedInThisConnection: false,
|
|
33
|
+
connection: { state: "unknown", reason: `daemon ${daemon.state}: ${daemon.reason || "process state unavailable"}` },
|
|
34
|
+
inbound: { state: "unavailable", reason: "channel connection is not currently verifiable" },
|
|
35
|
+
issue: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (daemon.state === "owned" && daemonStartedAt === null) {
|
|
39
|
+
return {
|
|
40
|
+
running: false,
|
|
41
|
+
connectedInThisRun: false,
|
|
42
|
+
inboundVerifiedInThisRun: false,
|
|
43
|
+
inboundVerifiedInThisConnection: false,
|
|
44
|
+
connection: { state: "unknown", reason: "owned daemon has no valid startedAt connection epoch" },
|
|
45
|
+
inbound: { state: "unavailable", reason: "daemon connection epoch is unavailable" },
|
|
46
|
+
issue: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (!daemon.running || !assigned || connectedAt === null || daemonStartedAt === null || connectedAt < daemonStartedAt - 1_000) {
|
|
50
|
+
const reason = !daemon.running
|
|
51
|
+
? `daemon ${daemon.state}: ${daemon.reason || "not running"}`
|
|
52
|
+
: !assigned
|
|
53
|
+
? "Agent is not assigned to the running daemon"
|
|
54
|
+
: "no connection evidence from the current daemon run";
|
|
55
|
+
return {
|
|
56
|
+
running: false,
|
|
57
|
+
connectedInThisRun: false,
|
|
58
|
+
inboundVerifiedInThisRun: false,
|
|
59
|
+
inboundVerifiedInThisConnection: false,
|
|
60
|
+
connection: { state: "disconnected", reason },
|
|
61
|
+
inbound: { state: "unavailable", reason: "channel is disconnected" },
|
|
62
|
+
issue: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const inboundAt = finiteTime(agent.inboundVerifiedAt);
|
|
66
|
+
const verified = inboundAt !== null && inboundAt >= connectedAt;
|
|
67
|
+
return {
|
|
68
|
+
running: true,
|
|
69
|
+
connectedInThisRun: true,
|
|
70
|
+
inboundVerifiedInThisRun: verified,
|
|
71
|
+
inboundVerifiedInThisConnection: verified,
|
|
72
|
+
connection: { state: "connected", reason: "current daemon has current channel connection evidence" },
|
|
73
|
+
inbound: verified
|
|
74
|
+
? { state: "verified", reason: "real inbound observed on the current connection" }
|
|
75
|
+
: { state: "pending", reason: "no real inbound observed on the current connection yet" },
|
|
76
|
+
issue: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function loadDashboardConfig() {
|
|
80
|
+
return larkinConfig.loadConfig(process.env);
|
|
81
|
+
}
|
|
82
|
+
function ageSec(iso) {
|
|
83
|
+
const time = finiteTime(iso);
|
|
84
|
+
return time === null ? null : Math.max(0, Math.round((Date.now() - time) / 1000));
|
|
85
|
+
}
|
|
86
|
+
// 指示灯「卡住」判定:待摘条目存在超过 3 分钟。正常一轮点上→执行→摘除应远快于此;
|
|
87
|
+
// 超过说明摘除路径可能挂了(曾发生:activity 帧丢失、larkApi 静默失败)。阈值宽松,避免误报慢 turn。
|
|
88
|
+
const EYE_STUCK_SEC = 180;
|
|
89
|
+
const SESSION_FILE_MAX_BYTES = 32 * 1024 * 1024;
|
|
90
|
+
const SESSION_FILE_CACHE_TTL_MS = 30_000;
|
|
91
|
+
const SESSION_USAGE_CACHE_TTL_MS = 30_000;
|
|
92
|
+
const SESSION_NDJSON_MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
93
|
+
const SESSION_FILE_CACHE = new Map();
|
|
94
|
+
const SESSION_USAGE_CACHE = new Map();
|
|
95
|
+
function readNdjson(file, maxBytes = SESSION_FILE_MAX_BYTES) {
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(file);
|
|
98
|
+
const readBytes = Math.min(stat.size, maxBytes);
|
|
99
|
+
const fd = fs.openSync(file, "r");
|
|
100
|
+
const buffer = Buffer.alloc(readBytes);
|
|
101
|
+
try {
|
|
102
|
+
fs.readSync(fd, buffer, 0, readBytes, stat.size - readBytes);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
fs.closeSync(fd);
|
|
106
|
+
}
|
|
107
|
+
let text = buffer.toString("utf8");
|
|
108
|
+
if (readBytes < stat.size)
|
|
109
|
+
text = text.slice(text.indexOf("\n") + 1);
|
|
110
|
+
return {
|
|
111
|
+
rows: text.split("\n").filter(Boolean).flatMap((line) => { try {
|
|
112
|
+
return [JSON.parse(line)];
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
} }),
|
|
117
|
+
stat,
|
|
118
|
+
partial: readBytes < stat.size,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { rows: [], stat: null, partial: false };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function firstNdjsonRecord(file) {
|
|
126
|
+
try {
|
|
127
|
+
const fd = fs.openSync(file, "r");
|
|
128
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
129
|
+
let bytes = 0;
|
|
130
|
+
try {
|
|
131
|
+
bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
fs.closeSync(fd);
|
|
135
|
+
}
|
|
136
|
+
const first = buffer.subarray(0, bytes).toString("utf8").split("\n", 1)[0];
|
|
137
|
+
return first ? JSON.parse(first) : null;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function findFileRecursive(root, predicate, budget = 20_000) {
|
|
144
|
+
const stack = [root];
|
|
145
|
+
let seen = 0;
|
|
146
|
+
while (stack.length && seen < budget) {
|
|
147
|
+
const dir = stack.pop();
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
if (++seen >= budget)
|
|
157
|
+
break;
|
|
158
|
+
const full = path.join(dir, entry.name);
|
|
159
|
+
if (entry.isFile() && predicate(entry.name, full))
|
|
160
|
+
return full;
|
|
161
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith("."))
|
|
162
|
+
stack.push(full);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
function sessionFileFor(runtime, sessionId, agent) {
|
|
168
|
+
if (!runtime || !sessionId)
|
|
169
|
+
return null;
|
|
170
|
+
const key = `${runtime}:${sessionId}:${agent.agentId}`;
|
|
171
|
+
const cached = SESSION_FILE_CACHE.get(key);
|
|
172
|
+
if (cached && cached.expiresAt > Date.now() && fs.existsSync(cached.file)
|
|
173
|
+
&& (runtime !== "pi" || firstNdjsonRecord(cached.file)?.id === sessionId))
|
|
174
|
+
return cached.file;
|
|
175
|
+
SESSION_FILE_CACHE.delete(key);
|
|
176
|
+
const home = os.homedir();
|
|
177
|
+
const roots = runtime === "codex"
|
|
178
|
+
? [path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "sessions")]
|
|
179
|
+
: runtime === "claude"
|
|
180
|
+
? [path.join(process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "projects")]
|
|
181
|
+
: runtime === "pi"
|
|
182
|
+
? [path.join(agent.stateDir, "runtime", "pi-sessions")]
|
|
183
|
+
: [];
|
|
184
|
+
const file = roots.map((root) => findFileRecursive(root, (name, candidate) => name.endsWith(".jsonl")
|
|
185
|
+
&& (runtime === "pi" ? firstNdjsonRecord(candidate)?.id === sessionId : name.includes(sessionId)))).find(Boolean) || null;
|
|
186
|
+
if (file) {
|
|
187
|
+
SESSION_FILE_CACHE.set(key, { file, expiresAt: Date.now() + SESSION_FILE_CACHE_TTL_MS });
|
|
188
|
+
if (SESSION_FILE_CACHE.size > 100) {
|
|
189
|
+
const oldestKey = SESSION_FILE_CACHE.keys().next().value;
|
|
190
|
+
if (oldestKey)
|
|
191
|
+
SESSION_FILE_CACHE.delete(oldestKey);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return file;
|
|
195
|
+
}
|
|
196
|
+
function number(value) { return Number.isFinite(Number(value)) ? Number(value) : 0; }
|
|
197
|
+
function compactMoments(values) {
|
|
198
|
+
return values.map((value) => new Date(String(value)).getTime()).filter(Number.isFinite).sort((a, b) => a - b)
|
|
199
|
+
.filter((value, index, all) => index === 0 || value - all[index - 1] > 5000);
|
|
200
|
+
}
|
|
201
|
+
function streamNdjson(file, visit) {
|
|
202
|
+
const fd = fs.openSync(file, "r");
|
|
203
|
+
const decoder = new StringDecoder("utf8");
|
|
204
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
205
|
+
let pending = "";
|
|
206
|
+
let discardingOversizedLine = false;
|
|
207
|
+
let exact = true;
|
|
208
|
+
const consume = (input) => {
|
|
209
|
+
let text = input;
|
|
210
|
+
while (text.length) {
|
|
211
|
+
if (discardingOversizedLine) {
|
|
212
|
+
const newline = text.indexOf("\n");
|
|
213
|
+
if (newline < 0)
|
|
214
|
+
return;
|
|
215
|
+
discardingOversizedLine = false;
|
|
216
|
+
text = text.slice(newline + 1);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const newline = text.indexOf("\n");
|
|
220
|
+
if (newline < 0) {
|
|
221
|
+
pending += text;
|
|
222
|
+
if (Buffer.byteLength(pending) > SESSION_NDJSON_MAX_LINE_BYTES) {
|
|
223
|
+
pending = "";
|
|
224
|
+
discardingOversizedLine = true;
|
|
225
|
+
exact = false;
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const line = pending + text.slice(0, newline);
|
|
230
|
+
pending = "";
|
|
231
|
+
text = text.slice(newline + 1);
|
|
232
|
+
if (!line)
|
|
233
|
+
continue;
|
|
234
|
+
try {
|
|
235
|
+
visit(JSON.parse(line));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
exact = false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
try {
|
|
243
|
+
for (;;) {
|
|
244
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
245
|
+
if (!bytes)
|
|
246
|
+
break;
|
|
247
|
+
consume(decoder.write(buffer.subarray(0, bytes)));
|
|
248
|
+
}
|
|
249
|
+
consume(decoder.end());
|
|
250
|
+
if (pending) {
|
|
251
|
+
try {
|
|
252
|
+
visit(JSON.parse(pending));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
exact = false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (discardingOversizedLine)
|
|
259
|
+
exact = false;
|
|
260
|
+
return { exact };
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
fs.closeSync(fd);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function cacheSessionUsage(key, value) {
|
|
267
|
+
SESSION_USAGE_CACHE.set(key, { value, expiresAt: Date.now() + SESSION_USAGE_CACHE_TTL_MS });
|
|
268
|
+
if (SESSION_USAGE_CACHE.size > 100) {
|
|
269
|
+
const oldestKey = SESSION_USAGE_CACHE.keys().next().value;
|
|
270
|
+
if (oldestKey)
|
|
271
|
+
SESSION_USAGE_CACHE.delete(oldestKey);
|
|
272
|
+
}
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
export function parsePiSessionUsage(file, catalog) {
|
|
276
|
+
const tokenTotal = (usage) => number(usage?.totalTokens)
|
|
277
|
+
|| (number(usage?.input) + number(usage?.cacheRead) + number(usage?.cacheWrite) + number(usage?.output));
|
|
278
|
+
const contextWindows = new Map(catalog.flatMap((model) => {
|
|
279
|
+
const contextWindow = Number(model.contextWindow);
|
|
280
|
+
return model.id && Number.isFinite(contextWindow) && contextWindow > 0 ? [[model.id, contextWindow]] : [];
|
|
281
|
+
}));
|
|
282
|
+
let cumulativeTokens = 0;
|
|
283
|
+
let latestTokens = 0;
|
|
284
|
+
let latestTimestamp;
|
|
285
|
+
let turns = 0;
|
|
286
|
+
let compactCount = 0;
|
|
287
|
+
let lastCompactAt = null;
|
|
288
|
+
let assistantUsageRows = 0;
|
|
289
|
+
let currentModel = null;
|
|
290
|
+
const streamed = streamNdjson(file, (row) => {
|
|
291
|
+
if (row.type === "model_change" && typeof row.provider === "string" && typeof row.modelId === "string") {
|
|
292
|
+
currentModel = `${row.provider}/${row.modelId}`;
|
|
293
|
+
}
|
|
294
|
+
if (row.type === "message" && row.message?.role === "user")
|
|
295
|
+
turns += 1;
|
|
296
|
+
if (row.type === "message" && row.message?.role === "assistant") {
|
|
297
|
+
if (typeof row.message.provider === "string" && typeof row.message.model === "string") {
|
|
298
|
+
currentModel = `${row.message.provider}/${row.message.model}`;
|
|
299
|
+
}
|
|
300
|
+
if (row.message.usage) {
|
|
301
|
+
latestTokens = tokenTotal(row.message.usage);
|
|
302
|
+
cumulativeTokens += latestTokens;
|
|
303
|
+
latestTimestamp = row.timestamp;
|
|
304
|
+
assistantUsageRows += 1;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (row.type === "compaction") {
|
|
308
|
+
compactCount += 1;
|
|
309
|
+
lastCompactAt = row.timestamp || null;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
if (!streamed.exact)
|
|
313
|
+
return {
|
|
314
|
+
available: false, source: "pi", fileFound: true, partial: true,
|
|
315
|
+
reason: "session 记录存在无法解析或超大行,无法精确累计 usage",
|
|
316
|
+
};
|
|
317
|
+
if (!assistantUsageRows)
|
|
318
|
+
return { available: false, source: "pi", fileFound: true, partial: false, reason: "当前 session 尚无可用 usage" };
|
|
319
|
+
const contextWindow = currentModel ? contextWindows.get(currentModel) ?? null : null;
|
|
320
|
+
return {
|
|
321
|
+
available: true, source: "pi", fileFound: true, partial: false,
|
|
322
|
+
cumulativeTokens, latestTokens, contextWindow,
|
|
323
|
+
contextPercent: contextWindow ? Math.min(100, latestTokens / contextWindow * 100) : null,
|
|
324
|
+
turns, compactCount, lastCompactAt,
|
|
325
|
+
updatedAt: latestTimestamp ? new Date(latestTimestamp).toISOString() : null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function collectRuntimeUsage(runtime, sessionId, agent, piCatalog = []) {
|
|
329
|
+
const file = sessionFileFor(runtime, sessionId, agent);
|
|
330
|
+
if (!file)
|
|
331
|
+
return { available: false, source: runtime, reason: "未找到本机 session 记录" };
|
|
332
|
+
let stat;
|
|
333
|
+
try {
|
|
334
|
+
stat = fs.statSync(file);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return { available: false, source: runtime, reason: "本机 session 记录暂不可读" };
|
|
338
|
+
}
|
|
339
|
+
const piCatalogKey = runtime === "pi" ? piCatalog.map((model) => `${model.id}:${String(model.contextWindow ?? "")}`).sort().join(",") : "";
|
|
340
|
+
const cacheKey = `${runtime}:${sessionId}:${file}:${stat.dev}:${stat.ino}:${stat.ctimeMs}:${stat.mtimeMs}:${stat.size}:${piCatalogKey}`;
|
|
341
|
+
const cached = SESSION_USAGE_CACHE.get(cacheKey);
|
|
342
|
+
if (cached && cached.expiresAt > Date.now())
|
|
343
|
+
return cached.value;
|
|
344
|
+
if (cached)
|
|
345
|
+
SESSION_USAGE_CACHE.delete(cacheKey);
|
|
346
|
+
const { rows, partial } = runtime === "pi" ? { rows: [], partial: false } : readNdjson(file);
|
|
347
|
+
const base = { available: true, source: runtime, fileFound: true, partial, startedAt: stat.birthtime.toISOString() };
|
|
348
|
+
let result;
|
|
349
|
+
if (runtime === "codex") {
|
|
350
|
+
const tokenRows = rows.filter((row) => row.type === "event_msg" && row.payload?.type === "token_count" && row.payload.info);
|
|
351
|
+
const latest = tokenRows[tokenRows.length - 1];
|
|
352
|
+
const total = latest?.payload?.info?.total_token_usage || {};
|
|
353
|
+
const last = latest?.payload?.info?.last_token_usage || {};
|
|
354
|
+
const contextWindow = number(latest?.payload?.info?.model_context_window) || null;
|
|
355
|
+
const latestTokens = number(last.total_tokens);
|
|
356
|
+
const compactAt = compactMoments(rows.filter((row) => row.type === "event_msg" && row.payload?.type === "context_compacted").map((row) => row.timestamp));
|
|
357
|
+
result = {
|
|
358
|
+
...base,
|
|
359
|
+
cumulativeTokens: number(total.total_tokens), inputTokens: number(total.input_tokens), cachedTokens: number(total.cached_input_tokens),
|
|
360
|
+
outputTokens: number(total.output_tokens), reasoningTokens: number(total.reasoning_output_tokens), latestTokens, contextWindow,
|
|
361
|
+
contextPercent: contextWindow ? Math.min(100, latestTokens / contextWindow * 100) : null,
|
|
362
|
+
turns: rows.filter((row) => row.type === "event_msg" && row.payload?.type === "task_started").length,
|
|
363
|
+
compactCount: compactAt.length, lastCompactAt: compactAt.length ? new Date(compactAt.at(-1)).toISOString() : null,
|
|
364
|
+
updatedAt: latest?.timestamp || stat.mtime.toISOString(),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
else if (runtime === "claude") {
|
|
368
|
+
const byMessage = new Map();
|
|
369
|
+
for (const row of rows)
|
|
370
|
+
if (row.type === "assistant" && row.message?.usage)
|
|
371
|
+
byMessage.set(row.message.id || row.uuid, row);
|
|
372
|
+
const messages = [...byMessage.values()];
|
|
373
|
+
const usageOf = (row) => row?.message?.usage || {};
|
|
374
|
+
const tokenTotal = (usage) => number(usage.input_tokens) + number(usage.cache_creation_input_tokens) + number(usage.cache_read_input_tokens) + number(usage.output_tokens);
|
|
375
|
+
const latest = messages.at(-1);
|
|
376
|
+
const totals = messages.reduce((sum, row) => sum + tokenTotal(usageOf(row)), 0);
|
|
377
|
+
const compactAt = compactMoments(rows.filter((row) => row.type === "system" && row.subtype === "compact_boundary").map((row) => row.timestamp));
|
|
378
|
+
result = {
|
|
379
|
+
...base, cumulativeTokens: totals, latestTokens: tokenTotal(usageOf(latest)), contextWindow: null, contextPercent: null,
|
|
380
|
+
turns: new Set(rows.filter((row) => row.type === "user" && !row.isSidechain).map((row) => row.uuid)).size,
|
|
381
|
+
compactCount: compactAt.length, lastCompactAt: compactAt.length ? new Date(compactAt.at(-1)).toISOString() : null,
|
|
382
|
+
updatedAt: latest?.timestamp || stat.mtime.toISOString(),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
else if (runtime === "pi") {
|
|
386
|
+
const parsed = parsePiSessionUsage(file, piCatalog);
|
|
387
|
+
if (!parsed.available)
|
|
388
|
+
return cacheSessionUsage(cacheKey, { ...base, ...parsed });
|
|
389
|
+
result = { ...base, ...parsed, updatedAt: parsed.updatedAt || stat.mtime.toISOString() };
|
|
390
|
+
}
|
|
391
|
+
else
|
|
392
|
+
result = { available: false, source: runtime, reason: "该 runtime 暂无用量适配器" };
|
|
393
|
+
return cacheSessionUsage(cacheKey, result);
|
|
394
|
+
}
|
|
395
|
+
function readConversation(file, limit = 60) {
|
|
396
|
+
return readNdjson(file, 512 * 1024).rows.slice(-limit).reverse();
|
|
397
|
+
}
|
|
398
|
+
export function collectWorkspaceEntry(agentId, requestedPath) {
|
|
399
|
+
const { config } = larkinConfig.loadConfig(process.env);
|
|
400
|
+
return collectTypedWorkspaceEntry(config, agentId || "", requestedPath);
|
|
401
|
+
}
|
|
402
|
+
export function resolveDashboardAvatarSource(agentId) {
|
|
403
|
+
const { config } = larkinConfig.loadConfig(process.env);
|
|
404
|
+
const agent = config.agents?.[agentId];
|
|
405
|
+
if (!agent)
|
|
406
|
+
return null;
|
|
407
|
+
const identity = readJson(path.join(agent.stateDir, "bot-identity.json"), null);
|
|
408
|
+
return isAllowedDashboardAvatarUrl(identity?.avatar_url) ? identity.avatar_url : null;
|
|
409
|
+
}
|
|
410
|
+
function meaningfulTimelineText(value) {
|
|
411
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
412
|
+
}
|
|
413
|
+
export function projectStatusTimeline(status) {
|
|
414
|
+
const activityLog = Array.isArray(status.activityLog) ? status.activityLog : [];
|
|
415
|
+
// deliverLog intentionally contains both runtime delivery lifecycle records and the richer
|
|
416
|
+
// inbound-message projection. Only the latter has a sender/target and belongs in the UI.
|
|
417
|
+
const deliverLog = (Array.isArray(status.deliverLog) ? status.deliverLog : [])
|
|
418
|
+
.filter((entry) => meaningfulTimelineText(entry.from) || meaningfulTimelineText(entry.target));
|
|
419
|
+
const recentErrors = Array.isArray(status.recentErrors) ? status.recentErrors : [];
|
|
420
|
+
const durableActivity = activityLog.length ? activityLog[activityLog.length - 1] : null;
|
|
421
|
+
const lastActivity = status.lastActivity && typeof status.lastActivity === "object" && !Array.isArray(status.lastActivity)
|
|
422
|
+
? status.lastActivity
|
|
423
|
+
: durableActivity;
|
|
424
|
+
const lastDeliver = deliverLog.length ? deliverLog[deliverLog.length - 1] : null;
|
|
425
|
+
const feed = [
|
|
426
|
+
...deliverLog.map((d) => ({ at: d.at, kind: "deliver", from: d.from, target: d.target })),
|
|
427
|
+
...activityLog.map((s) => ({ at: s.at, kind: "activity", state: s.state, detail: s.detail, tool: s.tool || null })),
|
|
428
|
+
...recentErrors.map((e) => ({ at: e.at, kind: "error", text: e.text })),
|
|
429
|
+
].sort((x, y) => new Date(String(y.at)).getTime() - new Date(String(x.at)).getTime()).slice(0, 30);
|
|
430
|
+
return { activityLog, deliverLog, recentErrors, lastActivity, lastDeliver, feed };
|
|
431
|
+
}
|
|
432
|
+
async function collectAgentStatus(a, configDir, piModelResolver) {
|
|
433
|
+
const dir = a.stateDir;
|
|
434
|
+
const botIdentity = readJson(path.join(dir, "bot-identity.json"), null);
|
|
435
|
+
const status = readJson(path.join(dir, "status.json"), {});
|
|
436
|
+
const pending = readJson(path.join(dir, "feishu-pending-react.json"), { items: [] })?.items || [];
|
|
437
|
+
const map = readJson(path.join(dir, "feishu-map.json"), {});
|
|
438
|
+
const reminders = readJson(path.join(dir, "reminders.json"), { reminders: [] })?.reminders || [];
|
|
439
|
+
const agentState = readJson(path.join(dir, "agent-state.json"), { sessions: {} });
|
|
440
|
+
const sessionId = agentState.sessions?.[a.runtime] || status.session?.id || null;
|
|
441
|
+
let piCatalog = [];
|
|
442
|
+
if (a.runtime === "pi" && piModelResolver) {
|
|
443
|
+
try {
|
|
444
|
+
piCatalog = await piModelResolver.resolve({
|
|
445
|
+
agentId: a.agentId,
|
|
446
|
+
cwd: a.workspaceDir,
|
|
447
|
+
...(process.env.PI_CODING_AGENT_DIR ? { agentDir: process.env.PI_CODING_AGENT_DIR } : {}),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch { /* unknown or unavailable catalog keeps the explicit turns fallback */ }
|
|
451
|
+
}
|
|
452
|
+
const usage = sessionId ? collectRuntimeUsage(a.runtime, sessionId, a, piCatalog) : { available: false, source: a.runtime, reason: "尚未建立 session" };
|
|
453
|
+
const statusCompaction = status.compaction?.sessionId && status.compaction.sessionId !== sessionId ? {} : (status.compaction || {});
|
|
454
|
+
const statusCompactAt = statusCompaction.lastFinishedAt || null;
|
|
455
|
+
const usageCompactAt = usage.lastCompactAt || null;
|
|
456
|
+
const lastCompactAt = [statusCompactAt, usageCompactAt].filter(Boolean).sort((x, y) => new Date(String(y)).getTime() - new Date(String(x)).getTime())[0] || null;
|
|
457
|
+
// runtime 原生 session 是 Compact 的最终事实来源。status 的 compacting_context 是过程状态,
|
|
458
|
+
// 同一次压缩可能上报多帧;历史版本曾把这些帧重复累计。原生文件可读时不能再取两者 max。
|
|
459
|
+
const nativeCompactCount = usage.available && Number.isFinite(Number(usage.compactCount)) ? Number(usage.compactCount) : null;
|
|
460
|
+
const compaction = {
|
|
461
|
+
active: !!statusCompaction.active,
|
|
462
|
+
count: nativeCompactCount ?? Number(statusCompaction.count || 0),
|
|
463
|
+
countSource: nativeCompactCount !== null ? "runtime" : "status",
|
|
464
|
+
startedAt: statusCompaction.startedAt || null,
|
|
465
|
+
lastFinishedAt: lastCompactAt,
|
|
466
|
+
};
|
|
467
|
+
const conversation = readConversation(path.join(dir, "conversation.ndjson"));
|
|
468
|
+
const knownChats = new Set(Object.values(map).filter((v) => typeof v === "string" && v.startsWith("oc_"))).size;
|
|
469
|
+
const oldestPendingSec = pending.length ? Math.max(...pending.map((p) => ageSec(p.at) ?? 0)) : null;
|
|
470
|
+
let eyeAgeSec = oldestPendingSec;
|
|
471
|
+
if (pending.length && eyeAgeSec == null) {
|
|
472
|
+
try {
|
|
473
|
+
eyeAgeSec = Math.round((Date.now() - fs.statSync(path.join(dir, "feishu-pending-react.json")).mtimeMs) / 1000);
|
|
474
|
+
}
|
|
475
|
+
catch { /* 忽略 */ }
|
|
476
|
+
}
|
|
477
|
+
// 本 agent 专属时间线:三路历史合并按时间倒序,不是全局一条汇总——每个 agent 只看自己的故事。
|
|
478
|
+
const { recentErrors, lastActivity, lastDeliver, feed } = projectStatusTimeline(status);
|
|
479
|
+
// 提醒:不只给数量,给完整列表——待触发按最近到期优先排前面,已触发/已取消按最近的排在后面。
|
|
480
|
+
const pendingReminders = reminders.filter((r) => r.status === "scheduled" || r.status === "pending")
|
|
481
|
+
.sort((x, y) => new Date(String(x.fireAt)).getTime() - new Date(String(y.fireAt)).getTime());
|
|
482
|
+
const terminalReminderStates = new Set(["fired", "canceled", "cancelled", "failed"]);
|
|
483
|
+
const doneReminders = reminders.filter((r) => terminalReminderStates.has(String(r.status)))
|
|
484
|
+
.sort((x, y) => new Date(String(y.fireAt)).getTime() - new Date(String(x.fireAt)).getTime()).slice(0, 10);
|
|
485
|
+
const remindersList = [...pendingReminders, ...doneReminders].map((r) => ({
|
|
486
|
+
title: r.title, status: r.status, fireAt: r.fireAt, repeat: r.repeat || null,
|
|
487
|
+
}));
|
|
488
|
+
return {
|
|
489
|
+
agentId: a.agentId,
|
|
490
|
+
name: a.name,
|
|
491
|
+
displayName: (botIdentity && botIdentity.name) || a.name,
|
|
492
|
+
runtime: a.runtime,
|
|
493
|
+
model: status.session?.runtime === a.runtime && status.session?.model ? String(status.session.model) : a.model,
|
|
494
|
+
effort: status.session?.runtime === a.runtime && status.session?.reasoningEffort ? String(status.session.reasoningEffort) : a.effort || null,
|
|
495
|
+
runtimeReadiness: status.runtimeReadiness || null,
|
|
496
|
+
credentialReady: fs.existsSync(path.join(configDir, "bots", `${a.feishuProfile}.json`)),
|
|
497
|
+
bot: botIdentity ? {
|
|
498
|
+
name: botIdentity.name,
|
|
499
|
+
openId: botIdentity.open_id,
|
|
500
|
+
hasAvatar: isAllowedDashboardAvatarUrl(botIdentity.avatar_url),
|
|
501
|
+
} : null,
|
|
502
|
+
connectedAt: status.connectedAt || null,
|
|
503
|
+
connectedAgeSec: status.connectedAt ? ageSec(status.connectedAt) : null,
|
|
504
|
+
inboundVerifiedAt: status.inboundVerifiedAt || null,
|
|
505
|
+
inboundVerifiedAgeSec: status.inboundVerifiedAt ? ageSec(status.inboundVerifiedAt) : null,
|
|
506
|
+
lastActivity: lastActivity ? { ...lastActivity, ageSec: ageSec(lastActivity.at) } : null,
|
|
507
|
+
lastDeliver: lastDeliver ? { ...lastDeliver, ageSec: ageSec(lastDeliver.at) } : null,
|
|
508
|
+
recentErrors: recentErrors.slice(-5).reverse(),
|
|
509
|
+
eyeIndicator: { pendingCount: pending.length, oldestAgeSec: eyeAgeSec, stuck: pending.length > 0 && eyeAgeSec != null && eyeAgeSec > EYE_STUCK_SEC },
|
|
510
|
+
knownChats,
|
|
511
|
+
activeReminders: pendingReminders.length,
|
|
512
|
+
remindersList,
|
|
513
|
+
sessions: agentState.sessions || {},
|
|
514
|
+
session: sessionId ? {
|
|
515
|
+
id: sessionId,
|
|
516
|
+
runtime: a.runtime,
|
|
517
|
+
startedAt: usage.startedAt || (status.session?.id === sessionId ? status.session.startedAt : null) || null,
|
|
518
|
+
ageSec: ageSec(usage.startedAt || (status.session?.id === sessionId ? status.session.startedAt : null)),
|
|
519
|
+
lastTurnAt: status.session?.lastTurnAt || null,
|
|
520
|
+
turns: Math.max(Number(status.session?.turns || 0), Number(usage.turns || 0)),
|
|
521
|
+
usage,
|
|
522
|
+
compaction,
|
|
523
|
+
} : null,
|
|
524
|
+
conversation,
|
|
525
|
+
noMentionChats: Array.isArray(a.noMentionChats) ? a.noMentionChats.length : 0,
|
|
526
|
+
feed,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
export async function collectStatus(options = {}) {
|
|
530
|
+
const { config, configDir } = larkinConfig.loadConfig(process.env);
|
|
531
|
+
const agents = Object.values(config.agents || {});
|
|
532
|
+
const daemonStatus = readProcessState(config.larkinHome).daemon;
|
|
533
|
+
const daemon = {
|
|
534
|
+
running: daemonStatus.state === "owned",
|
|
535
|
+
state: daemonStatus.state,
|
|
536
|
+
reason: daemonStatus.reason,
|
|
537
|
+
alive: daemonStatus.alive,
|
|
538
|
+
pid: daemonStatus?.pid || null,
|
|
539
|
+
startedAt: daemonStatus?.startedAt || null,
|
|
540
|
+
epochValid: daemonStatus.state === "owned" && finiteTime(daemonStatus?.startedAt) !== null,
|
|
541
|
+
epochReason: daemonStatus.state === "owned" && finiteTime(daemonStatus?.startedAt) === null ? "owned daemon status lacks a valid startedAt epoch" : null,
|
|
542
|
+
uptimeSec: daemonStatus?.startedAt ? ageSec(daemonStatus.startedAt) : null,
|
|
543
|
+
agents: Array.isArray(daemonStatus?.agents) ? daemonStatus.agents.filter((id) => typeof id === "string") : [],
|
|
544
|
+
};
|
|
545
|
+
const statuses = (await Promise.all(agents.map((a) => collectAgentStatus(a, configDir, options.piModelResolver)))).map((agent) => {
|
|
546
|
+
return { ...agent, ...projectAgentHealth(agent, daemon) };
|
|
547
|
+
});
|
|
548
|
+
return {
|
|
549
|
+
version: DASHBOARD_BUILD_VERSION,
|
|
550
|
+
packageVersion: PACKAGE_VERSION,
|
|
551
|
+
buildFingerprint: DASHBOARD_BUILD_FINGERPRINT,
|
|
552
|
+
generatedAt: new Date().toISOString(),
|
|
553
|
+
daemon,
|
|
554
|
+
agents: statuses,
|
|
555
|
+
};
|
|
556
|
+
}
|