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.
- package/dist/bridge/index.d.ts +1 -3
- package/dist/bridge/index.js +2 -11
- package/dist/card/card-shared.d.ts +8 -0
- package/dist/card/card-shared.js +14 -0
- package/dist/index.js +20 -23
- package/dist/router/index.d.ts +1 -1
- package/dist/router/index.js +41 -11
- package/dist/session/claude/sessions.d.ts +7 -5
- package/dist/session/claude/sessions.js +122 -16
- package/dist/session/codex/rollout-reader.d.ts +1 -0
- package/dist/session/codex/rollout-reader.js +6 -0
- package/dist/session/codex/sessions.d.ts +1 -1
- package/dist/session/codex/sessions.js +2 -1
- package/dist/session/kimi/sessions.js +35 -5
- package/package.json +1 -1
package/dist/bridge/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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;
|
package/dist/bridge/index.js
CHANGED
|
@@ -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 {};
|
package/dist/card/card-shared.js
CHANGED
|
@@ -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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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 {
|
|
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,
|
package/dist/router/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/router/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
629
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
26
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
/**
|
|
200
|
-
|
|
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
|
-
//
|
|
214
|
-
// directory-name collisions
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
|
287
|
-
const
|
|
288
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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},
|
|
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
|
-
//
|
|
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.
|
|
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",
|