lark-remote 0.1.1 → 0.1.2

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.
@@ -1,4 +1,4 @@
1
- import type { Runner, AgentRunner } from '../runner/index.js';
1
+ import type { AgentRunner } from '../runner/index.js';
2
2
  import type { AgentRegistry } from '../runner/registry.js';
3
3
  import type { SessionReaderRegistry, SessionStore } from '../session/index.js';
4
4
  import type { AppConfig } from '../config/index.js';
@@ -51,7 +51,6 @@ export interface ActiveRunSnapshot {
51
51
  cacheCreationTokens?: number;
52
52
  }
53
53
  interface BridgeDeps {
54
- runner: Runner;
55
54
  connector: BridgeChannel;
56
55
  sessionStore: SessionStore;
57
56
  config: AppConfig;
@@ -93,7 +92,6 @@ interface BridgeDeps {
93
92
  * Runner/BridgeChannel seams.
94
93
  */
95
94
  export declare class Bridge {
96
- private runner;
97
95
  private connector;
98
96
  private sessionStore;
99
97
  private config;
@@ -7,7 +7,7 @@ import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
7
7
  import { markdownDiv } from '../card/collapsible.js';
8
8
  import { sessionEventPanel } from '../router/card-helpers.js';
9
9
  import { formatUsageStats } from '../router/index.js';
10
- import { agentDisplayName } from '../card/card-shared.js';
10
+ import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
11
11
  import { enforceCardBudget } from '../card/card-budget.js';
12
12
  import { normalizeResultUsage } from '../runner/common/usage.js';
13
13
  import { BashProcessRunner } from '../runner/index.js';
@@ -58,7 +58,6 @@ function terminalReactionEmoji(terminal) {
58
58
  * Runner/BridgeChannel seams.
59
59
  */
60
60
  export class Bridge {
61
- runner;
62
61
  connector;
63
62
  sessionStore;
64
63
  config;
@@ -89,7 +88,6 @@ export class Bridge {
89
88
  */
90
89
  activeBashRuns = new Map();
91
90
  constructor(deps) {
92
- this.runner = deps.runner;
93
91
  this.connector = deps.connector;
94
92
  this.sessionStore = deps.sessionStore;
95
93
  this.config = deps.config;
@@ -1104,14 +1102,7 @@ export class Bridge {
1104
1102
  {
1105
1103
  tag: 'column',
1106
1104
  width: 'auto',
1107
- elements: [
1108
- {
1109
- tag: 'button',
1110
- text: { tag: 'plain_text', content: '🔁 切换到此会话' },
1111
- type: 'primary',
1112
- behaviors: [{ type: 'callback', value: { cmd: 'resume.use', sessionId } }],
1113
- },
1114
- ],
1105
+ elements: [resumeUseButton(sessionId, agentKind)],
1115
1106
  },
1116
1107
  ],
1117
1108
  });
@@ -1,3 +1,4 @@
1
+ import { type AgentKind } from '../runner/index.js';
1
2
  interface TruncateOptions {
2
3
  suffix?: string;
3
4
  normalizeWhitespace?: boolean;
@@ -25,4 +26,11 @@ export declare function stopButton(runId: string): object;
25
26
  * Standardized: ✨ 新会话 + primary type
26
27
  */
27
28
  export declare function newSessionButton(): object;
29
+ /**
30
+ * Create a CardKit 2.0 "resume session" button with behaviors callback.
31
+ * Carries both sessionId and agent so the handler routes to the correct
32
+ * agent's session reader (P2 fix: completion cards from non-default agents
33
+ * must carry agent to avoid wrong-reader fallback).
34
+ */
35
+ export declare function resumeUseButton(sessionId: string, agent: AgentKind): object;
28
36
  export {};
@@ -96,4 +96,18 @@ export function newSessionButton() {
96
96
  behaviors: [{ type: 'callback', value: { cmd: 'new-session' } }],
97
97
  };
98
98
  }
99
+ /**
100
+ * Create a CardKit 2.0 "resume session" button with behaviors callback.
101
+ * Carries both sessionId and agent so the handler routes to the correct
102
+ * agent's session reader (P2 fix: completion cards from non-default agents
103
+ * must carry agent to avoid wrong-reader fallback).
104
+ */
105
+ export function resumeUseButton(sessionId, agent) {
106
+ return {
107
+ tag: 'button',
108
+ text: { tag: 'plain_text', content: '🔁 切换到此会话' },
109
+ type: 'primary',
110
+ behaviors: [{ type: 'callback', value: { cmd: 'resume.use', sessionId, agent } }],
111
+ };
112
+ }
99
113
  //# sourceMappingURL=card-shared.js.map
package/dist/index.js CHANGED
@@ -143,27 +143,24 @@ function setupInstanceLockAndHandlers(instanceLock, logger, configDir) {
143
143
  }
144
144
  /** Initialize per-workspace runner factory + agent registry. */
145
145
  function initializeRunner(config, configDir, cliArgs) {
146
- const createRunner = (workspace) => {
147
- const runner = new ClaudeRunner({
148
- binary: config.claude.binary,
149
- model: config.claude.model,
150
- effort: config.claude.effort,
151
- stopGraceMs: config.claude.stopGraceMs,
146
+ const agentRegistry = new AgentRegistry();
147
+ // P1-15: Claude factory reads from configContainer (not closure) so runtime
148
+ // config changes (binary, model, effort, stopGraceMs) take effect after
149
+ // bridge.setConfig() + clearRunners(). Same pattern as codex/pi/kimi.
150
+ agentRegistry.register('claude', (ws) => {
151
+ const container = agentRegistry.getConfigContainer();
152
+ const latestConfig = container?.current ?? config;
153
+ const claudeConfig = latestConfig.claude;
154
+ return new ClaudeRunner({
155
+ binary: claudeConfig.binary,
156
+ model: claudeConfig.model,
157
+ effort: claudeConfig.effort,
158
+ stopGraceMs: claudeConfig.stopGraceMs,
152
159
  settings: cliArgs.settings,
153
160
  pidDir: configDir,
154
- workspace,
161
+ workspace: ws,
155
162
  });
156
- return runner;
157
- };
158
- const runner = createRunner('default');
159
- runner.killOrphan();
160
- runner.registerExitHandlers();
161
- // Wire multi-agent registries. All five agents (claude/codex/opencode/pi/kimi)
162
- // are already registered. Bridge and Router depend on these registries instead of
163
- // importing session readers directly, so adding a new agent is "register factory + reader"
164
- // with zero upstream changes.
165
- const agentRegistry = new AgentRegistry();
166
- agentRegistry.register('claude', (ws) => createRunner(ws));
163
+ });
167
164
  // Register CodexExecRunner (codex exec --json, approval_policy=never).
168
165
  // Factory reads latest config from container.current so runtime config changes
169
166
  // (reasoningEffort, model, etc.) take effect after bridge.setConfig() + clearRunners().
@@ -215,7 +212,8 @@ function initializeRunner(config, configDir, cliArgs) {
215
212
  return new OpencodeExecRunner({
216
213
  binary: ocConfig?.binary ?? 'opencode',
217
214
  model,
218
- stopGraceMs: config.claude?.stopGraceMs ?? DEFAULT_STOP_GRACE_MS,
215
+ // P1-15: read stopGraceMs from latestConfig (not startup closure)
216
+ stopGraceMs: latestConfig.claude?.stopGraceMs ?? DEFAULT_STOP_GRACE_MS,
219
217
  pidDir: configDir,
220
218
  workspace: ws,
221
219
  sessionReader: opencodeSessionReader,
@@ -260,7 +258,7 @@ function initializeRunner(config, configDir, cliArgs) {
260
258
  binary: kimiConf?.binary ?? 'kimi',
261
259
  model: kimiConf?.model ?? 'kimi-code/k3',
262
260
  thinkingEffort: kimiConf?.thinkingEffort ?? 'max',
263
- stopGraceMs: config.claude?.stopGraceMs ?? DEFAULT_STOP_GRACE_MS,
261
+ stopGraceMs: latestConfig.claude?.stopGraceMs ?? DEFAULT_STOP_GRACE_MS,
264
262
  pidDir: configDir,
265
263
  workspace: ws,
266
264
  sessionReader: kimiSessionReader,
@@ -275,7 +273,7 @@ function initializeRunner(config, configDir, cliArgs) {
275
273
  agentRegistry.registerDisplayName('kimi', 'Kimi');
276
274
  // 设置全局 registry,供 agentDisplayName 等全局函数使用
277
275
  AgentRegistry.setGlobalInstance(agentRegistry);
278
- return { createRunner, runner, agentRegistry, sessionReaderRegistry };
276
+ return { agentRegistry, sessionReaderRegistry };
279
277
  }
280
278
  /** Setup message and card action handlers. */
281
279
  function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger) {
@@ -492,7 +490,7 @@ async function main() {
492
490
  if (cliArgs.settings) {
493
491
  logger.info(`claude.settings = ${cliArgs.settings}`);
494
492
  }
495
- const { runner, agentRegistry, sessionReaderRegistry } = initializeRunner(config, configDir, cliArgs);
493
+ const { agentRegistry, sessionReaderRegistry } = initializeRunner(config, configDir, cliArgs);
496
494
  const connector = new FeishuConnector(config);
497
495
  const startupContactStore = new StartupContactStore(path.join(configDir, 'startup-contact.json'));
498
496
  const binder = new OwnerBinder(startupContactStore);
@@ -507,7 +505,6 @@ async function main() {
507
505
  const sessionStore = new SessionStore(path.join(configDir, 'last-session.json'), config.defaultAgent);
508
506
  const workspaceStore = new WorkspaceStore(path.join(configDir, 'workspace.json'));
509
507
  const bridge = new Bridge({
510
- runner,
511
508
  connector,
512
509
  sessionStore,
513
510
  config,
@@ -26,7 +26,7 @@ interface CommandResult {
26
26
  * stop — interrupt current run
27
27
  * ls.file — send file only
28
28
  * ws.remove — delete workspace alias only
29
- * resume.use — set sessionId only
29
+ * resume.use — set sessionId + agent for correct reader routing
30
30
  * help.* — read-only help commands
31
31
  * order.delete — delete order only
32
32
  * order.exec is intercepted by index.ts at the enqueue boundary
@@ -43,7 +43,7 @@ import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
43
43
  * stop — interrupt current run
44
44
  * ls.file — send file only
45
45
  * ws.remove — delete workspace alias only
46
- * resume.use — set sessionId only
46
+ * resume.use — set sessionId + agent for correct reader routing
47
47
  * help.* — read-only help commands
48
48
  * order.delete — delete order only
49
49
  * order.exec is intercepted by index.ts at the enqueue boundary
@@ -617,25 +617,55 @@ export class CommandRouter {
617
617
  await this.bridge.sendResult({ text: '⚠️ 缺少新消息内容' }, ctx);
618
618
  return;
619
619
  }
620
+ // RACE FIX: register the replacement closure BEFORE any await. The old
621
+ // code awaited updateMessagePreview first, yielding to the microtask
622
+ // queue. Between that yield and the later setTaskReplacement call, the
623
+ // queue chain could advance: the task's begin path would find no
624
+ // replacement → the original stale closure would run → the user saw
625
+ // "消息已更新" but the agent executed the old prompt.
626
+ //
627
+ // By registering synchronously first, the begin path always finds the
628
+ // replacement if the task starts during the subsequent await. Both
629
+ // setTaskReplacement and the begin-path consumption are synchronous, so
630
+ // there is no interleaving window.
631
+ //
632
+ // D3/Step3:复用原任务 binding(不重新快照)+ 恢复 cwdOverride。
633
+ const inputTask = this.bridge.getQueuedTask(workspace, messageId);
634
+ if (!inputTask) {
635
+ // Task already left the queue (began or was cancelled) before we could
636
+ // register the replacement. The user must be told the edit failed.
637
+ await this.bridge.sendResult({ text: '⚠️ 任务已不在队列中(可能已开始执行或被撤销)' }, ctx);
638
+ return;
639
+ }
640
+ this.bridge.setTaskReplacement(workspace, messageId, async () => {
641
+ await this.handle(newMessage, ctx, { cwdOverride: workspace, binding: inputTask.binding });
642
+ });
620
643
  // Update the message preview (and editedMessage so handleQueueImmediate
621
644
  // re-enqueues the edited content instead of the stale original closure),
622
645
  // and build the updated queue card. Returning the card in the callback
623
646
  // response makes Feishu render it in place -- a toast-only response leaves
624
647
  // the card stuck in the edit state (Feishu keeps the pre-click card when
625
648
  // the callback response has no `card` field).
649
+ //
650
+ // After the synchronous replacement registration above, it is safe to
651
+ // await here: even if the queue chain advances and the replacement is
652
+ // consumed, the user-facing card update is best-effort (the executing card
653
+ // is updated by updateQueueCardToExecuting instead).
626
654
  const card = await this.bridge.updateMessagePreview(workspace, messageId, newMessage);
627
655
  if (!card) {
628
- await this.bridge.sendResult({ text: '⚠️ 任务已不在队列中' }, ctx);
629
- return;
656
+ // Task left the queue during the await (began or cancelled). The
657
+ // replacement was already consumed (begin path) or cleaned up
658
+ // (cancel/removeFromQueue path). Inform the user the edit didn't stick
659
+ // as a *card update*, but the replacement closure was already in effect.
660
+ if (this.bridge.hasTaskBegan(messageId)) {
661
+ return {
662
+ toast: { type: 'success', content: '消息已更新(任务已开始执行)' },
663
+ };
664
+ }
665
+ return {
666
+ toast: { type: 'info', content: '任务已不在队列中,编辑未生效' },
667
+ };
630
668
  }
631
- // 编辑即生效:自然轮到时(不经立即执行)也必须执行编辑后内容,而不是
632
- // enqueue 时闭包冻结的旧指令。setTaskReplacement 原位替换该任务槽位的闭包,
633
- // 保持队列位置;handleQueueImmediate 后续注册/刷新幂等覆盖。
634
- // D3/Step3:复用原任务 binding(不重新快照)+ 恢复 cwdOverride。
635
- const inputTask = this.bridge.getQueuedTask(workspace, messageId);
636
- this.bridge.setTaskReplacement(workspace, messageId, async () => {
637
- await this.handle(newMessage, ctx, { cwdOverride: workspace, binding: inputTask?.binding });
638
- });
639
669
  return {
640
670
  toast: { type: 'success', content: '消息已更新' },
641
671
  card: { type: 'raw', data: card },
@@ -1,13 +1,15 @@
1
1
  import type { AgentSession, AgentSessionContentEvent, AgentSessionUsage } from '../../runner/index.js';
2
2
  /**
3
- * Read cwd from a Claude session JSONL by scanning events for the first
4
- * `cwd` field. Claude CLI writes the absolute (symlink-resolved) cwd on
3
+ * Read the FIRST cwd from a Claude session JSONL. Used for display and
4
+ * diagnostics. Claude CLI writes the absolute (symlink-resolved) cwd on
5
5
  * every event that produces one; older versions wrote it once on init.
6
6
  * Returns the first non-empty value, or undefined if absent.
7
7
  *
8
- * This is the SOLE source of truth for cwd. The directory name under
9
- * ~/.claude/projects/ is lossy (encodes `/` AND `_` as `-`), so it cannot
10
- * be decoded back to a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
8
+ * For cwd GUARD checks (list/read), use `jsonlContainsCwd` which accepts
9
+ * any matching cwd field (required for relocated sessions whose first cwd
10
+ * is the pre-relocate path). The directory name under ~/.claude/projects/
11
+ * is lossy (encodes `/` AND `_` as `-`), so it cannot be decoded back to
12
+ * a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
11
13
  */
12
14
  export declare function readCwdFromJsonl(filePath: string): string | undefined;
13
15
  /**
@@ -22,14 +22,16 @@ function projectDirForCwd(cwd, projectsDir) {
22
22
  return path.join(projectsDir, encoded);
23
23
  }
24
24
  /**
25
- * Read cwd from a Claude session JSONL by scanning events for the first
26
- * `cwd` field. Claude CLI writes the absolute (symlink-resolved) cwd on
25
+ * Read the FIRST cwd from a Claude session JSONL. Used for display and
26
+ * diagnostics. Claude CLI writes the absolute (symlink-resolved) cwd on
27
27
  * every event that produces one; older versions wrote it once on init.
28
28
  * Returns the first non-empty value, or undefined if absent.
29
29
  *
30
- * This is the SOLE source of truth for cwd. The directory name under
31
- * ~/.claude/projects/ is lossy (encodes `/` AND `_` as `-`), so it cannot
32
- * be decoded back to a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
30
+ * For cwd GUARD checks (list/read), use `jsonlContainsCwd` which accepts
31
+ * any matching cwd field (required for relocated sessions whose first cwd
32
+ * is the pre-relocate path). The directory name under ~/.claude/projects/
33
+ * is lossy (encodes `/` AND `_` as `-`), so it cannot be decoded back to
34
+ * a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
33
35
  */
34
36
  export function readCwdFromJsonl(filePath) {
35
37
  const line = findJsonlLine(filePath, (l) => {
@@ -185,7 +187,7 @@ export function listClaudeSessions(cwd, opts = {}) {
185
187
  sessions = cached.sessions;
186
188
  }
187
189
  else {
188
- sessions = scanClaudeSessions(cwd, dir);
190
+ sessions = scanClaudeSessions(cwd, dir, opts.projectsDir);
189
191
  claudeListCache.set(cacheKey, { builtAt: Date.now(), sessions });
190
192
  // Bound the cache (map iteration order = insertion order).
191
193
  if (claudeListCache.size > LIST_CACHE_MAX_ENTRIES) {
@@ -196,8 +198,13 @@ export function listClaudeSessions(cwd, opts = {}) {
196
198
  }
197
199
  return limit === undefined ? sessions : sessions.slice(0, limit);
198
200
  }
199
- /** Uncached scan of a claude project dir (the expensive part being cached). */
200
- function scanClaudeSessions(cwd, dir) {
201
+ /**
202
+ * Scan a single project directory for jsonl files whose cwd field matches
203
+ * the requested cwd. Uses `jsonlContainsCwd` (any cwd field matches) rather
204
+ * than `readCwdFromJsonl` (first cwd only) so that relocated sessions whose
205
+ * first cwd is the pre-relocate path are still found. P1 fix 2026-08-10.
206
+ */
207
+ function scanDirForSessions(cwd, dir) {
201
208
  let files;
202
209
  try {
203
210
  files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
@@ -210,11 +217,10 @@ function scanClaudeSessions(cwd, dir) {
210
217
  const full = path.join(dir, f);
211
218
  try {
212
219
  const st = fs.statSync(full);
213
- // Verify the jsonl's cwd field actually matches. Defends against
214
- // directory-name collisions where a different cwd encoded to the same
215
- // directory name.
216
- const fileCwd = readCwdFromJsonl(full);
217
- if (fileCwd !== cwd)
220
+ // P1 fix: use jsonlContainsCwd (any match) instead of readCwdFromJsonl
221
+ // (first only). Defends against directory-name collisions AND includes
222
+ // relocated sessions whose first cwd is the pre-relocate path.
223
+ if (!jsonlContainsCwd(full, cwd))
218
224
  continue;
219
225
  const sessionId = f.slice(0, -'.jsonl'.length);
220
226
  sessions.push({
@@ -227,6 +233,97 @@ function scanClaudeSessions(cwd, dir) {
227
233
  getLogger().warn(`[session] skip ${f}: ${err.message}`);
228
234
  }
229
235
  }
236
+ return sessions;
237
+ }
238
+ /**
239
+ * Scan OTHER project subdirectories for jsonl files whose cwd matches the
240
+ * requested cwd. Used as a fallback when the primary dir doesn't contain the
241
+ * session (EnterWorktree moves the transcript to the new cwd's project dir).
242
+ * Skips the primary dir (already scanned) and any sessionIds already found
243
+ * (avoids duplicates). P1 fix 2026-08-10.
244
+ */
245
+ function scanRelocatedSessions(cwd, primaryDir, projectsDir, seenIds) {
246
+ let entries;
247
+ try {
248
+ entries = fs.readdirSync(projectsDir);
249
+ }
250
+ catch {
251
+ return [];
252
+ }
253
+ const sessions = [];
254
+ for (const entry of entries.sort()) {
255
+ const subdir = path.join(projectsDir, entry);
256
+ // Skip the primary dir (already scanned) and non-directories
257
+ if (subdir === primaryDir)
258
+ continue;
259
+ try {
260
+ if (!fs.statSync(subdir).isDirectory())
261
+ continue;
262
+ }
263
+ catch {
264
+ continue;
265
+ }
266
+ let files;
267
+ try {
268
+ files = fs
269
+ .readdirSync(subdir)
270
+ .filter((f) => f.endsWith('.jsonl'))
271
+ .sort();
272
+ }
273
+ catch {
274
+ continue;
275
+ }
276
+ for (const f of files) {
277
+ const full = path.join(subdir, f);
278
+ const sessionId = f.slice(0, -'.jsonl'.length);
279
+ // Skip sessions already found in the primary dir scan
280
+ if (seenIds.has(sessionId))
281
+ continue;
282
+ try {
283
+ const st = fs.statSync(full);
284
+ if (!jsonlContainsCwd(full, cwd))
285
+ continue;
286
+ sessions.push({
287
+ sessionId,
288
+ summary: truncate(summarizeSession(full), 60, { normalizeWhitespace: true }),
289
+ mtime: st.mtimeMs,
290
+ });
291
+ }
292
+ catch (err) {
293
+ getLogger().warn(`[session] skip ${f}: ${err.message}`);
294
+ }
295
+ }
296
+ }
297
+ return sessions;
298
+ }
299
+ /** Uncached scan of a claude project dir (the expensive part being cached).
300
+ * P1 fix: cwd guard now uses `jsonlContainsCwd` (any cwd field matches)
301
+ * instead of `readCwdFromJsonl` (first cwd only). Relocated sessions
302
+ * (EnterWorktree) contain multiple cwd values; the first one is the
303
+ * pre-relocate path, which would be rejected by the old first-cwd-only
304
+ * check. See regression 2026-08-10 list vs read inconsistency.
305
+ */
306
+ function scanClaudeSessions(cwd, dir, projectsDir) {
307
+ const sessions = [];
308
+ // Phase 1: scan the primary project dir (fast path)
309
+ const primarySessions = scanDirForSessions(cwd, dir);
310
+ sessions.push(...primarySessions);
311
+ // Phase 2: cross-dir fallback for relocated sessions whose transcript
312
+ // was moved away from the primary dir. Only scan OTHER project subdirs
313
+ // when the primary scan found nothing (the common case: primary dir has
314
+ // all sessions, no need to scan elsewhere). When primary has results,
315
+ // relocated sessions in other dirs that match this cwd are already
316
+ // handled by jsonlContainsCwd on files in the primary dir (the file was
317
+ // moved TO the primary dir's encoded path). Skip to avoid O(all projects)
318
+ // overhead on every cached-miss list call.
319
+ const effectiveProjectsDir = projectsDir ?? defaultProjectsDir();
320
+ if (primarySessions.length === 0 &&
321
+ effectiveProjectsDir &&
322
+ dir.startsWith(effectiveProjectsDir)) {
323
+ const seenIds = new Set(primarySessions.map((s) => s.sessionId));
324
+ const crossDirSessions = scanRelocatedSessions(cwd, dir, effectiveProjectsDir, seenIds);
325
+ sessions.push(...crossDirSessions);
326
+ }
230
327
  // Same-mtime ties: secondary key keeps the full order deterministic.
231
328
  sessions.sort((a, b) => b.mtime - a.mtime || a.sessionId.localeCompare(b.sessionId));
232
329
  return sessions;
@@ -283,9 +380,18 @@ function isSessionActive(filePath, mtimeMs, now = Date.now()) {
283
380
  * used for session-active detection. Returns false if the file doesn't exist.
284
381
  */
285
382
  export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
286
- const dir = projectDirForCwd(cwd, opts.projectsDir ?? defaultProjectsDir());
287
- const filePath = path.join(dir, `${sessionId}.jsonl`);
288
- if (!fs.existsSync(filePath))
383
+ const projectsDir = opts.projectsDir ?? defaultProjectsDir();
384
+ const dir = projectDirForCwd(cwd, projectsDir);
385
+ let filePath = path.join(dir, `${sessionId}.jsonl`);
386
+ if (!fs.existsSync(filePath)) {
387
+ // EnterWorktree relocate: transcript moved to the new cwd's project dir.
388
+ const relocated = findSessionFileInProjects(sessionId, projectsDir);
389
+ if (!relocated)
390
+ return false;
391
+ filePath = relocated;
392
+ }
393
+ // Verify the jsonl belongs to the requested cwd (any cwd field may match).
394
+ if (!jsonlContainsCwd(filePath, cwd))
289
395
  return false;
290
396
  try {
291
397
  const st = fs.statSync(filePath);
@@ -70,6 +70,7 @@ export declare function listCodexRollouts(opts?: ListCodexRolloutsOptions): {
70
70
  export declare function readCodexSessionContent(sessionId: string, opts?: {
71
71
  codexHome?: string;
72
72
  maxEvents?: number;
73
+ cwd?: string;
73
74
  }): SessionContent;
74
75
  /**
75
76
  * Check if a session is still active (recently updated).
@@ -253,6 +253,12 @@ export function readCodexSessionContent(sessionId, opts = {}) {
253
253
  if (!entry) {
254
254
  return { events: [] };
255
255
  }
256
+ // Cwd guard: when a cwd is provided, the session's working directory must
257
+ // match. Without this, /resume <id> finds sessions by global ID lookup and
258
+ // allows resuming a session from workspace B while the user is in workspace A.
259
+ if (opts.cwd && entry.cwd !== opts.cwd) {
260
+ return { events: [] };
261
+ }
256
262
  const rollout = readCodexRollout(entry.filePath);
257
263
  if (!rollout || rollout.threadId !== sessionId) {
258
264
  return { events: [] };
@@ -13,7 +13,7 @@ export declare class CodexSessionReader implements AgentSessionReader {
13
13
  total: number;
14
14
  };
15
15
  getNewestSession(cwd: string): AgentSession | null;
16
- readSessionContent(sessionId: string, _cwd: string, opts?: {
16
+ readSessionContent(sessionId: string, cwd: string, opts?: {
17
17
  maxEvents?: number;
18
18
  }): SessionContent;
19
19
  isSessionActive(sessionId: string, _cwd: string): boolean;
@@ -27,10 +27,11 @@ export class CodexSessionReader {
27
27
  getNewestSession(cwd) {
28
28
  return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
29
29
  }
30
- readSessionContent(sessionId, _cwd, opts) {
30
+ readSessionContent(sessionId, cwd, opts) {
31
31
  return readCodexSessionContent(sessionId, {
32
32
  codexHome: this.codexHome,
33
33
  maxEvents: opts?.maxEvents,
34
+ cwd,
34
35
  });
35
36
  }
36
37
  isSessionActive(sessionId, _cwd) {
@@ -283,27 +283,57 @@ export class KimiSessionReader {
283
283
  * Read session content (events + usage)
284
284
  */
285
285
  readSessionContent(sessionId, cwd, opts) {
286
- // Find session directory from index
287
- const sessionDir = this.findSessionDirFromIndex(sessionId);
286
+ // Find session directory from index (also retrieve index entry for
287
+ // fallback cwd guard when state.json is missing or unparseable)
288
+ const indexEntry = this.findSessionIndexEntry(sessionId);
289
+ const sessionDir = indexEntry?.sessionDir ?? null;
288
290
  if (!sessionDir) {
289
291
  return { events: [] };
290
292
  }
291
293
  const statePath = path.join(sessionDir, 'state.json');
294
+ let realCwd;
295
+ try {
296
+ realCwd = fs.realpathSync(cwd);
297
+ }
298
+ catch {
299
+ getLogger().warn(`[kimi-session-reader] fs.realpathSync failed for cwd=${cwd} (session ${sessionId}), returning empty`);
300
+ return { events: [] };
301
+ }
302
+ let cwdGuardPassed = false;
292
303
  try {
293
304
  if (fs.existsSync(statePath)) {
294
305
  const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
295
- const realCwd = fs.realpathSync(cwd);
296
306
  const guardResult = checkCwdGuard(state, realCwd);
297
307
  if (guardResult === 'failed') {
298
308
  return { events: [] };
299
309
  }
300
310
  if (guardResult === 'unverifiable') {
301
- getLogger().warn(`[kimi-session-reader] state.json has no workDir/cwd field for session ${sessionId}, skipping cwd guard`);
311
+ getLogger().warn(`[kimi-session-reader] state.json has no workDir/cwd field for session ${sessionId}, falling back to index workDir`);
312
+ // Fall through to index-based guard below
313
+ }
314
+ else {
315
+ // verified — state.json cwd matches
316
+ cwdGuardPassed = true;
302
317
  }
303
318
  }
304
319
  }
305
320
  catch {
306
- // Unverifiable — skip the check
321
+ // state.json unparseable — fall through to index-based guard
322
+ getLogger().warn(`[kimi-session-reader] state.json parse failed for session ${sessionId}, falling back to index workDir`);
323
+ }
324
+ // Fallback: when state.json is missing, unparseable, or has no workDir/cwd,
325
+ // use the workDir from the session index entry as a secondary guard.
326
+ // This prevents /resume <id> from accessing a session belonging to a
327
+ // different workspace when state.json is unavailable.
328
+ if (!cwdGuardPassed) {
329
+ const indexWorkDir = indexEntry?.workDir;
330
+ if (indexWorkDir && indexWorkDir !== realCwd) {
331
+ return { events: [] };
332
+ }
333
+ // When index also has no workDir, fall through (unverifiable from all sources)
334
+ if (!indexWorkDir) {
335
+ getLogger().warn(`[kimi-session-reader] no cwd source (state.json + index) for session ${sessionId}, skipping cwd guard`);
336
+ }
307
337
  }
308
338
  const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
309
339
  if (!fs.existsSync(wirePath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",