@rynx-ai/plugin-channel-lark 0.1.10 → 0.1.11-beta.10

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 (36) hide show
  1. package/.rynx-plugin/plugin.json +25 -0
  2. package/dist/cli-mirror/mirror-index.d.ts +4 -2
  3. package/dist/cli-mirror/mirror-index.js +10 -2
  4. package/dist/host-adapters.d.ts +26 -13
  5. package/dist/host-adapters.js +146 -44
  6. package/dist/index.d.ts +4 -4
  7. package/dist/index.js +3 -3
  8. package/dist/lark-config-store.d.ts +30 -0
  9. package/dist/lark-config-store.js +135 -0
  10. package/dist/lark-runtime-state-store.d.ts +18 -0
  11. package/dist/lark-runtime-state-store.js +95 -0
  12. package/dist/lark-setup.d.ts +27 -5
  13. package/dist/lark-setup.js +24 -7
  14. package/dist/lark.d.ts +9 -2
  15. package/dist/lark.js +45 -80
  16. package/dist/runtime.d.ts +5 -8
  17. package/dist/runtime.js +131312 -141
  18. package/dist/settings.d.ts +7 -0
  19. package/dist/{runtime.mjs → settings.js} +6631 -10535
  20. package/package.json +27 -10
  21. package/ui/assets/index-CGUVAMsv.css +1 -0
  22. package/ui/assets/index-D8Amoe6a.js +201 -0
  23. package/ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  24. package/ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  25. package/ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  26. package/ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  27. package/ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  28. package/ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  29. package/ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  30. package/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  31. package/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  32. package/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  33. package/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  34. package/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  35. package/ui/index.html +13 -0
  36. package/rynx-plugin.json +0 -42
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import path from "node:path";
4
+ const INSTANCE_ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
5
+ export class LarkConfigStore {
6
+ file;
7
+ constructor(dataDir) {
8
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
9
+ this.file = path.join(dataDir, "lark-config.json");
10
+ }
11
+ list() {
12
+ return structuredClone(this.read().instances);
13
+ }
14
+ get(id) {
15
+ return this.list().find((instance) => instance.id === id);
16
+ }
17
+ upsert(input) {
18
+ const state = this.read();
19
+ const id = input.id ?? `instance-${randomUUID().slice(0, 8)}`;
20
+ if (!INSTANCE_ID_PATTERN.test(id))
21
+ throw new Error("Lark instance id is invalid");
22
+ const existing = state.instances.find((instance) => instance.id === id);
23
+ const secret = input.appSecret?.trim() || existing?.appSecret;
24
+ if (!secret)
25
+ throw new Error("App Secret is required");
26
+ const appId = input.appId.trim();
27
+ const name = input.name.trim();
28
+ if (!appId)
29
+ throw new Error("App ID is required");
30
+ if (!name)
31
+ throw new Error("instance name is required");
32
+ const defaultAgent = input.defaultAgent === undefined
33
+ ? existing?.defaultAgent
34
+ : input.defaultAgent?.trim() || undefined;
35
+ const roots = input.allowedRoots ?? existing?.executionDefaults.allowedRoots ?? [];
36
+ if (!Array.isArray(roots) || roots.some((root) => typeof root !== "string" || !path.isAbsolute(root))) {
37
+ throw new Error("allowed roots must be absolute paths");
38
+ }
39
+ const instance = {
40
+ id,
41
+ name,
42
+ enabled: input.enabled,
43
+ appId,
44
+ appSecret: secret,
45
+ ...(defaultAgent ? { defaultAgent } : {}),
46
+ executionDefaults: {
47
+ runtime: input.runtime ?? existing?.executionDefaults.runtime ?? "codex",
48
+ allowedRoots: [...new Set(roots.map((root) => path.resolve(root)))],
49
+ ...(defaultAgent ? { defaultAgent } : {}),
50
+ },
51
+ };
52
+ const index = state.instances.findIndex((item) => item.id === id);
53
+ if (index >= 0)
54
+ state.instances[index] = instance;
55
+ else
56
+ state.instances.push(instance);
57
+ this.write(state);
58
+ return structuredClone(instance);
59
+ }
60
+ remove(id) {
61
+ const state = this.read();
62
+ const filtered = state.instances.filter((instance) => instance.id !== id);
63
+ if (filtered.length === state.instances.length)
64
+ return false;
65
+ this.write({ ...state, instances: filtered });
66
+ return true;
67
+ }
68
+ setEnabled(id, enabled) {
69
+ const state = this.read();
70
+ const instance = state.instances.find((item) => item.id === id);
71
+ if (!instance)
72
+ throw new Error(`Lark instance "${id}" not found`);
73
+ instance.enabled = enabled;
74
+ this.write(state);
75
+ return structuredClone(instance);
76
+ }
77
+ read() {
78
+ if (!existsSync(this.file))
79
+ return { schemaVersion: 1, instances: [] };
80
+ const info = lstatSync(this.file);
81
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) {
82
+ throw new Error("Lark plugin configuration is invalid");
83
+ }
84
+ return parseState(JSON.parse(readFileSync(this.file, "utf8")));
85
+ }
86
+ write(state) {
87
+ const temporary = `${this.file}.tmp-${randomUUID()}`;
88
+ try {
89
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, {
90
+ encoding: "utf8",
91
+ mode: 0o600,
92
+ flag: "wx",
93
+ });
94
+ chmodSync(temporary, 0o600);
95
+ renameSync(temporary, this.file);
96
+ }
97
+ finally {
98
+ rmSync(temporary, { force: true });
99
+ }
100
+ }
101
+ }
102
+ function parseState(value) {
103
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
104
+ throw new Error("Lark plugin configuration must be an object");
105
+ }
106
+ const record = value;
107
+ if (record.schemaVersion !== 1 || !Array.isArray(record.instances)) {
108
+ throw new Error("Lark plugin configuration schema is invalid");
109
+ }
110
+ const ids = new Set();
111
+ const instances = record.instances.map((value) => {
112
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
113
+ throw new Error("Lark instance configuration is invalid");
114
+ }
115
+ const instance = value;
116
+ if (!INSTANCE_ID_PATTERN.test(instance.id) ||
117
+ typeof instance.name !== "string" || !instance.name ||
118
+ typeof instance.enabled !== "boolean" ||
119
+ typeof instance.appId !== "string" || !instance.appId ||
120
+ typeof instance.appSecret !== "string" || !instance.appSecret ||
121
+ (instance.defaultAgent !== undefined &&
122
+ (typeof instance.defaultAgent !== "string" || !instance.defaultAgent.trim())) ||
123
+ !instance.executionDefaults ||
124
+ !["codex", "traex", "claude"].includes(instance.executionDefaults.runtime) ||
125
+ !Array.isArray(instance.executionDefaults.allowedRoots) ||
126
+ !instance.executionDefaults.allowedRoots.every((root) => typeof root === "string" && path.isAbsolute(root))) {
127
+ throw new Error("Lark instance configuration is invalid");
128
+ }
129
+ if (ids.has(instance.id))
130
+ throw new Error(`duplicate Lark instance id "${instance.id}"`);
131
+ ids.add(instance.id);
132
+ return structuredClone(instance);
133
+ });
134
+ return { schemaVersion: 1, instances };
135
+ }
@@ -0,0 +1,18 @@
1
+ import type { PluginHealth } from "@rynx-ai/plugin-sdk";
2
+ export interface LarkRuntimeInstanceState {
3
+ state: "starting" | "running" | "failed" | "stopping" | "stopped";
4
+ connected: boolean;
5
+ lastError: string | null;
6
+ lastEventAt: string | null;
7
+ }
8
+ export interface LarkRuntimeState {
9
+ active: boolean;
10
+ health: PluginHealth;
11
+ instances: Record<string, LarkRuntimeInstanceState>;
12
+ }
13
+ export declare class LarkRuntimeStateStore {
14
+ private readonly file;
15
+ constructor(dataDir: string);
16
+ read(): LarkRuntimeState;
17
+ write(state: LarkRuntimeState): void;
18
+ }
@@ -0,0 +1,95 @@
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ export class LarkRuntimeStateStore {
5
+ file;
6
+ constructor(dataDir) {
7
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
8
+ this.file = path.join(dataDir, "lark-runtime-state.json");
9
+ }
10
+ read() {
11
+ if (!existsSync(this.file)) {
12
+ return { active: false, health: { state: "running" }, instances: {} };
13
+ }
14
+ const info = lstatSync(this.file);
15
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) {
16
+ return {
17
+ active: false,
18
+ health: { state: "degraded", message: "Runtime state is unavailable" },
19
+ instances: {},
20
+ };
21
+ }
22
+ try {
23
+ return parseState(JSON.parse(readFileSync(this.file, "utf8")));
24
+ }
25
+ catch {
26
+ return {
27
+ active: false,
28
+ health: { state: "degraded", message: "Runtime state is unavailable" },
29
+ instances: {},
30
+ };
31
+ }
32
+ }
33
+ write(state) {
34
+ const temporary = `${this.file}.tmp-${randomUUID()}`;
35
+ try {
36
+ writeFileSync(temporary, `${JSON.stringify(state)}\n`, {
37
+ encoding: "utf8",
38
+ mode: 0o600,
39
+ flag: "wx",
40
+ });
41
+ chmodSync(temporary, 0o600);
42
+ renameSync(temporary, this.file);
43
+ }
44
+ finally {
45
+ rmSync(temporary, { force: true });
46
+ }
47
+ }
48
+ }
49
+ function parseState(value) {
50
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
51
+ throw new Error("invalid runtime state");
52
+ }
53
+ const record = value;
54
+ if (typeof record.active !== "boolean")
55
+ throw new Error("invalid runtime activity");
56
+ const health = parseHealth(record.health);
57
+ if (typeof record.instances !== "object" || record.instances === null ||
58
+ Array.isArray(record.instances)) {
59
+ throw new Error("invalid runtime instances");
60
+ }
61
+ const instances = {};
62
+ for (const [id, raw] of Object.entries(record.instances)) {
63
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
64
+ throw new Error("invalid runtime instance");
65
+ }
66
+ const item = raw;
67
+ if (!["starting", "running", "failed", "stopping", "stopped"].includes(String(item.state)) ||
68
+ typeof item.connected !== "boolean" ||
69
+ (item.lastError !== null && typeof item.lastError !== "string") ||
70
+ (item.lastEventAt !== null && typeof item.lastEventAt !== "string")) {
71
+ throw new Error("invalid runtime instance");
72
+ }
73
+ instances[id] = {
74
+ state: item.state,
75
+ connected: item.connected,
76
+ lastError: item.lastError,
77
+ lastEventAt: item.lastEventAt,
78
+ };
79
+ }
80
+ return { active: record.active, health, instances };
81
+ }
82
+ function parseHealth(value) {
83
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
84
+ throw new Error("invalid runtime health");
85
+ }
86
+ const record = value;
87
+ if ((record.state !== "running" && record.state !== "degraded") ||
88
+ (record.message !== undefined && typeof record.message !== "string")) {
89
+ throw new Error("invalid runtime health");
90
+ }
91
+ return {
92
+ state: record.state,
93
+ ...(typeof record.message === "string" ? { message: record.message } : {}),
94
+ };
95
+ }
@@ -1,4 +1,27 @@
1
- import type { AuthMaterial, ConfigField } from "@rynx-ai/core";
1
+ export interface LarkSetupField {
2
+ key: "LARK_APP_ID" | "LARK_APP_SECRET";
3
+ label: string;
4
+ type: "string" | "secret";
5
+ required: boolean;
6
+ help?: string;
7
+ }
8
+ export type LarkAuthorizationMaterial = {
9
+ kind: "qr";
10
+ url: string;
11
+ expireIn?: number;
12
+ } | {
13
+ kind: "link";
14
+ url: string;
15
+ label?: string;
16
+ } | {
17
+ kind: "status";
18
+ text: string;
19
+ };
20
+ export interface LarkAuthorizationCredentials {
21
+ LARK_APP_ID: string;
22
+ LARK_APP_SECRET: string;
23
+ botName?: string;
24
+ }
2
25
  /**
3
26
  * Minimal tenant (app-identity) scopes the Lark channel needs: receive/send
4
27
  * messages, add reactions, read chat + members, upload/download resources, and
@@ -8,10 +31,9 @@ export declare const LARK_BOT_SCOPES: string[];
8
31
  /** The bot only needs to be told when a message arrives. */
