larkin 0.5.18 → 0.5.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.19
4
+
5
+ The Feishu processing-eye OnIt reaction is no longer a config switch. After a successful deliver, Larkin reacts OnIt only for private chats (DM / p2p), when the inbound event already marked the bot as mentioned, or when the user is talking inside a topic the bot started (a Feishu topic whose root is a bot-owned message). Ordinary group messages without @ and outside a bot-started topic never get the reaction, including free-mention chats that still wake the Agent.
6
+
7
+ The 0.5.18 `processingEye.enabled` key, `larkin config processing-eye`, and Dashboard 处理中表情 toggle are removed. Leftover keys in existing configs are ignored on load and dropped on the next config write. There is no on/off flag and no migration prompt.
8
+
3
9
  ## 0.5.18
4
10
 
5
11
  The Feishu processing-eye OnIt reaction is now off by default. New installs and existing configs that omit `processingEye` no longer auto-react on inbound messages after upgrade; there is no migration prompt. Opt back in with `larkin config processing-eye global on`, a per-Agent `on`/`off`/`inherit` override, or the Dashboard 处理中表情 toggle. Explicit `enabled: false` and a missing key both skip the reactions API.
package/README.md CHANGED
@@ -114,16 +114,9 @@ larkin config inbox-audit global off
114
114
 
115
115
  When enabled, audit only revisits originally wake-eligible human group/topic work and does not wake a model for an empty work list. Reading an audit list does not complete it: the managed Agent follows the returned inspection instructions and explicitly confirms the receipt after checking. A failed check remains retryable; newer messages cannot be completed by an older receipt. Old audit-index records without proven eligibility or observation identity are ignored; ordinary Inbox messages and conversation history are preserved.
116
116
 
117
- ### Optional processing eye (OnIt)
117
+ ### Processing eye (OnIt)
118
118
 
119
- The Feishu "processing eye" (OnIt reaction on inbound messages while the Agent works) is **off by default**. A missing `processingEye` key is treated as off, so upgrading to this version stops the auto-reaction without a migration prompt. In the Dashboard, use **Global settings → 处理中表情** or the selected Agent's **Configuration → 处理中表情**. The same settings are available through the CLI:
120
-
121
- ```bash
122
- larkin config processing-eye global on
123
- larkin config processing-eye agent off --agent <App-ID>
124
- larkin config processing-eye agent inherit --agent <App-ID>
125
- larkin config processing-eye global off
126
- ```
119
+ The Feishu "processing eye" is an OnIt reaction on the inbound message while the Agent works. It is automatic and not configurable: Larkin adds it after a successful deliver only for private chats (DM / p2p), when the inbound event marked the bot as mentioned, or when the user is talking inside a topic the bot started. Ordinary group messages without @ and outside a bot-started topic never get the reaction. The 0.5.18 `processingEye` config key, CLI, and Dashboard toggle are removed; leftover keys are ignored.
127
120
 
128
121
  <details>
129
122
  <summary>Windows support and optional autostart</summary>
@@ -232,17 +232,15 @@ function normalizeInboxState(value) {
232
232
  }
233
233
  function newInboxLockOwner(inspect) {
234
234
  const inspected = inspect(process.pid);
235
- if (!inspected.ok || !inspected.startToken) {
236
- throw new Error(`无法读取 Inbox lock owner 身份:${inspected.reason || "metadata incomplete"}`);
237
- }
238
- return { version: 1, pid: process.pid, processStartToken: inspected.startToken, nonce: crypto.randomUUID() };
235
+ const identity = inspected.ok && inspected.startToken ? { processStartToken: inspected.startToken } : {};
236
+ return { version: 1, pid: process.pid, ...identity, nonce: crypto.randomUUID() };
239
237
  }
240
238
  function validInboxLockOwner(value) {
241
239
  if (!value || typeof value !== "object" || Array.isArray(value))
242
240
  return false;
243
241
  const owner = value;
244
242
  return owner.version === 1 && Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0
245
- && typeof owner.processStartToken === "string" && !!owner.processStartToken
243
+ && (owner.processStartToken === undefined || (typeof owner.processStartToken === "string" && !!owner.processStartToken))
246
244
  && typeof owner.nonce === "string" && /^[0-9a-f-]{36}$/i.test(owner.nonce);
247
245
  }
248
246
  class ContextOverflowRecoveryError extends Error {
@@ -308,6 +306,12 @@ class AgentStateStore {
308
306
  const age = Date.now() - stat.mtimeMs;
309
307
  return age >= INBOX_MALFORMED_LOCK_GRACE_MS ? "reclaimable" : "unknown";
310
308
  }
309
+ if (!(0, process_state_js_1.pidAlive)(owner.pid))
310
+ return "reclaimable";
311
+ // A live owner is reclaimed only when its start token proves the pid was reused. Without a
312
+ // token to compare, the lock may still be held, and its owner may still write back.
313
+ if (!owner.processStartToken)
314
+ return "unknown";
311
315
  const inspected = this.inspect(owner.pid);
312
316
  if (!inspected.ok)
313
317
  return inspected.dead ? "reclaimable" : "unknown";
@@ -333,7 +337,8 @@ class AgentStateStore {
333
337
  });
