lark-remote 0.1.4 → 0.1.5
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 +21 -0
- package/README.en.md +4 -1
- package/README.md +4 -1
- package/dist/bridge/index.js +22 -0
- package/dist/card/run-renderer.js +1 -1
- package/dist/card/run-state.js +7 -0
- package/dist/config/dir.d.ts +1 -0
- package/dist/config/dir.js +5 -1
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +2 -0
- package/dist/connector/index.js +20 -0
- package/dist/index.js +51 -1
- package/dist/router/config/index.d.ts +0 -10
- package/dist/router/config/index.js +0 -17
- package/dist/router/index.d.ts +36 -1
- package/dist/router/index.js +231 -37
- package/dist/runner/codex/argv.d.ts +1 -1
- package/dist/runner/codex/argv.js +1 -1
- package/dist/runner/common/runner-utils.d.ts +12 -0
- package/dist/runner/common/runner-utils.js +21 -0
- package/dist/runner/common/spawning-runner.js +9 -1
- package/dist/session/claude/session-index.d.ts +93 -0
- package/dist/session/claude/session-index.js +374 -0
- package/dist/session/claude/sessions.d.ts +0 -18
- package/dist/session/claude/sessions.js +102 -319
- package/dist/session/codex/rollout-reader.js +3 -3
- package/dist/update/index.d.ts +10 -0
- package/dist/update/index.js +11 -0
- package/dist/update/install.d.ts +29 -0
- package/dist/update/install.js +93 -0
- package/dist/update/startup-hint.d.ts +5 -0
- package/dist/update/startup-hint.js +11 -0
- package/dist/update/version-check.d.ts +31 -0
- package/dist/update/version-check.js +149 -0
- package/package.json +1 -14
package/dist/router/index.js
CHANGED
|
@@ -11,8 +11,9 @@ import { activeRunUsage, formatUsageStats } from './utils.js';
|
|
|
11
11
|
import { markdownDiv, sessionEventPanel } from './card-helpers.js';
|
|
12
12
|
import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
|
|
13
13
|
import { atomicWrite } from '../persistence/atomic-write.js';
|
|
14
|
+
import { checkLatestVersion as defaultCheckLatestVersion, isNewer as defaultIsNewer, runInstallLatest as defaultRunInstallLatest, } from '../update/index.js';
|
|
14
15
|
/** Config card builder - delegates to per-agent builders */
|
|
15
|
-
import { getConfigBuilder, listRegisteredAgents
|
|
16
|
+
import { getConfigBuilder, listRegisteredAgents } from './config/index.js';
|
|
16
17
|
import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
|
|
17
18
|
import { buildConfigCardFromTabs } from './config/common/render.js';
|
|
18
19
|
/** Max events to read for auto-resume and /resume cards.
|
|
@@ -114,6 +115,12 @@ export class CommandRouter {
|
|
|
114
115
|
*/
|
|
115
116
|
restartSpawner;
|
|
116
117
|
idleTimeoutMs;
|
|
118
|
+
/** Dev mode flag: --dev means the bridge was started from source (bun src/index.ts). */
|
|
119
|
+
devMode;
|
|
120
|
+
/** Cache file for version checks (<configDir>/update-cache.json). */
|
|
121
|
+
updateCachePath;
|
|
122
|
+
/** Injected update functions (for testability; defaults to real implementation). */
|
|
123
|
+
updateFns;
|
|
117
124
|
pendingConfig = null; // /config 卡片编辑暂存区
|
|
118
125
|
/**
|
|
119
126
|
* Monotonic counter minting unique internal keys for order.exec enqueue
|
|
@@ -161,6 +168,13 @@ export class CommandRouter {
|
|
|
161
168
|
this.bridge.setIdleTimeout(this.idleTimeoutMs);
|
|
162
169
|
}
|
|
163
170
|
this.sessionReaderRegistry = opts.sessionReaderRegistry;
|
|
171
|
+
this.devMode = opts.devMode ?? false;
|
|
172
|
+
this.updateCachePath = opts.updateCachePath;
|
|
173
|
+
this.updateFns = {
|
|
174
|
+
checkLatestVersion: opts.updateFns?.checkLatestVersion ?? defaultCheckLatestVersion,
|
|
175
|
+
isNewer: opts.updateFns?.isNewer ?? defaultIsNewer,
|
|
176
|
+
runInstallLatest: opts.updateFns?.runInstallLatest ?? defaultRunInstallLatest,
|
|
177
|
+
};
|
|
164
178
|
}
|
|
165
179
|
/**
|
|
166
180
|
* Resolve the active session reader for the current defaultAgent.
|
|
@@ -322,7 +336,7 @@ export class CommandRouter {
|
|
|
322
336
|
}
|
|
323
337
|
return;
|
|
324
338
|
}
|
|
325
|
-
//
|
|
339
|
+
// Design constraint: miss paths must reply via bridge.sendResult —
|
|
326
340
|
// don't silently swallow unknown card actions.
|
|
327
341
|
getLogger().warn(`[router] unknown card action: ${value.cmd}`);
|
|
328
342
|
await this.bridge.sendResult({ text: `⚠️ 未知的卡片操作: ${value.cmd}` }, ctx);
|
|
@@ -1191,7 +1205,7 @@ export class CommandRouter {
|
|
|
1191
1205
|
this.config = setConfigValues(this.configPath, this.config, updates);
|
|
1192
1206
|
// P1-6:运行时传播(idleTimeout / clearRunners / syncAgentChoices /
|
|
1193
1207
|
// defaultAgent 切换的 session 处理)抽到共享方法,与文本直写路径共用。
|
|
1194
|
-
const
|
|
1208
|
+
const switchResult = this.propagateConfigSave(oldDefaultAgent, updates, ctx);
|
|
1195
1209
|
this.pendingConfig = null;
|
|
1196
1210
|
// 原地更新卡片:保存后卡片刷新为已保存状态(2026-07-04)
|
|
1197
1211
|
// 2026-08-03: 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
|
|
@@ -1202,14 +1216,15 @@ export class CommandRouter {
|
|
|
1202
1216
|
catch (refreshErr) {
|
|
1203
1217
|
getLogger().warn(`[router] config.save 卡片刷新失败(保存已成功): ${refreshErr.message}`);
|
|
1204
1218
|
}
|
|
1205
|
-
// 2026-08-
|
|
1206
|
-
//
|
|
1207
|
-
// toast
|
|
1208
|
-
if (
|
|
1209
|
-
const
|
|
1219
|
+
// 2026-08-13: agent 切换时发送 Resume 卡片(替代纯文本通知),
|
|
1220
|
+
// 让用户直观看到新 agent 的会话状态和历史。
|
|
1221
|
+
// 发送失败(resolve false)时兜底回退 toast 即时反馈。
|
|
1222
|
+
if (switchResult.notice) {
|
|
1223
|
+
const switchCard = this.buildConfigSwitchCard(switchResult, ctx);
|
|
1224
|
+
const sent = await this.bridge.sendResult(switchCard, ctx);
|
|
1210
1225
|
if (!sent) {
|
|
1211
1226
|
return {
|
|
1212
|
-
toast: { type: 'info', content:
|
|
1227
|
+
toast: { type: 'info', content: switchResult.notice },
|
|
1213
1228
|
};
|
|
1214
1229
|
}
|
|
1215
1230
|
}
|
|
@@ -1311,7 +1326,7 @@ export class CommandRouter {
|
|
|
1311
1326
|
*
|
|
1312
1327
|
* @param updates 以 config 路径形式给出的变更 key(卡片路径来自 diffConfig;
|
|
1313
1328
|
* 直写路径传 mapAgentKey(key),保证 pi./codex./opencode./kimi. 命中 agents.*)。
|
|
1314
|
-
* @returns
|
|
1329
|
+
* @returns 结构化切换结果(含 notice 文案 + 新 agent/sessionId 信息)
|
|
1315
1330
|
*/
|
|
1316
1331
|
propagateConfigSave(oldDefaultAgent, updates, ctx) {
|
|
1317
1332
|
this.bridge.setConfig(this.config);
|
|
@@ -1336,13 +1351,12 @@ export class CommandRouter {
|
|
|
1336
1351
|
// 同时清除新 agent 的 sessionId(等效于 /new 命令),
|
|
1337
1352
|
// 并支持 session 恢复 - 切换回来时如果条件满足则恢复之前的 session。
|
|
1338
1353
|
let switchNotice;
|
|
1354
|
+
let switchNewAgent;
|
|
1355
|
+
let switchSessionId;
|
|
1339
1356
|
if (hasDefaultAgentChange) {
|
|
1340
1357
|
const newAgent = this.config.defaultAgent;
|
|
1358
|
+
switchNewAgent = newAgent;
|
|
1341
1359
|
// Step 0: 在清空 old 之前计算「用户活动」基线差(arrival 基线):
|
|
1342
|
-
// arrivalSessions[old] 是用户最近一次经 config.save 切换「到达」该 agent
|
|
1343
|
-
// 时被赋予的 session id(清空到达 = '')。当前 session 与到达基线不一致
|
|
1344
|
-
// ⇒ 用户在 oldAgent 上产生过用户活动(发消息 /resume /new /cd 等,均
|
|
1345
|
-
// 不更新 arrival),离开时恢复必须被阻断。
|
|
1346
1360
|
const oldSessionId = this.sessionStore.getSessionId(ctx.userId, oldDefaultAgent);
|
|
1347
1361
|
const oldArrivalSessionId = this.sessionStore.getArrivalSessionId(ctx.userId, oldDefaultAgent);
|
|
1348
1362
|
const userChangedOld = (oldSessionId ?? '') !== (oldArrivalSessionId ?? '');
|
|
@@ -1352,48 +1366,36 @@ export class CommandRouter {
|
|
|
1352
1366
|
}
|
|
1353
1367
|
// Step 2: 清除旧 agent 的 session(保存后清除,为新会话腾出空间)
|
|
1354
1368
|
this.sessionStore.clearSessionId(ctx.userId, oldDefaultAgent);
|
|
1355
|
-
// Step 2.5:
|
|
1356
|
-
// 显式改选了 new 的 session(/resume 不更新 arrival),切换时必须让选择存活。
|
|
1357
|
-
// 优先级:显式选择 > 停车恢复 > 清空;newSessionId 为空(防御)不触发本分支。
|
|
1369
|
+
// Step 2.5: 显式选择判定
|
|
1358
1370
|
const newSessionId = this.sessionStore.getSessionId(ctx.userId, newAgent);
|
|
1359
1371
|
const newArrivalSessionId = this.sessionStore.getArrivalSessionId(ctx.userId, newAgent);
|
|
1360
1372
|
const userSelectedNew = !!newSessionId && (newSessionId ?? '') !== (newArrivalSessionId ?? '');
|
|
1361
|
-
// Step 3:
|
|
1362
|
-
// 没有用户活动(session 未变于到达基线)。判断方法:arrival 基线差
|
|
1363
|
-
// userChangedOld 为 false 时,oldAgent 的 session 只可能是 config.save
|
|
1364
|
-
// 自动恢复/清空得到的,不构成用户活动,恢复不被阻断。
|
|
1373
|
+
// Step 3: 恢复判定
|
|
1365
1374
|
const previousSessionId = this.sessionStore.getPreviousSessionId(ctx.userId, newAgent);
|
|
1366
1375
|
const canRestore = !!previousSessionId && !userChangedOld;
|
|
1367
1376
|
if (userSelectedNew) {
|
|
1368
|
-
// 显式选择优先于停车恢复与用户活动阻断:保留 sessions[new](不动),更新
|
|
1369
|
-
// arrival 基线为所选 session;prev[new] 停车位原样保留(下次离开时会被
|
|
1370
|
-
// sessions[new] 覆盖停车)。
|
|
1371
1377
|
this.sessionStore.setArrivalSessionId(ctx.userId, newAgent, newSessionId);
|
|
1372
1378
|
const newAgentName = agentDisplayName(newAgent);
|
|
1373
1379
|
switchNotice = `已切换到 ${newAgentName},已使用所选 session,sessionId: ${newSessionId}`;
|
|
1380
|
+
switchSessionId = newSessionId;
|
|
1374
1381
|
}
|
|
1375
1382
|
else if (canRestore) {
|
|
1376
|
-
// 可以恢复之前的 session
|
|
1377
1383
|
this.sessionStore.setSessionId(ctx.userId, newAgent, previousSessionId);
|
|
1378
|
-
// 恢复分支消费 previousSessions,并记录新的到达基线(自动恢复的 session
|
|
1379
|
-
// 不构成用户活动,后续离开时 userChangedOld 仍为 false)
|
|
1380
1384
|
this.sessionStore.clearPreviousSessionId(ctx.userId, newAgent);
|
|
1381
1385
|
this.sessionStore.setArrivalSessionId(ctx.userId, newAgent, previousSessionId);
|
|
1382
1386
|
const newAgentName = agentDisplayName(newAgent);
|
|
1383
1387
|
switchNotice = `已切换到 ${newAgentName},将继续之前的 session,sessionId: ${previousSessionId}`;
|
|
1388
|
+
switchSessionId = previousSessionId;
|
|
1384
1389
|
}
|
|
1385
1390
|
else {
|
|
1386
|
-
// Step 4: 清除新 agent 的 session(等效于 /new)
|
|
1387
1391
|
this.sessionStore.clearSessionId(ctx.userId, newAgent, { clearSessionCwd: true });
|
|
1388
|
-
// 清空分支不清除 previousSessions[newAgent](停车语义:被拒绝的恢复
|
|
1389
|
-
// 机会保留,未来无用户活动的返回仍可恢复);记录清空到达基线
|
|
1390
1392
|
this.sessionStore.setArrivalSessionId(ctx.userId, newAgent, '');
|
|
1391
1393
|
const newAgentName = agentDisplayName(newAgent);
|
|
1392
1394
|
switchNotice = `已切换到 ${newAgentName},session 已清空,下次消息将启动新对话`;
|
|
1395
|
+
switchSessionId = '';
|
|
1393
1396
|
}
|
|
1394
1397
|
}
|
|
1395
1398
|
// 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
|
|
1396
|
-
// 检测是否有当前 agent 的配置变更
|
|
1397
1399
|
const currentAgent = this.config.defaultAgent;
|
|
1398
1400
|
const currentAgentConfigKey = `agents.${currentAgent}`;
|
|
1399
1401
|
const hasCurrentAgentUpdate = Object.keys(updates).some((k) => k === currentAgentConfigKey || k.startsWith(`${currentAgentConfigKey}.`));
|
|
@@ -1404,11 +1406,10 @@ export class CommandRouter {
|
|
|
1404
1406
|
this.bridge.setConfig(this.config);
|
|
1405
1407
|
}
|
|
1406
1408
|
// 2026-07-13: 任何 agent 配置变更或 defaultAgent 切换,都清除 runner 缓存
|
|
1407
|
-
// 否则 runner 仍使用旧配置值
|
|
1408
1409
|
if (hasDefaultAgentChange || hasAgentConfigChange) {
|
|
1409
1410
|
this.bridge.clearRunners();
|
|
1410
1411
|
}
|
|
1411
|
-
return switchNotice;
|
|
1412
|
+
return { notice: switchNotice, newAgent: switchNewAgent, sessionId: switchSessionId };
|
|
1412
1413
|
}
|
|
1413
1414
|
async executeCommand(message, ctx) {
|
|
1414
1415
|
const parts = message.slice(1).split(/\s+/);
|
|
@@ -1453,6 +1454,9 @@ export class CommandRouter {
|
|
|
1453
1454
|
case 'order':
|
|
1454
1455
|
case 'o':
|
|
1455
1456
|
return this.cmdOrder(args, ctx);
|
|
1457
|
+
case 'update':
|
|
1458
|
+
case 'u':
|
|
1459
|
+
return await this.cmdUpdate(args, ctx);
|
|
1456
1460
|
default:
|
|
1457
1461
|
return { text: `未知命令 /${cmd},输入 /help 查看可用命令` };
|
|
1458
1462
|
}
|
|
@@ -1472,6 +1476,7 @@ export class CommandRouter {
|
|
|
1472
1476
|
{ cmd: 'active', label: '/active', desc: '查看所有正在进行中的 session' },
|
|
1473
1477
|
{ cmd: 'exit', label: '/exit /e', desc: '退出 bridge' },
|
|
1474
1478
|
{ cmd: 'restart', label: '/restart', desc: '重启 bridge(新进程,config 不变)' },
|
|
1479
|
+
{ cmd: 'update', label: '/update /u', desc: '检查并升级到最新版本' },
|
|
1475
1480
|
{ cmd: 'status', label: '/status /s', desc: '显示当前状态' },
|
|
1476
1481
|
{ cmd: 'reconnect', label: '/reconnect', desc: '重连飞书' },
|
|
1477
1482
|
].sort((a, b) => a.label.length - b.label.length || a.cmd.localeCompare(b.cmd));
|
|
@@ -1658,6 +1663,61 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
1658
1663
|
return { text: `重启失败:${err.message},旧进程仍在运行` };
|
|
1659
1664
|
}
|
|
1660
1665
|
}
|
|
1666
|
+
/**
|
|
1667
|
+
* /update: check for newer version and upgrade if available.
|
|
1668
|
+
* /update check: only check, don't upgrade.
|
|
1669
|
+
*
|
|
1670
|
+
* Flow: check version → if newer, install → spawn replacement bridge → exit.
|
|
1671
|
+
* Reuses /restart's restartSpawner + pendingExit mechanism.
|
|
1672
|
+
*/
|
|
1673
|
+
async cmdUpdate(args, ctx) {
|
|
1674
|
+
const checkOnly = args[0] === 'check';
|
|
1675
|
+
// 1. Version query(/update check 始终绕过缓存,/update 用 TTL 缓存)
|
|
1676
|
+
let current;
|
|
1677
|
+
let latest;
|
|
1678
|
+
try {
|
|
1679
|
+
const result = await this.updateFns.checkLatestVersion({
|
|
1680
|
+
cachePath: this.updateCachePath,
|
|
1681
|
+
bypassCache: checkOnly,
|
|
1682
|
+
});
|
|
1683
|
+
current = result.current;
|
|
1684
|
+
latest = result.latest;
|
|
1685
|
+
}
|
|
1686
|
+
catch (err) {
|
|
1687
|
+
return { text: `❌ 版本检查失败: ${err.message}` };
|
|
1688
|
+
}
|
|
1689
|
+
if (!this.updateFns.isNewer(current, latest)) {
|
|
1690
|
+
return { text: `✅ 已是最新版本 ${current}` };
|
|
1691
|
+
}
|
|
1692
|
+
if (checkOnly) {
|
|
1693
|
+
return { text: `📦 有新版本 ${latest} 可用(当前 ${current}),发送 /update 升级` };
|
|
1694
|
+
}
|
|
1695
|
+
// 2. Dev mode guard
|
|
1696
|
+
if (this.devMode) {
|
|
1697
|
+
return { text: '⚠️ 开发模式不支持自更新,请 git pull 后重新构建' };
|
|
1698
|
+
}
|
|
1699
|
+
// 3. Install (send intermediate status before the blocking operation)
|
|
1700
|
+
await this.bridge.sendResult({ text: `⬆️ 正在升级 ${current} → ${latest} ...` }, ctx);
|
|
1701
|
+
const installResult = await this.updateFns.runInstallLatest();
|
|
1702
|
+
if (!installResult.success) {
|
|
1703
|
+
return { text: `❌ 升级失败: ${installResult.error}` };
|
|
1704
|
+
}
|
|
1705
|
+
// 4. Restart (reuse /restart's restartSpawner + pendingExit)
|
|
1706
|
+
if (!this.restartSpawner) {
|
|
1707
|
+
return { text: `✅ 已升级到 ${latest},请手动 /restart 重启 bridge` };
|
|
1708
|
+
}
|
|
1709
|
+
try {
|
|
1710
|
+
const pid = this.restartSpawner();
|
|
1711
|
+
this.pendingExit = true;
|
|
1712
|
+
return { text: `✅ 升级成功 (${current} → ${latest}),正在重启(新进程 pid ${pid})…` };
|
|
1713
|
+
}
|
|
1714
|
+
catch (err) {
|
|
1715
|
+
getLogger().error('[router] update restart spawn failed:', err);
|
|
1716
|
+
return {
|
|
1717
|
+
text: `✅ 已升级到 ${latest},但重启失败:${err.message},请手动 /restart`,
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1661
1721
|
cmdNew(ctx) {
|
|
1662
1722
|
// 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
|
|
1663
1723
|
// 设置工作目录",但用户的 workspace 还在。2026-06-21 复盘:用户
|
|
@@ -1840,6 +1900,136 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
1840
1900
|
};
|
|
1841
1901
|
return { card };
|
|
1842
1902
|
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Build card for config.save agent switch.
|
|
1905
|
+
* When the new agent has a restored session, renders a Resume card (mirrors
|
|
1906
|
+
* buildAutoResumeCard) with session history + stop/new buttons.
|
|
1907
|
+
* When the session was cleared, renders a compact notice card instead.
|
|
1908
|
+
*/
|
|
1909
|
+
buildConfigSwitchCard(switchResult, ctx) {
|
|
1910
|
+
const newAgent = switchResult.newAgent;
|
|
1911
|
+
const agentName = agentDisplayName(newAgent);
|
|
1912
|
+
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
1913
|
+
const sessionId = switchResult.sessionId;
|
|
1914
|
+
// No cwd or no session → compact notice card
|
|
1915
|
+
if (!cwd || !sessionId) {
|
|
1916
|
+
const elements = [markdownDiv(switchResult.notice)];
|
|
1917
|
+
// New session button
|
|
1918
|
+
elements.push({
|
|
1919
|
+
tag: 'column_set',
|
|
1920
|
+
columns: [
|
|
1921
|
+
{
|
|
1922
|
+
tag: 'column',
|
|
1923
|
+
width: 'auto',
|
|
1924
|
+
elements: [newSessionButton()],
|
|
1925
|
+
},
|
|
1926
|
+
],
|
|
1927
|
+
});
|
|
1928
|
+
return {
|
|
1929
|
+
card: {
|
|
1930
|
+
schema: '2.0',
|
|
1931
|
+
config: { wide_screen_mode: true },
|
|
1932
|
+
header: {
|
|
1933
|
+
title: { tag: 'plain_text', content: `🔀 切换到 ${agentName}` },
|
|
1934
|
+
},
|
|
1935
|
+
body: { elements },
|
|
1936
|
+
},
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
// Has session → Resume card (mirrors buildAutoResumeCard)
|
|
1940
|
+
// Guard: if the session reader for the new agent is not available (e.g. agent
|
|
1941
|
+
// not registered), fall back to compact notice card to avoid NPE.
|
|
1942
|
+
const readerForNewAgent = newAgent === this.config.defaultAgent
|
|
1943
|
+
? this.sessionReader
|
|
1944
|
+
: this.sessionReaderRegistry.get(newAgent);
|
|
1945
|
+
if (!readerForNewAgent) {
|
|
1946
|
+
// Fallback: compact notice card with the notice text
|
|
1947
|
+
const elements = [markdownDiv(switchResult.notice)];
|
|
1948
|
+
elements.push({
|
|
1949
|
+
tag: 'column_set',
|
|
1950
|
+
columns: [
|
|
1951
|
+
{
|
|
1952
|
+
tag: 'column',
|
|
1953
|
+
width: 'auto',
|
|
1954
|
+
elements: [newSessionButton()],
|
|
1955
|
+
},
|
|
1956
|
+
],
|
|
1957
|
+
});
|
|
1958
|
+
return {
|
|
1959
|
+
card: {
|
|
1960
|
+
schema: '2.0',
|
|
1961
|
+
config: { wide_screen_mode: true },
|
|
1962
|
+
header: { title: { tag: 'plain_text', content: `🔀 切换到 ${agentName}` } },
|
|
1963
|
+
body: { elements },
|
|
1964
|
+
},
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionId, cwd, {
|
|
1968
|
+
agentKind: newAgent,
|
|
1969
|
+
maxEvents: AUTO_RESUME_MAX_EVENTS,
|
|
1970
|
+
});
|
|
1971
|
+
let header = `📂 \`${cwd}\`\n已恢复会话: **${sessionId}**`;
|
|
1972
|
+
const sections = [];
|
|
1973
|
+
if (displayTitle) {
|
|
1974
|
+
const label = aiTitle ? 'AI 标题' : '最近输入';
|
|
1975
|
+
sections.push(`🏷️ **${label}**\n${displayTitle}`);
|
|
1976
|
+
}
|
|
1977
|
+
if (recap) {
|
|
1978
|
+
const recapPreview = recap.length > 200 ? recap.slice(0, 197) + '...' : recap;
|
|
1979
|
+
sections.push(`📝 **Recap**\n${recapPreview}`);
|
|
1980
|
+
}
|
|
1981
|
+
if (sections.length > 0) {
|
|
1982
|
+
header += '\n\n' + sections.join('\n\n──\n\n');
|
|
1983
|
+
}
|
|
1984
|
+
const elements = [markdownDiv(header), { tag: 'hr' }];
|
|
1985
|
+
if (content.length === 0) {
|
|
1986
|
+
elements.push(markdownDiv('_该会话暂无新消息可显示(最后一条为用户输入)_'));
|
|
1987
|
+
}
|
|
1988
|
+
else {
|
|
1989
|
+
content.forEach((ev, i) => {
|
|
1990
|
+
elements.push(sessionEventPanel(ev, i, content.length, 2, newAgent));
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
if (usage) {
|
|
1994
|
+
const usageStr = formatUsageStats(usage, { showResult: true, result: 'success' });
|
|
1995
|
+
elements.push(markdownDiv(usageStr));
|
|
1996
|
+
}
|
|
1997
|
+
if (elements.length > 0 && elements[elements.length - 1].tag === 'hr') {
|
|
1998
|
+
elements.pop();
|
|
1999
|
+
}
|
|
2000
|
+
// Action buttons
|
|
2001
|
+
const actions = [];
|
|
2002
|
+
if (isActive) {
|
|
2003
|
+
actions.push({
|
|
2004
|
+
tag: 'button',
|
|
2005
|
+
text: { tag: 'plain_text', content: '⏹ 终止' },
|
|
2006
|
+
type: 'danger',
|
|
2007
|
+
behaviors: [{ type: 'callback', value: { cmd: 'stop', runId: activeRunRunId, cwd } }],
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
actions.push(newSessionButton());
|
|
2011
|
+
elements.push({
|
|
2012
|
+
tag: 'column_set',
|
|
2013
|
+
columns: actions.map((btn) => ({
|
|
2014
|
+
tag: 'column',
|
|
2015
|
+
width: 'auto',
|
|
2016
|
+
elements: [btn],
|
|
2017
|
+
})),
|
|
2018
|
+
});
|
|
2019
|
+
return {
|
|
2020
|
+
card: {
|
|
2021
|
+
schema: '2.0',
|
|
2022
|
+
config: { wide_screen_mode: true },
|
|
2023
|
+
header: {
|
|
2024
|
+
title: {
|
|
2025
|
+
tag: 'plain_text',
|
|
2026
|
+
content: `${isActive ? '⏳ 切换 Agent(完成中)' : '🔀 切换 Agent'} · ${agentName}`,
|
|
2027
|
+
},
|
|
2028
|
+
},
|
|
2029
|
+
body: { elements },
|
|
2030
|
+
},
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
1843
2033
|
static LS_PAGE_SIZE = 30;
|
|
1844
2034
|
cmdLs(args, ctx, offset = 0) {
|
|
1845
2035
|
const cwd = this.sessionStore.getCwd(ctx.userId);
|
|
@@ -2906,8 +3096,14 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2906
3096
|
// clearRunners / syncAgentChoices / defaultAgent 切换)。key 经 mapAgentKey
|
|
2907
3097
|
// 归一化,pi./codex./opencode./kimi. 命中 agents.* 判定;claude. 与
|
|
2908
3098
|
// defaultAgent 不再漏判。
|
|
2909
|
-
this.propagateConfigSave(oldDefaultAgent, { [mapAgentKey(key)]: value }, ctx);
|
|
3099
|
+
const switchResult = this.propagateConfigSave(oldDefaultAgent, { [mapAgentKey(key)]: value }, ctx);
|
|
2910
3100
|
this.pendingConfig = null; // 命令式写盘后清空暂存,避免不一致
|
|
3101
|
+
// 2026-08-13: agent 切换时额外发送 Resume 卡片
|
|
3102
|
+
if (switchResult.notice) {
|
|
3103
|
+
const switchCard = this.buildConfigSwitchCard(switchResult, ctx);
|
|
3104
|
+
// 文本路径返回 config card,Resume card 通过 bridge 异步发送
|
|
3105
|
+
void this.bridge.sendResult(switchCard, ctx);
|
|
3106
|
+
}
|
|
2911
3107
|
const card = this.buildConfigCard();
|
|
2912
3108
|
if (discarded > 0 && card.card && typeof card.card === 'object') {
|
|
2913
3109
|
const body = card.card.body;
|
|
@@ -2942,9 +3138,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2942
3138
|
// Direct inline construction (not via ConfigField) so that text (display label)
|
|
2943
3139
|
// and value (agentKind for config storage) can differ — uninstalled agents get
|
|
2944
3140
|
// a "⚠️ (未安装)" suffix in the label while the value stays clean.
|
|
2945
|
-
|
|
2946
|
-
// order (claude → codex → pi → opencode → kimi).
|
|
2947
|
-
const allAgents = sortAgentsByAvailability(listRegisteredAgents(), getCachedAvailability);
|
|
3141
|
+
const allAgents = listRegisteredAgents();
|
|
2948
3142
|
const agentOptions = allAgents.map((kind) => {
|
|
2949
3143
|
const available = getCachedAvailability(kind);
|
|
2950
3144
|
const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - `approval_policy="never"` eliminates the approval handshake (root cause ①).
|
|
6
6
|
* - Prompt goes via stdin (`-` flag) to avoid argv escaping issues.
|
|
7
7
|
* - `--skip-git-repo-check`: lark-remote's cwd may not be a git repo.
|
|
8
|
-
* - User's `~/.codex/config.toml` and
|
|
8
|
+
* - User's `~/.codex/config.toml` and project rules are preserved
|
|
9
9
|
* (no `--ignore-user-config` / `--ignore-rules`).
|
|
10
10
|
*/
|
|
11
11
|
interface BuildCodexExecArgsInput {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - `approval_policy="never"` eliminates the approval handshake (root cause ①).
|
|
6
6
|
* - Prompt goes via stdin (`-` flag) to avoid argv escaping issues.
|
|
7
7
|
* - `--skip-git-repo-check`: lark-remote's cwd may not be a git repo.
|
|
8
|
-
* - User's `~/.codex/config.toml` and
|
|
8
|
+
* - User's `~/.codex/config.toml` and project rules are preserved
|
|
9
9
|
* (no `--ignore-user-config` / `--ignore-rules`).
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
@@ -11,6 +11,18 @@ export declare function pipeAllStdio(): ('ignore' | 'pipe')[];
|
|
|
11
11
|
* rather than argv.
|
|
12
12
|
*/
|
|
13
13
|
export declare function endStdinWithPrompt(proc: ChildProcess, message: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Build a synthetic system.init event for pre-spawn failure paths.
|
|
16
|
+
*
|
|
17
|
+
* When the runner exits before spawning a process (validateBeforeRun failure,
|
|
18
|
+
* ENOENT spawn failure), no real system.init arrives from stdout. Without a
|
|
19
|
+
* synthetic init, the bridge's pre-init result guard (§9.22) and the run-state
|
|
20
|
+
* reducer's sessionId check would silently drop the error result, leaving the
|
|
21
|
+
* card showing "输出流已结束,但未收到 result 事件" instead of the actual
|
|
22
|
+
* error message. The synthetic init satisfies both guards so the real error
|
|
23
|
+
* result is processed normally.
|
|
24
|
+
*/
|
|
25
|
+
export declare function syntheticInitEvent(sessionId?: string): AgentEvent;
|
|
14
26
|
/**
|
|
15
27
|
* Build a standardized auth/error result event for yield.
|
|
16
28
|
*
|
|
@@ -46,6 +46,27 @@ export function endStdinWithPrompt(proc, message) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Build a synthetic system.init event for pre-spawn failure paths.
|
|
51
|
+
*
|
|
52
|
+
* When the runner exits before spawning a process (validateBeforeRun failure,
|
|
53
|
+
* ENOENT spawn failure), no real system.init arrives from stdout. Without a
|
|
54
|
+
* synthetic init, the bridge's pre-init result guard (§9.22) and the run-state
|
|
55
|
+
* reducer's sessionId check would silently drop the error result, leaving the
|
|
56
|
+
* card showing "输出流已结束,但未收到 result 事件" instead of the actual
|
|
57
|
+
* error message. The synthetic init satisfies both guards so the real error
|
|
58
|
+
* result is processed normally.
|
|
59
|
+
*/
|
|
60
|
+
export function syntheticInitEvent(sessionId = '') {
|
|
61
|
+
return {
|
|
62
|
+
type: 'system',
|
|
63
|
+
subtype: 'init',
|
|
64
|
+
session_id: sessionId,
|
|
65
|
+
cwd: '',
|
|
66
|
+
model: '',
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
49
70
|
/**
|
|
50
71
|
* Build a standardized auth/error result event for yield.
|
|
51
72
|
*
|
|
@@ -6,7 +6,7 @@ import { getLogger } from '../../logger/index.js';
|
|
|
6
6
|
import { ProcessStopper } from './process-stopper.js';
|
|
7
7
|
import { SpawnHeartbeat } from './spawn-heartbeat.js';
|
|
8
8
|
import { createJSONLStream } from './jsonl-stream.js';
|
|
9
|
-
import { authErrorEvent } from './runner-utils.js';
|
|
9
|
+
import { authErrorEvent, syntheticInitEvent } from './runner-utils.js';
|
|
10
10
|
import { DEFAULT_STOP_GRACE_MS } from '../../config/index.js';
|
|
11
11
|
/**
|
|
12
12
|
* Magic-number constants (Clean Code P3-1, G25 Replace Magic Numbers with
|
|
@@ -255,6 +255,11 @@ export class SpawningRunner {
|
|
|
255
255
|
// Pre-spawn validation hook (e.g. codex API key check)
|
|
256
256
|
const validationError = this.validateBeforeRun(opts);
|
|
257
257
|
if (validationError) {
|
|
258
|
+
// §9.22: yield synthetic init so the bridge's pre-init result guard and
|
|
259
|
+
// the run-state reducer's sessionId check don't silently drop the error
|
|
260
|
+
// result. Without init, the card would show a generic "输出流已结束"
|
|
261
|
+
// instead of the specific validation error message.
|
|
262
|
+
yield syntheticInitEvent(opts.sessionId);
|
|
258
263
|
yield validationError;
|
|
259
264
|
return;
|
|
260
265
|
}
|
|
@@ -287,6 +292,9 @@ export class SpawningRunner {
|
|
|
287
292
|
// so the user is not misdiagnosed into reinstalling the binary.
|
|
288
293
|
const baseMsg = `${this.binary} 命令不可用(未找到或不可执行),请检查是否已安装或在 PATH 中`;
|
|
289
294
|
const msg = spawnErr ? `${baseMsg}:${spawnErr.message}` : baseMsg;
|
|
295
|
+
// §9.22: yield synthetic init before the error result so bridge/run-state
|
|
296
|
+
// guards don't silently drop it (same rationale as validateBeforeRun).
|
|
297
|
+
yield syntheticInitEvent(opts.sessionId);
|
|
290
298
|
yield authErrorEvent(msg);
|
|
291
299
|
return;
|
|
292
300
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type ContentBlockMapping } from '../common/content-blocks.js';
|
|
2
|
+
export interface SessionIndexEntry {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
path: string;
|
|
5
|
+
fingerprint: string;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
summary: string;
|
|
8
|
+
cwdSet: Set<string>;
|
|
9
|
+
}
|
|
10
|
+
export interface SessionIndexOptions {
|
|
11
|
+
/** Minimum interval between refresh scans (ms). Default 5000. */
|
|
12
|
+
refreshIntervalMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Claude-specific field-name mapping for content block extraction. */
|
|
15
|
+
export declare const CLAUDE_MAPPING: ContentBlockMapping;
|
|
16
|
+
/**
|
|
17
|
+
* Detect Claude CLI sub-agent completion notification text.
|
|
18
|
+
* Claude CLI injects a `type:"user"` record with string content starting
|
|
19
|
+
* with `<task-notification>` when a sub-agent (Agent tool) finishes.
|
|
20
|
+
* These must be excluded from displayTitle/summary — they are machine
|
|
21
|
+
* injections, not real user input. Primary criterion: extracted text
|
|
22
|
+
* starts with `<task-notification` (works for both string and array
|
|
23
|
+
* content since `extractText` is called first). Secondary (optional):
|
|
24
|
+
* `origin.kind === 'task-notification'` (not all CLI versions write it).
|
|
25
|
+
*/
|
|
26
|
+
export declare function isTaskNotificationText(text: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Extract first text from a user message.
|
|
29
|
+
* Used by parseSessionJsonl and sessions.ts readSessionContent.
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractText(content: unknown): string | null;
|
|
32
|
+
export interface ParsedSession {
|
|
33
|
+
cwdSet: Set<string>;
|
|
34
|
+
summary: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Unified parser: single streaming scan of a JSONL file producing both
|
|
38
|
+
* the complete set of cwd values AND the first real user message summary.
|
|
39
|
+
* Replaces the split readCwdFromJsonl (first-cwd) + jsonlContainsCwd (any-cwd).
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseSessionJsonl(filePath: string): ParsedSession;
|
|
42
|
+
/**
|
|
43
|
+
* Build a fingerprint string from file stat metadata.
|
|
44
|
+
* Uses `size + mtimeNs + ctimeNs + dev + ino` (bigint stat).
|
|
45
|
+
* `size`/`mtime` are required; the rest are best-effort.
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildFingerprint(filePath: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* In-memory session index with three query views:
|
|
50
|
+
* byPath: path → entry
|
|
51
|
+
* bySessionId: sessionId → Set<path>
|
|
52
|
+
* byCwd: cwd → Set<path>
|
|
53
|
+
*
|
|
54
|
+
* Build once, refresh on demand (throttled). Single-file parse failure
|
|
55
|
+
* doesn't affect other files; readdir failure preserves old index.
|
|
56
|
+
*/
|
|
57
|
+
export declare class SessionIndex {
|
|
58
|
+
private readonly projectsDir;
|
|
59
|
+
private readonly refreshIntervalMs;
|
|
60
|
+
private readonly byPath;
|
|
61
|
+
private readonly bySessionId;
|
|
62
|
+
private readonly byCwd;
|
|
63
|
+
private lastRefreshAt;
|
|
64
|
+
private built;
|
|
65
|
+
/** Test-only: count of parseSessionJsonl calls since creation */
|
|
66
|
+
parseCount: number;
|
|
67
|
+
/** Test-only: count of directory scan operations */
|
|
68
|
+
scanCount: number;
|
|
69
|
+
constructor(projectsDir: string, opts?: SessionIndexOptions);
|
|
70
|
+
/** Whether a full scan has ever completed successfully (readdir succeeded). */
|
|
71
|
+
get isBuilt(): boolean;
|
|
72
|
+
build(): void;
|
|
73
|
+
refresh(): void;
|
|
74
|
+
listByCwd(cwd: string, opts?: {
|
|
75
|
+
limit?: number;
|
|
76
|
+
offset?: number;
|
|
77
|
+
}): SessionIndexEntry[];
|
|
78
|
+
/**
|
|
79
|
+
* Find a session entry by sessionId and cwd.
|
|
80
|
+
* Re-stats the file to verify fingerprint before returning (拍板 3):
|
|
81
|
+
* - fingerprint unchanged → trust index cwdSet
|
|
82
|
+
* - fingerprint changed → re-parse that one file, update index, re-check
|
|
83
|
+
* Returns undefined if not found or cwd doesn't match.
|
|
84
|
+
*
|
|
85
|
+
* Note: we re-stat ALL paths for the sessionId regardless of the old cwdSet,
|
|
86
|
+
* because a fingerprint change may have added the target cwd to the set.
|
|
87
|
+
*/
|
|
88
|
+
findBySessionIdAndCwd(sessionId: string, cwd: string): SessionIndexEntry | undefined;
|
|
89
|
+
private fullScan;
|
|
90
|
+
private updateEntry;
|
|
91
|
+
private removeEntry;
|
|
92
|
+
private removeCrossRefs;
|
|
93
|
+
}
|