9
32
  export declare const LARK_BOT_EVENTS: string[];
10
33
  /** Config mode 1: paste an existing app's credentials. */
11
- export declare const larkConfigSchema: ConfigField[];
34
+ export declare const larkConfigSchema: LarkSetupField[];
12
35
  /**
13
- * Config mode 2: scan-to-create/authorize. Emits a `qr` material (the URL the
14
- * host renders as a QR and/or a clickable link) and `status` updates, then
36
+ * Scan-to-create/authorize. Emits a `qr` material and `status` updates, then
15
37
  * resolves with the App ID / App Secret to persist. Aborts via `signal`.
16
38
  */
17
- export declare function authorizeLark(emit: (material: AuthMaterial) => void, signal: AbortSignal): Promise<Record<string, unknown>>;
39
+ export declare function authorizeLark(emit: (material: LarkAuthorizationMaterial) => void, signal: AbortSignal): Promise<LarkAuthorizationCredentials>;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Lark (Feishu) authorization flow + its credential form schema.
3
3
  *
4
- * Two config modes the host can offer for this channel type:
4
+ * Two setup modes the Lark plugin can offer from its own UI or command:
5
5
  * 1. **Form** ({@link larkConfigSchema}) — paste an existing app's App ID /
6
- * App Secret. The host renders these fields generically.
6
+ * App Secret.
7
7
  * 2. **Authorize** ({@link authorizeLark}) — the SDK's `registerApp` device