334
338
  }
335
339
  catch (error) {
336
- if (/lock 已被|无法取得 lock|正在创建|暂不能接管/.test(error instanceof Error ? error.message : String(error)))
340
+ // A sandboxed process cannot record its own identity for the reclaim lock; it waits for another process to reclaim.
341
+ if (/lock 已被|无法取得 lock|正在创建|暂不能接管|无法读取当前进程身份/.test(error instanceof Error ? error.message : String(error)))
337
342
  return false;
338
343
  throw error;
339
344
  }
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as crypto from "node:crypto";
3
3
  import * as path from "node:path";
4
4
  import { TargetRootLayout } from "../platform/root-layout.mjs";
5
- import { acquireProcessLock, inspectProcess } from "../platform/process-state.mjs";
5
+ import { acquireProcessLock, inspectProcess, pidAlive } from "../platform/process-state.mjs";
6
6
  import { isWindows } from "../platform/secure-metadata.mjs";
7
7
  import { isCanonicalInboxTarget, isUserDeliveryTarget, RUNTIME_REMINDER_TARGET, targetKeyOfInboxEnvelope } from "./inbox-projection.mjs";
8
8
  import { buildStrictProviderErrorInput, classifyStrictProviderError } from "../runtime/provider-error-classifier.mjs";
@@ -195,17 +195,15 @@ function normalizeInboxState(value) {
195
195
  }
196
196
  function newInboxLockOwner(inspect) {
197
197
  const inspected = inspect(process.pid);
198
- if (!inspected.ok || !inspected.startToken) {
199
- throw new Error(`无法读取 Inbox lock owner 身份:${inspected.reason || "metadata incomplete"}`);
200
- }
201
- return { version: 1, pid: process.pid, processStartToken: inspected.startToken, nonce: crypto.randomUUID() };
198
+ const identity = inspected.ok && inspected.startToken ? { processStartToken: inspected.startToken } : {};
199
+ return { version: 1, pid: process.pid, ...identity, nonce: crypto.randomUUID() };
202
200
  }
203
201
  function validInboxLockOwner(value) {
204
202
  if (!value || typeof value !== "object" || Array.isArray(value))
205
203
  return false;
206
204
  const owner = value;
207
205
  return owner.version === 1 && Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0
208
- && typeof owner.processStartToken === "string" && !!owner.processStartToken
206
+ && (owner.processStartToken === undefined || (typeof owner.processStartToken === "string" && !!owner.processStartToken))
209
207
  && typeof owner.nonce === "string" && /^[0-9a-f-]{36}$/i.test(owner.nonce);
210
208
  }
211
209
  export class ContextOverflowRecoveryError extends Error {
@@ -270,6 +268,12 @@ export class AgentStateStore {
270
268
  const age = Date.now() - stat.mtimeMs;
271
269
  return age >= INBOX_MALFORMED_LOCK_GRACE_MS ? "reclaimable" : "unknown";
272
270
  }
271
+ if (!pidAlive(owner.pid))
272
+ return "reclaimable";
273
+ // A live owner is reclaimed only when its start token proves the pid was reused. Without a
274
+ // token to compare, the lock may still be held, and its owner may still write back.
275
+ if (!owner.processStartToken)
276
+ return "unknown";
273
277
  const inspected = this.inspect(owner.pid);
274
278
  if (!inspected.ok)
275
279
  return inspected.dead ? "reclaimable" : "unknown";
@@ -295,7 +299,8 @@ export class AgentStateStore {
295
299
  });
296
300
  }
297
301
  catch (error) {
298
- if (/lock 已被|无法取得 lock|正在创建|暂不能接管/.test(error instanceof Error ? error.message : String(error)))
302
+ // A sandboxed process cannot record its own identity for the reclaim lock; it waits for another process to reclaim.
303
+ if (/lock 已被|无法取得 lock|正在创建|暂不能接管|无法读取当前进程身份/.test(error instanceof Error ? error.message : String(error)))
299
304
  return false;
300
305
  throw error;
301
306
  }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CONFIG_CLI_VALUES = exports.CONFIG_CLI_USAGE = exports.CONFIG_CLI_OPERATIONS = void 0;