8
8
  * flow (RFC 8628-shaped, Feishu-proprietary provisioning): the user scans a
9
9
  * QR (or opens the link) in Feishu, an app is created/authorized with the
@@ -11,10 +11,11 @@
11
11
  * App ID / App Secret.
12
12
  *
13
13
  * `authorizeLark` is transport-neutral: it translates the SDK's callbacks into
14
- * the host's {@link AuthMaterial} stream (`qr` / `status`) and resolves with the
15
- * options to persist. The terminal vs web rendering is the host's concern.
14
+ * the plugin's own {@link LarkAuthorizationMaterial} stream and resolves with
15
+ * the values to persist.
16
16
  */
17
17
  import { registerApp } from "@larksuiteoapi/node-sdk";
18
+ import { createLarkSdkClient } from "./lark-sdk/client.js";
18
19
  /**
19
20
  * Minimal tenant (app-identity) scopes the Lark channel needs: receive/send
20
21
  * messages, add reactions, read chat + members, upload/download resources, and
@@ -50,8 +51,7 @@ const STATUS_TEXT = {
50
51
  domain_switched: "识别到国际版租户,已切换到 larksuite.com 继续。",
51
52
  };
52
53
  /**
53
- * Config mode 2: scan-to-create/authorize. Emits a `qr` material (the URL the
54
- * host renders as a QR and/or a clickable link) and `status` updates, then
54
+ * Scan-to-create/authorize. Emits a `qr` material and `status` updates, then
55
55
  * resolves with the App ID / App Secret to persist. Aborts via `signal`.
56
56
  */