4
- exports.CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "inbox-audit", "processing-eye", "apply"]);
4
+ exports.CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "inbox-audit", "apply"]);
5
5
  exports.CONFIG_CLI_USAGE = Object.freeze([
6
6
  "larkin config show [--agent <App ID>] [--chat <oc_id>] [--json]",
7
7
  "larkin config runtime <runtime> [--model <model>] [--agent <App ID>]",
@@ -12,8 +12,6 @@ exports.CONFIG_CLI_USAGE = Object.freeze([
12
12
  "larkin config mention chat <oc_id> <inherit|require|free> [--agent <App ID>]",
13
13
  "larkin config inbox-audit global <on|off> [--interval <15m|1h>]",
14
14
  "larkin config inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]",
15
- "larkin config processing-eye global <on|off>",
16
- "larkin config processing-eye agent <inherit|on|off> [--agent <App ID>]",
17
15
  "larkin config apply [--agent <App ID>]",
18
16
  ]);
19
17
  exports.CONFIG_CLI_VALUES = Object.freeze({
@@ -1,4 +1,4 @@
1
- export const CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "inbox-audit", "processing-eye", "apply"]);
1
+ export const CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "inbox-audit", "apply"]);
2
2
  export const CONFIG_CLI_USAGE = Object.freeze([
3
3
  "larkin config show [--agent <App ID>] [--chat <oc_id>] [--json]",
4
4
  "larkin config runtime <runtime> [--model <model>] [--agent <App ID>]",
@@ -9,8 +9,6 @@ export const CONFIG_CLI_USAGE = Object.freeze([
9
9
  "larkin config mention chat <oc_id> <inherit|require|free> [--agent <App ID>]",
10
10
  "larkin config inbox-audit global <on|off> [--interval <15m|1h>]",
11
11
  "larkin config inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]",
12
- "larkin config processing-eye global <on|off>",
13
- "larkin config processing-eye agent <inherit|on|off> [--agent <App ID>]",
14
12
  "larkin config apply [--agent <App ID>]",
15
13
  ]);
16
14
  export const CONFIG_CLI_VALUES = Object.freeze({
@@ -49,6 +49,7 @@ const reminder_routes_js_1 = require("./reminder-routes.cjs");
49
49
  const reminder_delivery_audit_js_1 = require("./reminder-delivery-audit.cjs");
50
50
  const transport_shell_js_1 = require("./transport-shell.cjs");
51
51
  const agent_lark_cli_workspace_js_1 = require("../app/agent-lark-cli-workspace.cjs");
52
+ const host_processing_eye_js_1 = require("../feishu/host-processing-eye.cjs");
52
53
  function formatFeishuError(error, grantLink = null) {
53
54
  if (!error)
54
55
  return null;
@@ -139,6 +140,8 @@ function createTransportBusinessContext(env = process.env) {
139
140
  const appendConversation = (item) => {
140
141
  try {
141
142
  fs.appendFileSync(paths.conversation, `${JSON.stringify({ ...item, text: (0, transport_shell_js_1.safeConversationExcerpt)(item.text) })}\n`);
143
+ if (item.direction === "out")
144
+ (0, host_processing_eye_js_1.persistBotTopicRoot)(stateStore, typeof item.messageId === "string" ? item.messageId : null);
142
145
  }
143
146
  catch (error) {
144
147
  log(`conversation 写失败: ${error.message}`);
@@ -12,6 +12,7 @@ import { createReminderRoutes } from "./reminder-routes.mjs";
12
12
  import { auditReminderDelivery } from "./reminder-delivery-audit.mjs";
13
13
  import { isImageMime, looksLikeMarkdown, resolveMappedChatId, safeConversationExcerpt } from "./transport-shell.mjs";
14
14
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
15
+ import { persistBotTopicRoot } from "../feishu/host-processing-eye.mjs";
15
16
  export function formatFeishuError(error, grantLink = null) {
16
17
  if (!error)
17
18
  return null;
@@ -102,6 +103,8 @@ export function createTransportBusinessContext(env = process.env) {
102
103
  const appendConversation = (item) => {
103
104
  try {
104
105
  fs.appendFileSync(paths.conversation, `${JSON.stringify({ ...item, text: safeConversationExcerpt(item.text) })}\n`);
106
+ if (item.direction === "out")
107
+ persistBotTopicRoot(stateStore, typeof item.messageId === "string" ? item.messageId : null);
105
108
  }
106
109
  catch (error) {
107
110
  log(`conversation 写失败: ${error.message}`);
@@ -256,23 +256,6 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
256
256
  agentId: targetId,
257
257
  });
258
258
  }
259
- else if (operation === "processing-eye") {
260
- const [scope, first] = options.positionals;
261
- if (scope === "global")
262
- assertOnlyFlags([]);
263
- else if (scope === "agent")
264
- assertOnlyFlags(["--agent"]);
265
- else
266
- assertOnlyFlags([]);
267
- if (options.positionals.length !== 2) {
268
- throw new Error("用法: larkin config processing-eye global <on|off> | processing-eye agent <inherit|on|off> [--agent <App ID>]");
269
- }
270
- mutation = larkinConfig.processingEyeMutationFromCli({
271
- scope: scope || "",
272
- enabled: first,
273
- agentId: targetId,
274
- });
275
- }
276
259
  else if (operation === "apply") {
277
260
  assertOnlyFlags(["--agent"]);
278
261
  if (options.positionals.length)
@@ -286,7 +269,7 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
286
269
  }).catch((error) => { throw new Error(`配置已保存但未应用:${error instanceof Error ? error.message : String(error)}`); });
287
270
  }
288
271
  else
289
- throw new Error("config 只支持 show/runtime/model/effort/mention/inbox-audit/processing-eye/apply;运行 larkin config --help");
272
+ throw new Error("config 只支持 show/runtime/model/effort/mention/inbox-audit/apply;运行 larkin config --help");
290
273
  const result = larkinConfig.mutateConfig(env, mutation, authority);
291
274
  return { ok: true, revision: result.revision, persisted: true, applyState: result.applyState, changedScope: result.changedScope };
292
275
  }
@@ -52,7 +52,7 @@ const die = (message) => {
52
52
  process.exit(1);
53
53
  };
54
54
  if (!kind || !["agents", "model", "runtime", "effort", "chats", "config"].includes(kind)) {
55
- die("用法: larkin agents | larkin config <show|mention|inbox-audit|processing-eye|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
55
+ die("用法: larkin agents | larkin config <show|mention|inbox-audit|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
56
56
  }
57
57
  const allowedValueFlags = {
58
58
  agents: new Set(), model: new Set(["--agent"]), runtime: new Set(["--agent", "--model"]),
@@ -162,23 +162,13 @@ else if (kind === "config") {
162
162
  if (positionals.length !== 3)
163
163
  die("用法: larkin config inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]");
164
164
  }
165
- else if (operation === "processing-eye" && scope === "global") {
166
- assertOnlyFlags([]);
167
- if (positionals.length !== 3)
168
- die("用法: larkin config processing-eye global <on|off>");
169
- }
170
- else if (operation === "processing-eye" && scope === "agent") {
171
- assertOnlyFlags(["--agent"]);
172
- if (positionals.length !== 3)
173
- die("用法: larkin config processing-eye agent <inherit|on|off> [--agent <App ID>]");
174
- }
175
165
  else if (operation === "apply") {
176
166
  assertOnlyFlags(["--agent"]);
177
167
  if (positionals.length !== 1)
178
168
  die("用法: larkin config apply [--agent <App ID>]");
179
169
  }
180
170
  else {
181
- die("config 只支持 show/mention/inbox-audit/processing-eye/apply;运行 larkin config --help");
171
+ die("config 只支持 show/mention/inbox-audit/apply;运行 larkin config --help");
182
172
  }
183
173
  }
184
174
  const loaded = (() => {
@@ -362,20 +352,15 @@ if (kind === "config") {
362
352
  say(`全局群消息策略=${String(view.mentionPolicy)}(free 会唤醒所有继承它的 Agent)`);
363
353
  const inboxAudit = view.inboxAudit;
364
354
  say(`全局 Inbox Audit=${inboxAudit?.enabled ? "on" : "off"} interval=${larkinConfig.formatInboxAuditInterval(Number(inboxAudit?.intervalMs || larkinConfig.DEFAULT_INBOX_AUDIT_INTERVAL_MS))}(默认关闭;未 @ 的 require 群消息不会进入审计)`);
365
- const processingEye = view.processingEye;
366
- say(`全局处理中表情=${processingEye?.enabled ? "on" : "off"}(默认关闭;缺 key 视为关闭,升级后不再自动点 OnIt)`);
367
355
  for (const item of view.agents) {
368
356
  const mention = item.mention;
369
357
  const agentAudit = item.inboxAudit;
370
- const agentEye = item.processingEye;
371
358
  const apply = item.apply;
372
- say(`agent=${item.agentId} runtime=${item.runtime} model=${item.model} effort=${item.effort || "default"} mention=${mention.override} inbox-audit=${agentAudit?.override?.enabled || "inherit"} processing-eye=${agentEye?.override?.enabled || "inherit"} apply=${apply.applyState || "unknown"}`);
359
+ say(`agent=${item.agentId} runtime=${item.runtime} model=${item.model} effort=${item.effort || "default"} mention=${mention.override} inbox-audit=${agentAudit?.override?.enabled || "inherit"} apply=${apply.applyState || "unknown"}`);
373
360
  if (mention.chat)
374
361
  say(` chat=${mention.chat.chatId} override=${mention.chat.override} effective=${mention.chat.effective} source=${mention.chat.source}`);
375
362
  if (agentAudit?.effective)
376
363
  say(` inbox-audit effective=${agentAudit.effective.enabled ? "on" : "off"} interval=${larkinConfig.formatInboxAuditInterval(Number(agentAudit.effective.intervalMs || larkinConfig.DEFAULT_INBOX_AUDIT_INTERVAL_MS))}`);
377
- if (agentEye?.effective)
378
- say(` processing-eye effective=${agentEye.effective.enabled ? "on" : "off"}`);
379
364
  }
380
365
  }
381
366
  process.exit(0);
@@ -420,20 +405,6 @@ if (kind === "config") {
420
405
  }
421
406
  process.exit(0);
422
407
  }
423
- if (operation === "processing-eye") {
424
- try {
425
- const result = larkinConfig.mutateConfig(process.env, larkinConfig.processingEyeMutationFromCli({
426
- scope: scope || "",
427
- enabled: first,
428
- agentId: scope === "agent" ? requireTarget() : (selected || keys[0] || ""),
429
- }), authority);
430
- say(JSON.stringify({ ok: true, revision: result.revision, persisted: result.persisted, applyState: result.applyState, changedScope: result.changedScope }, null, 2));
431
- }
432
- catch (error) {
433
- die(error instanceof Error ? error.message : String(error));
434
- }
435
- process.exit(0);
436
- }
437
408
  if (operation === "apply") {
438
409
  const agentId = requireTarget();
439
410
  const expectedSignature = larkinConfig.runtimeConfigSignature(config, agentId);
@@ -453,7 +424,7 @@ if (kind === "config") {
453
424
  }
454
425
  process.exit(0);
455
426
  }
456
- die("config 只支持 show/mention/inbox-audit/processing-eye/apply");
427
+ die("config 只支持 show/mention/inbox-audit/apply");
457
428
  }
458
429
  let key = flagAgent;
459
430
  if (!key && runtimeAgentId)
package/dist/app/cli.mjs CHANGED
@@ -87,7 +87,6 @@ Examples:
87
87
  larkin config runtime pi --agent cli_x --model default
88
88
  larkin config mention chat oc_x free --agent cli_x
89
89
  larkin config inbox-audit global on --interval 15m
90
- larkin config processing-eye global on
91
90
 
92
91
  Credentials, internal paths, serverId, activeAgent, and raw config are never exposed here.`,
93
92
  session: `Usage: larkin session reset --agent <App ID> --json [--wait-ready <seconds>]
@@ -13,11 +13,10 @@ import "../platform/check-bun-version.cjs";
13
13
  import fs from "node:fs";
14
14
  import http from "node:http";
15
15
  import path from "node:path";
16
- import crypto from "node:crypto";
17
16
  import { spawnSync } from "node:child_process";
18
17
  import { fileURLToPath } from "node:url";
19
18
  import { currentProcessMetadata, readJson } from "../platform/process-state.mjs";
20
- import { prepareDashboardLaunch } from "../dashboard/dashboard-lifecycle.mjs";
19
+ import { loadDashboardCapability, prepareDashboardLaunch } from "../dashboard/dashboard-lifecycle.mjs";
21
20
  import { DASHBOARD_BUILD_FINGERPRINT, DASHBOARD_BUILD_VERSION, PACKAGE_VERSION, collectStatus, collectWorkspaceEntry, loadDashboardConfig, resolveDashboardAvatarSource } from "../dashboard/dashboard-view-model.mjs";
22
21
  import { renderDashboardHtml } from "../dashboard/dashboard-template.mjs";
23
22
  import { createDashboardConfigController, createPiModelDirectoryResolver } from "../dashboard/dashboard-config-controller.mjs";
@@ -64,7 +63,7 @@ if (preparedDashboard.action === "reuse") {
64
63
  process.exit(0);
65
64
  }
66
65
  const dashboardLaunchLock = { release: preparedDashboard.release };
67
- const csrfCapability = crypto.randomBytes(32).toString("base64url");
66
+ const csrfCapability = loadDashboardCapability(path.join(startupConfig.larkinHome, "dashboard-capability"));
68
67
  const HTML = renderDashboardHtml(PACKAGE_VERSION, DASHBOARD_BUILD_VERSION, BUILD_FINGERPRINT, csrfCapability);
69
68
  const piModelResolver = createPiModelDirectoryResolver();
70
69
  const configController = createDashboardConfigController({ csrfCapability, piModelDirectoryResolver: piModelResolver });
@@ -11,6 +11,7 @@ import * as larkinConfig from "../platform/config.mjs";
11
11
  import { resolveOfficialLarkCli } from "./official-lark-cli.mjs";
12
12
  import { assertAgentWorkspaceBound, managedLarkCliEnv } from "./agent-lark-cli-workspace.mjs";
13
13
  import { parseDocumentCommentTarget } from "../feishu/document-comment.mjs";
14
+ import { persistBotTopicRoot } from "../feishu/host-processing-eye.mjs";
14
15
  import { SpanKind } from "@opentelemetry/api";
15
16
  import { loadTelemetryConfig } from "../platform/telemetry-config.mjs";
16
17
  import { telemetrySingleton } from "../platform/telemetry-tracing.mjs";
@@ -1294,6 +1295,9 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1294
1295
  const deliveryAnchor = currentReminder?.deliveryAnchor;
1295
1296
  const memo = !write.error && write.status === 0 && writeMessage
1296
1297
  ? recordImWriteMemo(store, intentKey, writeMessage.message_id, deliveryAnchor) : { duplicate: false };
1298
+ if (!write.error && write.status === 0 && writeMessage?.message_id) {
1299
+ persistBotTopicRoot(store, writeMessage.message_id);
1300
+ }
1297
1301
  const duplicateOfEarlierReminder = memo.duplicate && Boolean(currentReminder)
1298
1302
  && memo.priorSourceMessageId !== currentReminder?.deliveryAnchor;
1299
1303
  const committedWrite = !write.error && write.status === 0 && !duplicateOfEarlierReminder;
package/dist/app/run.mjs CHANGED
@@ -385,10 +385,12 @@ export async function main() {
385
385
  launchLock.release();
386
386
  console.error(`[start] unified supervisor pid=${process.pid} daemon=${daemon.pid} agents=${names.join(",")}`);
387
387
  console.error("[start] dashboard 已随服务启动;不会自动打开浏览器");
388
- const requestStop = (signal) => {
388
+ const requestStop = (signal, received = signal) => {
389
389
  if (stopping)
390
390
  return;
391
391
  stopping = true;
392
+ // Name the stop signal so a service that vanished can be told apart from a crash (a crash logs its own line).
393
+ console.error(`[start] 收到 ${received},停止服务 (pid=${process.pid} ppid=${process.ppid})`);
392
394
  if (daemonRestartTimer) {
393
395
  clearTimeout(daemonRestartTimer);
394
396
  daemonRestartTimer = null;
@@ -405,6 +407,8 @@ export async function main() {
405
407
  };
406
408
  process.once("SIGINT", () => requestStop("SIGINT"));
407
409
  process.once("SIGTERM", () => requestStop("SIGTERM"));
410
+ // A closed terminal would otherwise end the supervisor without a trace; stop the same way as SIGTERM.
411
+ process.once("SIGHUP", () => requestStop("SIGTERM", "SIGHUP"));
408
412
  const daemonResult = await daemonFinal;
409
413
  try {
410
414
  cleanupStaleAgentControlSocket(configDir, controlToken);
@@ -310,9 +310,12 @@ export function createClaudeModelDirectoryResolver(options = {}) {
310
310
  },
311
311
  };
312
312
  }
313
- async function sanitizedView(env, chatDirectoryResolver, onlyAgentId, chatId) {
313
+ async function sanitizedView(env, chatDirectoryResolver, onlyAgentId, chatId, withChats = true) {
314
314
  const { config } = loadConfig(env);
315
315
  const view = safeConfigView(config, onlyAgentId, chatId, configApplyState(env, config));
316
+ // Group names come from Feishu, one lookup per Agent; callers that only need the global values skip them.
317
+ if (!withChats)
318
+ return view;
316
319
  for (const item of view.agents) {
317
320
  const agentId = String(item.agentId);
318
321
  const agent = config.agents[agentId];
@@ -367,6 +370,37 @@ function assertWriteRequest(req, csrfCapability) {
367
370
  if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json"))
368
371
  throw new Error("application/json required");
369
372
  }
373
+ /**
374
+ * Map a failed dashboard request to a status and a message the page shows as is.
375
+ * The raw reason goes to the local log. A wrong capability means the page was opened before this dashboard
376
+ * (re)started; an unknown operation or field means the page is from another build; both are fixed by a reload.
377
+ */
378
+ export function dashboardError(error, fallback) {
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ console.warn(`[dashboard] ${fallback}: ${message}`);
381
+ const reply = (status, text) => ({ status, body: { error: text } });
382
+ if (/^(?:csrf|capability) rejected$/.test(message))
383
+ return reply(403, "这个页面已过期,刷新页面后再操作。");
384
+ if (/^(?:invalid host|same-origin required)$/.test(message))
385
+ return reply(403, "请在本机用 http://localhost 打开 dashboard。");
386
+ if (message === "request too large")
387
+ return reply(413, "内容太大,无法保存。");
388
+ if (message === "unknown agent")
389
+ return reply(404, "这个 Agent 已不存在,刷新页面看看。");
390
+ if (/not in current (?:runtime|model) directory$/.test(message))
391
+ return reply(400, "所选的模型或推理强度已不在可用列表里,重新选择后再保存。");
392
+ if (/^(?:unsupported operation|invalid |JSON object required|application\/json required)/.test(message) || error instanceof SyntaxError) {
393
+ return reply(400, "页面和正在运行的 larkin 版本不一致。刷新页面后再操作。");
394
+ }
395
+ // Messages written for people (Chinese) are shown as is; anything else keeps the fallback plus its reason.
396
+ if (/[\u4e00-\u9fff]/.test(message))
397
+ return reply(400, message);
398
+ return reply(500, `${fallback}:${message.slice(0, 200)}`);
399
+ }
400
+ function sendError(res, error, fallback, extra = {}) {
401
+ const { status, body } = dashboardError(error, fallback);
402
+ json(res, status, { ...body, ...extra });
403
+ }
370
404
  function assertPrivateReadRequest(req, csrfCapability) {
371
405
  const host = String(req.headers.host || "");
372
406
  if (!/^(?:localhost|127\.0\.0\.1):\d+$/.test(host))
@@ -402,17 +436,11 @@ function dashboardMutation(value) {
402
436
  "set-chat-mention": ["operation", "agentId", "chatId", "value"], "set-agent-runtime": ["operation", "agentId", "runtime", "model"],
403
437
  "set-agent-model": ["operation", "agentId", "model"], "set-agent-effort": ["operation", "agentId", "effort"],
404
438
  "set-global-inbox-audit": ["operation", "enabled", "intervalMs"], "set-agent-inbox-audit": ["operation", "agentId", "enabled", "intervalMs"],
405
- "set-global-processing-eye": ["operation", "enabled"], "set-agent-processing-eye": ["operation", "agentId", "enabled"],
406
439
  };
407
440
  if (!allowed[operation] || Object.keys(value).some((key) => !allowed[operation].includes(key)))
408
441
  throw new Error("unsupported operation");
409
442
  if (operation === "set-global-mention")
410
443
  return { kind: operation, value: String(value.value) };
411
- if (operation === "set-global-processing-eye") {
412
- if (typeof value.enabled !== "boolean")
413
- throw new Error("invalid processing-eye enabled");
414
- return { kind: operation, enabled: value.enabled };
415
- }
416
444
  if (operation === "set-global-inbox-audit") {
417
445
  if (value.enabled !== undefined && typeof value.enabled !== "boolean")
418
446
  throw new Error("invalid inbox-audit enabled");
@@ -428,11 +456,6 @@ function dashboardMutation(value) {
428
456
  throw new Error("invalid inbox-audit interval");
429
457
  return { kind: operation, agentId, ...(value.enabled !== undefined ? { enabled: value.enabled } : {}), ...(value.intervalMs !== undefined ? { intervalMs: value.intervalMs } : {}) };
430
458
  }
431
- if (operation === "set-agent-processing-eye") {
432
- if (value.enabled !== "inherit" && typeof value.enabled !== "boolean")
433
- throw new Error("invalid processing-eye enabled");
434
- return { kind: operation, agentId, enabled: value.enabled };
435
- }
436
459
  if (operation === "set-agent-mention")
437
460
  return { kind: operation, agentId, value: String(value.value) };
438
461
  if (operation === "set-chat-mention")
@@ -519,13 +542,13 @@ export function createDashboardConfigController({ csrfCapability, env = process.
519
542
  try {
520
543
  assertPrivateReadRequest(req, csrfCapability);
521
544
  json(res, 200, {
522
- ...await sanitizedView(env, chatDirectoryResolver, requestUrl.searchParams.get("agent") || undefined, requestUrl.searchParams.get("chat") || undefined),
545
+ ...await sanitizedView(env, chatDirectoryResolver, requestUrl.searchParams.get("agent") || undefined, requestUrl.searchParams.get("chat") || undefined, requestUrl.searchParams.get("chats") !== "0"),
523
546
  runtimeModels: loadRuntimeModels(),
524
547
  runtimeOptions: [...RUNTIME_OPTIONS],
525
548
  });
526
549
  }
527
550
  catch (error) {
528
- json(res, error instanceof Error && /host|capability/.test(error.message) ? 403 : 500, { error: "configuration unavailable" });
551
+ sendError(res, error, "读取配置失败");
529
552
  }
530
553
  return true;
531
554
  }
@@ -537,7 +560,7 @@ export function createDashboardConfigController({ csrfCapability, env = process.
537
560
  json(res, 200, { models });
538
561
  }
539
562
  catch (error) {
540
- json(res, error instanceof Error && /host|capability/.test(error.message) ? 403 : 500, { error: "Pi model directory unavailable" });
563
+ sendError(res, error, "读取 Pi 模型列表失败");
541
564
  }
542
565
  return true;
543
566
  }
@@ -553,7 +576,7 @@ export function createDashboardConfigController({ csrfCapability, env = process.
553
576
  json(res, 200, { models });
554
577
  }
555
578
  catch (error) {
556
- json(res, error instanceof Error && /host|capability/.test(error.message) ? 403 : 500, { error: "Codex model directory unavailable" });
579
+ sendError(res, error, "读取 Codex 模型列表失败");
557
580
  }
558
581
  return true;
559
582
  }
@@ -569,7 +592,7 @@ export function createDashboardConfigController({ csrfCapability, env = process.
569
592
  json(res, 200, { models });
570
593
  }
571
594
  catch (error) {
572
- json(res, error instanceof Error && /host|capability/.test(error.message) ? 403 : 500, { error: "Claude model directory unavailable" });
595
+ sendError(res, error, "读取 Claude 模型列表失败");
573
596
  }
574
597
  return true;
575
598
  }
@@ -583,15 +606,10 @@ export function createDashboardConfigController({ csrfCapability, env = process.
583
606
  json(res, 200, { ok: true, revision: result.revision, persisted: true, applyState: result.applyState, changedScope: result.changedScope });
584
607
  }
585
608
  catch (error) {
586
- if (error instanceof RuntimePrerequisiteError) {
609
+ if (error instanceof RuntimePrerequisiteError)
587
610
  json(res, 400, { error: error.message, readiness: error.readiness });
588
- }
589
- else if (error instanceof Error && error.message.startsWith("未知 runtime")) {
590
- json(res, 400, { error: error.message });
591
- }
592
- else {
593
- json(res, 400, { error: "configuration update rejected" });
594
- }
611
+ else
612
+ sendError(res, error, "保存失败");
595
613
  }
596
614
  return true;
597
615
  }
@@ -608,15 +626,17 @@ export function createDashboardConfigController({ csrfCapability, env = process.
608
626
  const expectedSignature = runtimeConfigSignature(config, agentId);
609
627
  const result = await requestUpsert({ larkinHome: config.larkinHome, agentId });
610
628
  if (!result.ok) {
611
- json(res, 409, { error: result.error || "configuration saved but apply failed",
629
+ json(res, 409, { error: result.error || "配置已保存,但应用失败",
612
630
  ...(result.readiness ? { readiness: result.readiness } : {}) });
613
631
  return true;
614
632
  }
615
633
  markConfigApplied(env, agentId, expectedSignature);
616
634
  json(res, 200, { ok: true, agentId, applyState: "applied" });
617
635
  }
618
- catch {
619
- json(res, 409, { error: "configuration saved but apply failed" });
636
+ catch (error) {
637
+ const { status, body } = dashboardError(error, "配置已保存,但应用失败");
638
+ // An unreachable runtime is a failed apply of a saved config, not a server error.
639
+ json(res, status === 500 ? 409 : status, body);
620
640
  }
621
641
  return true;
622
642
  }
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadDashboardCapability = loadDashboardCapability;
6
7
  exports.dashboardBuildFingerprint = dashboardBuildFingerprint;
7
8
  exports.dashboardReuseDecision = dashboardReuseDecision;
8
9
  exports.reconcileDashboardRecord = reconcileDashboardRecord;
@@ -12,6 +13,7 @@ const node_path_1 = __importDefault(require("node:path"));
12
13
  const node_fs_1 = __importDefault(require("node:fs"));
13
14
  const process_state_js_1 = require("../platform/process-state.cjs");
14
15
  const internal_command_js_1 = require("../app/internal-command.cjs");
16
+ const secure_metadata_js_1 = require("../platform/secure-metadata.cjs");
15
17
  const PUBLISHED_RUNTIME_DIRS = ["dist", "assets"];
16
18
  const RUNTIME_EXTENSIONS = new Set([".cjs", ".mjs", ".js", ".css", ".json", ".node", ".svg", ".wasm"]);
17
19
  function publishedRuntimeFiles(root) {
@@ -39,6 +41,33 @@ function publishedRuntimeFiles(root) {
39
41
  return files.sort((a, b) => a.localeCompare(b, "en"));
40
42
  }
41
43
  /** Same semver can contain a different local build, so hash every published shell runtime byte. */
44
+ /**
45
+ * The dashboard's CSRF capability, kept in an owned 0600 file so a page opened before a restart keeps working.
46
+ * A missing or unsafe file gets a new value; if it cannot be written, the value lasts for this process only.
47
+ */
48
+ function loadDashboardCapability(file) {
49
+ try {
50
+ const stat = node_fs_1.default.lstatSync(file);
51
+ const value = node_fs_1.default.readFileSync(file, "utf8").trim();
52
+ if (stat.isFile() && (0, secure_metadata_js_1.exactMode)(stat, 0o600) && (typeof process.getuid !== "function" || stat.uid === process.getuid())
53
+ && /^[A-Za-z0-9_-]{43}$/.test(value))
54
+ return value;
55
+ }
56
+ catch { /* missing or unreadable: issue a new one */ }
57
+ const value = node_crypto_1.default.randomBytes(32).toString("base64url");
58
+ const temporary = `${file}.${process.pid}.tmp`;
59
+ try {
60
+ node_fs_1.default.writeFileSync(temporary, `${value}\n`, { mode: 0o600, flag: "wx" });
61
+ node_fs_1.default.renameSync(temporary, file);
62
+ }
63
+ catch {
64
+ try {
65
+ node_fs_1.default.unlinkSync(temporary);
66
+ }
67
+ catch { /* nothing was written */ }
68
+ }
69
+ return value;
70
+ }
42
71
  function dashboardBuildFingerprint(root) {
43
72
  const hash = node_crypto_1.default.createHash("sha256");
44
73
  for (const relative of publishedRuntimeFiles(root)) {