57
57
  export async function authorizeLark(emit, signal) {
@@ -75,7 +75,24 @@ export async function authorizeLark(emit, signal) {
75
75
  if (!result.client_id || !result.client_secret) {
76
76
  throw new Error("扫码返回的凭据为空,请改用手动填写 App ID / App Secret。");
77
77
  }
78
- return { LARK_APP_ID: result.client_id, LARK_APP_SECRET: result.client_secret };
78
+ const botName = await resolveBotName(result.client_id, result.client_secret);
79
+ return {
80
+ LARK_APP_ID: result.client_id,
81
+ LARK_APP_SECRET: result.client_secret,
82
+ ...(botName ? { botName } : {}),
83
+ };
84
+ }
85
+ async function resolveBotName(appId, appSecret) {
86
+ try {
87
+ const response = await createLarkSdkClient({ appId, appSecret }).request({
88
+ method: "GET",
89
+ url: "/open-apis/bot/v3/info",
90
+ });
91
+ return response?.bot?.app_name?.trim() || undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
79
96
  }
80
97
  function messageOf(err) {
81
98
  return err instanceof Error ? err.message : String(err);
package/dist/lark.d.ts CHANGED
@@ -4,7 +4,7 @@ import { chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isL
4
4
  import { type LarkConversationDirectory } from "./lark-conversation-directory.js";
5
5
  import { LarkClientProvider, LarkSdkMessageSender } from "./lark-sender.js";
6
6
  import { type LarkActiveSessionStore } from "./lark-session-store.js";
7
- import type { ConversationProvider, ConversationRuntime } from "@rynx-ai/core";
7
+ import type { ConversationProvider, ConversationStreamRequest, StartedLiveExecution } from "@rynx-ai/core";
8
8
  export { chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, };
9
9
  export { LarkClientProvider, LarkSdkMessageSender };
10
10
  export interface LarkUserIdentifier {
@@ -234,7 +234,12 @@ export interface LarkWsClientProvider {
234
234
  start(options: LarkWsClientStartOptions): Promise<void>;
235
235
  stop(): Promise<void>;
236
236
  }
237
- type LarkConversationRuntime = Pick<ConversationRuntime, "runLive">;
237
+ /** Plugin-facing run contract. The daemon-backed adapter sends only the
238
+ * provisioned Session id and message over Host RPC; the daemon loads that
239
+ * Session's immutable workspace/execution snapshots. */
240
+ export interface LarkConversationRuntime {
241
+ runLive(input: ConversationStreamRequest): Promise<StartedLiveExecution>;
242
+ }
238
243
  interface LarkMessageIngestorOptions {
239
244
  conversationRuntime: LarkConversationRuntime;
240
245
  messageSender: LarkMessageSender;
@@ -278,6 +283,7 @@ interface LarkMessageIngestorOptions {
278
283
  sessionProvisioner?: (input: {
279
284
  sessionKey: string;
280
285
  fromSessionId: string;
286
+ title?: string;
281
287
  }) => Promise<string>;
282
288
  }
283
289
  export interface LarkAgentCatalog {
@@ -484,6 +490,7 @@ interface LocalLarkBotRuntimeOptions {
484
490
  sessionProvisioner?: (input: {
485
491
  sessionKey: string;
486
492
  fromSessionId: string;
493
+ title?: string;
487
494
  }) => Promise<string>;
488
495
  }
489
496
  export declare class LocalLarkBotRuntime implements LarkBotRuntime {
package/dist/lark.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdir } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { promisify } from "node:util";
6
5
  import { CodexRuntimeError, collectLiveRun, } from "@rynx-ai/core";
@@ -16,7 +15,6 @@ import { buildSettingsCard, buildSettingsCardJson, buildSettingsResultCard, SETT
16
15
  import { getRuntimeProfile, isAgentRuntimeId, RUNTIME_PROFILES, } from "@rynx-ai/core";
17
16
  import { SteerController } from "@rynx-ai/core";
18
17
  import { toErrorMessage } from "@rynx-ai/core";
19
- import { findInnermostAllowedRoot } from "@rynx-ai/core";
20
18
  export { chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, };
21
19
  export { LarkClientProvider, LarkSdkMessageSender };
22
20
  const DEFAULT_UNSUPPORTED_MESSAGE = "暂时只支持文本消息,请发送文本内容。";
@@ -38,7 +36,7 @@ const LARK_COMMANDS = [
38
36
  { name: "models", help: "/models — 列出当前运行时可用模型", category: "info" },
39
37
  { name: "resume", help: "/resume [短id] — 列出最近会话/跳转或转发到指定会话", category: "takeover" },
40
38
  { name: "fork", help: "/fork — 把当前会话 fork 到新群/新话题(带上下文+历史记录)", category: "takeover" },
41
- { name: "cwd", help: "/cwd [目录] — 查看/绑定当前会话的工作目录(不存在则新建)", category: "takeover" },
39
+ { name: "cwd", help: "/cwd — 工作目录已改由 Rynx Project 管理", category: "other" },
42
40
  { name: "goal", help: "/goal [目标|clear] — 查看/设置/清除当前会话目标", category: "action" },
43
41
  { name: "diff", help: "/diff — 显示当前工作区 git 改动(含未跟踪文件)", category: "action" },
44
42
  { name: "help", help: "/help — 显示本帮助", category: "info" },
@@ -64,8 +62,7 @@ const defaultLogger = {
64
62
  }));
65
63
  },
66
64
  };
67
- /** Route SDK diagnostics through the runner logger instead of stdout, which is
68
- * reserved for the NDJSON RPC transport in an out-of-process plugin. */
65
+ /** Route SDK diagnostics through the host logger; the process transport owns stdout. */
69
66
  function larkSdkLogger(logger) {
70
67
  const write = (level, messages) => {
71
68
  const text = messages
@@ -502,6 +499,14 @@ export class LarkMessageIngestor {
502
499
  // → fall through to the queue so the message runs as the next turn.
503
500
  }
504
501
  }
502
+ // /new must execute immediately after interrupting the active turn. If it
503
+ // joins the per-session queue, it waits behind the very turn it just
504
+ // aborted; a backend that is slow to close its stream can otherwise leave
505
+ // the command with no acknowledgement and no rotated session.
506
+ if (isLarkNewSessionCommand(prompt)) {
507
+ await this.handleNewSessionCommand(event, sessionKey);
508
+ return;
509
+ }
505
510
  // Informational commands (/help, /models, /search) answer instantly and
506
511
  // also bypass the queue so they respond even while a turn is running.
507
512
  if (command && LARK_INFO_COMMANDS.has(command.name)) {
@@ -524,9 +529,9 @@ export class LarkMessageIngestor {
524
529
  await this.handleForkCommand(event, sessionKey);
525
530
  return;
526
531
  }
527
- // /cwd binds the conversation's working directory; bypasses queue.
532
+ // /cwd is retained only as a migration notice; Project owns workspace selection.
528
533
  if (command && command.name === "cwd") {
529
- await this.handleCwdCommand(event, sessionKey, command);
534
+ await this.handleCwdCommand(event);
530
535
  return;
531
536
  }
532
537
  // /runtime and /model open the interactive settings card; bypass queue.
@@ -540,10 +545,6 @@ export class LarkMessageIngestor {
540
545
  return;
541
546
  }
542
547
  await this.queue.enqueue(sessionKey, async () => {
543
- if (isLarkNewSessionCommand(prompt)) {
544
- await this.handleNewSessionCommand(event, sessionKey);
545
- return;
546
- }
547
548
  // Best-effort, off the turn's critical path: the directory only feeds
548
549
  // /resume, so a lagging or failed write must never delay the reply.
549
550
  void this.recordConversation(event, sessionKey, prompt);
@@ -559,11 +560,10 @@ export class LarkMessageIngestor {
559
560
  session_id: session.sessionId,
560
561
  provider: this.provider,
561
562
  });
563
+ // A session title is initialized from its first ordinary message and
564
+ // remains stable for the rest of that session.
565
+ this.registerSession(session.sessionId, prompt);
562
566
  }
563
- // Register the session's identity row (idempotent): the first turn fixes
564
- // its source + title; rotated (`/new`) and forked sessions land here on
565
- // their first turn too, so the control plane lists every Lark session.
566
- this.registerSession(session.sessionId, prompt);
567
567
  this.logMessageReceived(event, sessionKey, session.sessionId);
568
568
  const startedReactionId = await this.tryCreateReaction(event, sessionKey, LARK_WORK_STARTED_REACTION, session.sessionId);
569
569
  const reply = await this.buildReply(event, sessionKey, session.sessionId, prompt, session.cwd ?? this.cwd, await this.resolveTurnExecution(sessionKey, session));
@@ -810,39 +810,8 @@ export class LarkMessageIngestor {
810
810
  });
811
811
  }
812
812
  }
813
- async handleCwdCommand(event, sessionKey, command) {
814
- const session = await this.activeSessionStore.get(sessionKey);
815
- const currentCwd = session?.cwd ?? this.cwd;
816
- const arg = command.args.trim();
817
- if (!arg) {
818
- await this.sendReplyText(event, currentCwd ? `当前工作目录:${currentCwd}` : "当前使用默认工作目录。");
819
- return;
820
- }
821
- const base = currentCwd ?? this.allowedRoots[0] ?? process.cwd();
822
- const resolved = path.isAbsolute(arg) ? path.resolve(arg) : path.resolve(base, arg);
823
- if (!findInnermostAllowedRoot(resolved, this.allowedRoots)) {
824
- await this.sendReplyText(event, `目录不在允许范围内(需位于 AGENT_ALLOWED_DIRS 内):${resolved}`);
825
- return;
826
- }
827
- try {
828
- await mkdir(resolved, { recursive: true });
829
- }
830
- catch (error) {
831
- await this.sendReplyText(event, `创建目录失败:${toErrorMessage(error)}`);
832
- return;
833
- }
834
- await this.activeSessionStore.bindCwd(sessionKey, resolved);
835
- await this.sendReplyText(event, `已绑定工作目录:${resolved},并开启新会话。`);
836
- this.logger.log({
837
- event: "command.handled",
838
- tenant_key: event.tenant_key ?? event.sender.tenant_key,
839
- chat_id: event.message.chat_id,
840
- thread_id: event.message.thread_id,
841
- message_id: event.message.message_id,
842
- session_key: sessionKey,
843
- command: "cwd",
844
- provider: this.provider,
845
- });
813
+ async handleCwdCommand(event) {
814
+ await this.sendReplyText(event, "工作目录已改由 Rynx Project 管理;请在 Rynx 中创建或选择 Project。未选择时会使用该 Session 的独立临时目录。");
846
815
  }
847
816
  async handleResumeCommand(event, sessionKey, command) {
848
817
  const arg = command.args.trim();
@@ -955,8 +924,7 @@ export class LarkMessageIngestor {
955
924
  }
956
925
  async handleForkCommand(event, sessionKey) {
957
926
  const sender = this.messageSender;
958
- if (!this.capabilities?.forkSession ||
959
- !sender.sendText ||
927
+ if (!sender.sendText ||
960
928
  !sender.listMessages ||
961
929
  !sender.listChatMembers) {
962
930
  await this.sendReplyText(event, "当前不支持 /fork(仅 app_server 后端 + 飞书机器人能力)。");
@@ -1006,6 +974,22 @@ export class LarkMessageIngestor {
1006
974
  }
1007
975
  const header = `📋 原会话「${baseName}」记录(fork):`;
1008
976
  const body = transcript ? `${header}\n${transcript}` : header;
977
+ if (!this.sessionProvisioner) {
978
+ await this.sendReplyText(event, LARK_BACKEND_UNSUPPORTED);
979
+ return;
980
+ }
981
+ let newLocalThreadId;
982
+ try {
983
+ newLocalThreadId = await this.sessionProvisioner({
984
+ sessionKey: `fork:${tenantKey}:${event.message.message_id}`,
985
+ fromSessionId: session.sessionId,
986
+ title: `${baseName} - fork`,
987
+ });
988
+ }
989
+ catch (error) {
990
+ await this.sendReplyText(event, `fork 会话创建失败:${toErrorMessage(error)}`);
991
+ return;
992
+ }
1009
993
  let newSessionKey;
1010
994
  let newChatId = null;
1011
995
  try {
@@ -1041,31 +1025,16 @@ export class LarkMessageIngestor {
1041
1025
  await this.sendReplyText(event, `fork 失败:${toErrorMessage(error)}`);
1042
1026
  return;
1043
1027
  }
1044
- // Bind the new conversation to a forked Codex thread carrying context.
1045
- // Carry the source cwd so the forked thread's working dir stays consistent.
1046
- // The session id is opaque (no routing baked in); newSessionKey is the
1047
- // routing key the store maps to it.
1048
- if (!this.sessionProvisioner) {
1049
- await this.sendReplyText(event, LARK_BACKEND_UNSUPPORTED);
1050
- return;
1051
- }
1052
- let newLocalThreadId;
1053
- let forkResult;
1028
+ // Bind the new external conversation to the already-committed Machine
1029
+ // Session fork. Feishu group/topic creation remains channel-owned.
1054
1030
  try {
1055
- newLocalThreadId = await this.sessionProvisioner({
1056
- sessionKey: newSessionKey,
1057
- fromSessionId: session.sessionId,
1058
- });
1059
1031
  await this.activeSessionStore.set(newSessionKey, newLocalThreadId, session.cwd);
1060
- forkResult = await this.capabilities.forkSession(session.sessionId, newLocalThreadId);
1061
1032
  }
1062
1033
  catch (error) {
1063
- await this.sendReplyText(event, `fork 会话创建失败:${toErrorMessage(error)}`);
1034
+ await this.sendReplyText(event, `fork 已创建,但飞书会话绑定失败:${toErrorMessage(error)}`);
1064
1035
  return;
1065
1036
  }
1066
- const contextNote = forkResult.ok
1067
- ? "已带上当前会话上下文。"
1068
- : `但未能带上上下文:${this.capabilityErrorText(forkResult)}`;
1037
+ const contextNote = "已带上当前会话上下文。";
1069
1038
  // Surface a one-tap entry into the new conversation in the current chat.
1070
1039
  if (newChatId) {
1071
1040
  const entryCardJson = buildOpenChatCardJson(newChatId, `${baseName} - fork`);
@@ -1082,9 +1051,6 @@ export class LarkMessageIngestor {
1082
1051
  if (!sentCard) {
1083
1052
  await this.sendReplyText(event, `已 fork 到新群「${baseName} - fork」,点此进入:${buildChatApplink(newChatId)}(${contextNote})`);
1084
1053
  }
1085
- else if (!forkResult.ok) {
1086
- await this.sendReplyText(event, `提示:${contextNote}`);
1087
- }
1088
1054
  }
1089
1055
  else {
1090
1056
  // Topic thread: the root message is already visible in this group.
@@ -1119,6 +1085,7 @@ export class LarkMessageIngestor {
1119
1085
  includeRuntime,
1120
1086
  currentRuntime,
1121
1087
  currentModel,
1088
+ currentEffort,
1122
1089
  runtime: currentRuntime,
1123
1090
  model: currentModel ?? SETTINGS_DEFAULT_VALUE,
1124
1091
  effort: currentEffort ?? SETTINGS_DEFAULT_VALUE,
@@ -1333,7 +1300,6 @@ export class LarkMessageIngestor {
1333
1300
  const runtime = pending.includeRuntime && isAgentRuntimeId(pending.runtime)
1334
1301
  ? pending.runtime
1335
1302
  : pending.currentRuntime;
1336
- const runtimeChanged = runtime !== pending.currentRuntime;
1337
1303
  const model = pending.model === SETTINGS_DEFAULT_VALUE ? undefined : pending.model;
1338
1304
  const selectedModel = pending.models.find((candidate) => candidate.value === pending.model);
1339
1305
  const allowedEfforts = selectedModel?.supportedEfforts?.length
@@ -1342,10 +1308,9 @@ export class LarkMessageIngestor {
1342
1308
  const effort = pending.effort !== SETTINGS_DEFAULT_VALUE && allowedEfforts.includes(pending.effort)
1343
1309
  ? pending.effort
1344
1310
  : undefined;
1345
- // Only a runtime change rotates to a fresh session (its rollout/transcript
1346
- // store is runtime-specific). model / effort switch on the SAME thread —
1347
- // both lineages take them per-turn — so the conversation keeps its context.
1348
- const rotate = runtimeChanged;
1311
+ // Provider selects the immutable runtime implementation. Model and
1312
+ // reasoning effort are Turn settings and update the same idle Session.
1313
+ const rotate = runtime !== pending.currentRuntime;
1349
1314
  await this.activeSessionStore.applySettings(sessionKey, {
1350
1315
  runtime,
1351
1316
  model,
@@ -1748,7 +1713,7 @@ export class LarkMessageIngestor {
1748
1713
  // A user-triggered cancel needs no redundant chat reply (the card carries it).
1749
1714
  text: cancelled ? "" : `处理失败:${errorMessage}`,
1750
1715
  provider: started.provider,
1751
- model: started.model,
1716
+ ...(started.model ? { model: started.model } : {}),
1752
1717
  };
1753
1718
  }
1754
1719
  // The card carries the answer; only emit a plain-text reply when no card
@@ -1756,7 +1721,7 @@ export class LarkMessageIngestor {
1756
1721
  return {
1757
1722
  text: orchestrator.cardStarted ? "" : orchestrator.fallbackText.trim() || DEFAULT_EMPTY_RESPONSE,
1758
1723
  provider: started.provider,
1759
- model: started.model,
1724
+ ...(started.model ? { model: started.model } : {}),
1760
1725
  };
1761
1726
  }
1762
1727
  /**
@@ -1798,7 +1763,7 @@ export class LarkMessageIngestor {
1798
1763
  return {
1799
1764
  text: answer.trim() || DEFAULT_EMPTY_RESPONSE,
1800
1765
  provider: started.provider,
1801
- model: started.model,
1766
+ ...(started.model ? { model: started.model } : {}),
1802
1767
  };
1803
1768
  }
1804
1769
  catch (error) {