lark-remote 0.5.9 → 0.5.12
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 +49 -0
- package/dist/binder.js +7 -1
- package/dist/bridge/index.d.ts +9 -0
- package/dist/bridge/index.js +28 -3
- package/dist/bridge/queue-manager.d.ts +31 -0
- package/dist/bridge/queue-manager.js +78 -0
- package/dist/card/approval-render.d.ts +14 -0
- package/dist/card/approval-render.js +155 -29
- package/dist/card/bash-card-session.js +8 -0
- package/dist/card/bash-renderer.js +11 -23
- package/dist/card/card-budget.js +24 -4
- package/dist/card/run-renderer.js +31 -9
- package/dist/common/text-width.js +5 -0
- package/dist/common/tool-summary.js +4 -2
- package/dist/config/codex-config.js +5 -3
- package/dist/config/index.js +13 -1
- package/dist/index.js +42 -24
- package/dist/instance-lock.d.ts +9 -1
- package/dist/instance-lock.js +42 -7
- package/dist/platform/identity.js +4 -3
- package/dist/platform/path.js +3 -1
- package/dist/platform/terminator-win32.d.ts +13 -1
- package/dist/platform/terminator-win32.js +4 -14
- package/dist/router/index.js +179 -122
- package/dist/runner/codex/app-server/runner.js +12 -0
- package/dist/runner/common/acp/base-translator.d.ts +12 -0
- package/dist/runner/common/acp/base-translator.js +22 -0
- package/dist/runner/common/jsonl-stream.js +12 -1
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +6 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +34 -9
- package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
- package/dist/runner/common/jsonrpc/transport.js +7 -1
- package/dist/runner/dsh/approval.js +16 -3
- package/dist/runner/dsh/client.d.ts +31 -1
- package/dist/runner/dsh/client.js +126 -6
- package/dist/runner/dsh/exit-cancel.js +62 -23
- package/dist/runner/dsh/runner.d.ts +7 -0
- package/dist/runner/dsh/runner.js +42 -5
- package/dist/runner/kimi/acp/runner.js +42 -5
- package/dist/runner/kimi/acp/translator.d.ts +8 -0
- package/dist/runner/kimi/acp/translator.js +10 -0
- package/dist/runner/pi/rpc/runner.js +33 -5
- package/dist/runner/resolve-agent-choices.js +5 -1
- package/dist/runner/sync-agent-choices.js +4 -1
- package/dist/session/claude/session-index.js +9 -2
- package/dist/session/codex/rollout-reader.js +51 -26
- package/dist/session/common/usage-accumulator.d.ts +10 -1
- package/dist/session/common/usage-accumulator.js +10 -2
- package/dist/session/dsh/sessions.js +29 -9
- package/dist/session/kimi/sessions.js +9 -5
- package/dist/session/opencode/sessions.js +13 -6
- package/dist/session/session-store.js +4 -3
- package/package.json +1 -1
package/dist/router/index.js
CHANGED
|
@@ -319,6 +319,10 @@ const IMMEDIATE_ACTION_CMDS = new Set([
|
|
|
319
319
|
'queue.diagnose',
|
|
320
320
|
'queue.edit',
|
|
321
321
|
'queue.input',
|
|
322
|
+
// 终态卡「停止后台任务」:纯远程 job/kill 请求,不产生 agent 工作(§9.19
|
|
323
|
+
// 控制操作免排队)。排进串行队列会等当前 run 结束才杀任务——stop 类按钮
|
|
324
|
+
// 排队正是 §9.6 要防的形态,且排队期间还占 lane 多出一张排队卡。
|
|
325
|
+
'job.kill',
|
|
322
326
|
'order.delete',
|
|
323
327
|
'order.page',
|
|
324
328
|
'order.aliasEdit',
|
|
@@ -365,6 +369,20 @@ export const COMPACT_ACTION_CMDS = new Set(['compact', 'resume.compact']);
|
|
|
365
369
|
function payloadOffset(value) {
|
|
366
370
|
return Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
367
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* existsSync + statSync 的 TOCTOU 安全目录校验:路径在两次 syscalls 之间被删时
|
|
374
|
+
* statSync 会抛错——落进 enqueueImmediate / 队列任务的静默 catch,点击/命令
|
|
375
|
+
* 毫无反馈(违反「点击必有反馈」红线;同族 handleLsRefresh/Page/Filter 均已
|
|
376
|
+
* try/catch 防护)。统一返回 false 走「路径无效」回复。
|
|
377
|
+
*/
|
|
378
|
+
function isDirectorySafe(p) {
|
|
379
|
+
try {
|
|
380
|
+
return fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
368
386
|
/**
|
|
369
387
|
* 搜索框提交值单源读取:`inputValue` 主路(CardKit 2.0 input 提交(键盘回车/
|
|
370
388
|
* 完成键)经 raw `action.input_value` 回传,SDK normalizer 会丢弃
|
|
@@ -822,128 +840,143 @@ export class CommandRouter {
|
|
|
822
840
|
await this.bridge.sendResult({ text: '⚠️ 正在压缩会话,消息将在压缩完成后按顺序执行。' }, ctx);
|
|
823
841
|
return;
|
|
824
842
|
}
|
|
825
|
-
// 1.
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
+
// 1.4. Hold the target's run-card start until this handler has sent its
|
|
844
|
+
// final feedback. The queue chain can advance during the awaits below
|
|
845
|
+
// (interruptCurrentRun / the queue-card PATCH): the target may begin and
|
|
846
|
+
// call forwardToClaude before the toast is sent. Without this hold the
|
|
847
|
+
// run card would land first, leaving the toast as the newest message —
|
|
848
|
+
// the chat list then previews the toast instead of the run card's title
|
|
849
|
+
// (which is how the user follows a run without opening the conversation).
|
|
850
|
+
// Registered before any await, released in finally on every exit path;
|
|
851
|
+
// forwardToClaude waits for it before creating the run card.
|
|
852
|
+
const releaseRunStart = this.bridge.holdQueueTaskRun(cwd, messageId);
|
|
853
|
+
try {
|
|
854
|
+
// 1.5. If the user edited the message, the original closure (captured at
|
|
855
|
+
// enqueue time with stale content) must NOT run. Register a one-shot
|
|
856
|
+
// replacement closure on the task's EXISTING queue slot BEFORE any await:
|
|
857
|
+
// interruptCurrentRun / markQueueCardExecuting can let the chain advance to
|
|
858
|
+
// this task's begin, and begin consumes the replacement at that point — a
|
|
859
|
+
// registration after those awaits would arrive too late (the stale closure
|
|
860
|
+
// runs and the replacement leaks as a permanent dead closure). Registering
|
|
861
|
+
// on the existing slot (instead of removeFromQueue + re-enqueue) preserves
|
|
862
|
+
// the queue position: enqueue can only append to the promise chain tail,
|
|
863
|
+
// which would put the edited task behind tasks queued after it.
|
|
864
|
+
if (targetTask.editedMessage) {
|
|
865
|
+
const editedContent = targetTask.editedMessage;
|
|
866
|
+
///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
|
|
867
|
+
// cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
|
|
868
|
+
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
869
|
+
await this.handle(editedContent, ctx, {
|
|
870
|
+
cwdOverride: cwd,
|
|
871
|
+
binding: targetTask.binding,
|
|
872
|
+
});
|
|
843
873
|
});
|
|
874
|
+
}
|
|
875
|
+
// 2. Stop current running process
|
|
876
|
+
// (Note: queue executing count is reset inside Bridge.interruptCurrentRun
|
|
877
|
+
// so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
|
|
878
|
+
const interrupt = await this.bridge.interruptCurrentRunDetailed({
|
|
879
|
+
userId: ctx.userId,
|
|
880
|
+
chatId: ctx.chatId,
|
|
881
|
+
cwd,
|
|
844
882
|
});
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
// asked to clear can begin executing while its cancellation card is still
|
|
872
|
-
// in flight.
|
|
873
|
-
for (let i = 0; i < targetIdx; i++) {
|
|
874
|
-
const task = tasks[i];
|
|
875
|
-
// 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
|
|
876
|
-
// 同步推进的既有不变式。
|
|
877
|
-
if (this.bridge.removeFromQueue(cwd, task.messageId)) {
|
|
878
|
-
removedCount++;
|
|
883
|
+
const stopped = interrupt.stopped;
|
|
884
|
+
// A remote (DSH) stop is only a request; when it cannot be confirmed the
|
|
885
|
+
// target must not be promised "immediate" execution — its prompt would sit
|
|
886
|
+
// behind the still-running turn with no visible progress.
|
|
887
|
+
const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
|
|
888
|
+
getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
|
|
889
|
+
// 3. Remove all tasks BEFORE this one (by position in the current queue).
|
|
890
|
+
// The target may have begun while interruptCurrentRun was awaiting (the chain
|
|
891
|
+
// advances concurrently): then it is already out of the snapshot and every
|
|
892
|
+
// remaining queued task is BEHIND it — clearing must stop, not run off the
|
|
893
|
+
// end and delete tasks queued after the target.
|
|
894
|
+
const tasks = this.bridge.getQueuedTasks(cwd);
|
|
895
|
+
const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
|
|
896
|
+
let removedCount = 0;
|
|
897
|
+
// Phase 1: remove synchronously. The queue chain advances on any await
|
|
898
|
+
// (card PATCH round trips included), so every task before the target must
|
|
899
|
+
// be removed before yielding to the event loop — otherwise a task the user
|
|
900
|
+
// asked to clear can begin executing while its cancellation card is still
|
|
901
|
+
// in flight.
|
|
902
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
903
|
+
const task = tasks[i];
|
|
904
|
+
// 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
|
|
905
|
+
// 同步推进的既有不变式。
|
|
906
|
+
if (this.bridge.removeFromQueue(cwd, task.messageId)) {
|
|
907
|
+
removedCount++;
|
|
908
|
+
}
|
|
879
909
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
910
|
+
if (targetIdx < 0) {
|
|
911
|
+
getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
|
|
912
|
+
}
|
|
913
|
+
// 4. DO NOT remove the target task - keep it in queue to execute immediately
|
|
914
|
+
// The queue will naturally execute it after the current task (which we just stopped)
|
|
915
|
+
// 4.4. Refresh the replacement with the latest edited content (idempotent:
|
|
916
|
+
// re-registering over the same slot). Covers the user editing the message
|
|
917
|
+
// again while interruptCurrentRun was awaiting; skipped automatically once
|
|
918
|
+
// the task has begun, because getQueuedTask then returns undefined.
|
|
919
|
+
const latestTask = this.bridge.getQueuedTask(cwd, messageId);
|
|
920
|
+
if (latestTask?.editedMessage) {
|
|
921
|
+
const editedContent = latestTask.editedMessage;
|
|
922
|
+
///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
|
|
923
|
+
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
924
|
+
await this.handle(editedContent, ctx, {
|
|
925
|
+
cwdOverride: cwd,
|
|
926
|
+
binding: latestTask.binding,
|
|
927
|
+
});
|
|
898
928
|
});
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
929
|
+
}
|
|
930
|
+
// 4.5/6. The target must only be marked executing (and the toast may only
|
|
931
|
+
// promise immediate execution) when it is really the next task after the
|
|
932
|
+
// stop. While interruptCurrentRun was awaiting, a task ahead of the target
|
|
933
|
+
// may have begun and now occupy the cwd — the target is still queued
|
|
934
|
+
// but is NOT next, so its card must stay queued and the toast must say so.
|
|
935
|
+
const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
|
|
936
|
+
if (!targetStillQueued) {
|
|
937
|
+
// The target began or was cancelled while earlier awaits were in flight —
|
|
938
|
+
// a success toast must never promise execution of a task that will not
|
|
939
|
+
// run. The two missing-reason states get distinct feedback: a target
|
|
940
|
+
// that began while the interrupt was in flight is already executing, so
|
|
941
|
+
// the toast must acknowledge that instead of telling the user nothing
|
|
942
|
+
// was scheduled; only a target that was actually cancelled (never began)
|
|
943
|
+
// gets the "未安排执行" wording.
|
|
944
|
+
if (this.bridge.hasTaskBegan(messageId)) {
|
|
945
|
+
await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
|
|
946
|
+
}
|
|
947
|
+
else {
|
|
948
|
+
await this.bridge.sendResult({
|
|
949
|
+
text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
|
|
950
|
+
}, ctx);
|
|
951
|
+
}
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const workspaceBusy = this.bridge.isBusyFor(cwd);
|
|
955
|
+
if (stopUnconfirmed) {
|
|
956
|
+
const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
|
|
957
|
+
await this.bridge.sendResult({
|
|
958
|
+
text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
|
|
959
|
+
`已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
|
|
960
|
+
}, ctx);
|
|
961
|
+
}
|
|
962
|
+
else if (!workspaceBusy) {
|
|
963
|
+
// Normal path: nothing new began during the stop, the target is next.
|
|
964
|
+
// Mark its own card as executing now so buttons grey out immediately.
|
|
965
|
+
// Done BEFORE any removal so getQueuedTask still returns the task with
|
|
966
|
+
// its (possibly edited) messagePreview for the card. The queue callback's
|
|
967
|
+
// later updateQueueCardToExecuting call becomes a no-op (idempotent).
|
|
968
|
+
await this.bridge.markQueueCardExecuting(cwd, messageId);
|
|
969
|
+
const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
|
|
970
|
+
await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
|
|
917
971
|
}
|
|
918
972
|
else {
|
|
919
973
|
await this.bridge.sendResult({
|
|
920
|
-
text: `⚠️
|
|
974
|
+
text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
|
|
921
975
|
}, ctx);
|
|
922
976
|
}
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
const workspaceBusy = this.bridge.isBusyFor(cwd);
|
|
926
|
-
if (stopUnconfirmed) {
|
|
927
|
-
const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
|
|
928
|
-
await this.bridge.sendResult({
|
|
929
|
-
text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
|
|
930
|
-
`已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
|
|
931
|
-
}, ctx);
|
|
932
|
-
}
|
|
933
|
-
else if (!workspaceBusy) {
|
|
934
|
-
// Normal path: nothing new began during the stop, the target is next.
|
|
935
|
-
// Mark its own card as executing now so buttons grey out immediately.
|
|
936
|
-
// Done BEFORE any removal so getQueuedTask still returns the task with
|
|
937
|
-
// its (possibly edited) messagePreview for the card. The queue callback's
|
|
938
|
-
// later updateQueueCardToExecuting call becomes a no-op (idempotent).
|
|
939
|
-
await this.bridge.markQueueCardExecuting(cwd, messageId);
|
|
940
|
-
const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
|
|
941
|
-
await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
|
|
942
977
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
|
|
946
|
-
}, ctx);
|
|
978
|
+
finally {
|
|
979
|
+
releaseRunStart();
|
|
947
980
|
}
|
|
948
981
|
}
|
|
949
982
|
/**
|
|
@@ -958,6 +991,11 @@ export class CommandRouter {
|
|
|
958
991
|
const task = this.bridge.getQueuedTask(cwd, messageId);
|
|
959
992
|
const queueInfo = this.bridge.getQueueInfo(cwd);
|
|
960
993
|
const activeRuns = this.bridge.getAllActiveRuns();
|
|
994
|
+
// 「排在当前任务之前」按诊断目标自己的位置算(与 sendQueueStatusCard 同法)。
|
|
995
|
+
// getQueueInfo.tasksAhead 是队尾视角(len-1),诊断目标不是队尾时数字误导。
|
|
996
|
+
const queuedTasks = this.bridge.getQueuedTasks(cwd);
|
|
997
|
+
const targetIdx = queuedTasks.findIndex((t) => t.messageId === messageId);
|
|
998
|
+
const tasksAhead = targetIdx >= 0 ? targetIdx : queueInfo.tasksAhead;
|
|
961
999
|
// Build diagnostic info
|
|
962
1000
|
const now = new Date().toLocaleString('zh-CN');
|
|
963
1001
|
const pid = process.pid;
|
|
@@ -997,7 +1035,7 @@ export class CommandRouter {
|
|
|
997
1035
|
tag: 'div',
|
|
998
1036
|
text: {
|
|
999
1037
|
tag: 'lark_md',
|
|
1000
|
-
content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${
|
|
1038
|
+
content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${tasksAhead}`,
|
|
1001
1039
|
},
|
|
1002
1040
|
},
|
|
1003
1041
|
{ tag: 'hr' },
|
|
@@ -1188,7 +1226,7 @@ export class CommandRouter {
|
|
|
1188
1226
|
const resolvedTarget = path.resolve(targetPath);
|
|
1189
1227
|
// 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
|
|
1190
1228
|
// 安全边界由 binder owner 认证兜底,见 docs/zh/architecture/design.md §9.7 更新说明。
|
|
1191
|
-
if (!
|
|
1229
|
+
if (!isDirectorySafe(resolvedTarget)) {
|
|
1192
1230
|
await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
|
|
1193
1231
|
return;
|
|
1194
1232
|
}
|
|
@@ -1355,7 +1393,7 @@ export class CommandRouter {
|
|
|
1355
1393
|
return;
|
|
1356
1394
|
}
|
|
1357
1395
|
const resolvedTarget = path.resolve(targetPath);
|
|
1358
|
-
if (!
|
|
1396
|
+
if (!isDirectorySafe(resolvedTarget)) {
|
|
1359
1397
|
await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
|
|
1360
1398
|
return;
|
|
1361
1399
|
}
|
|
@@ -1402,8 +1440,12 @@ export class CommandRouter {
|
|
|
1402
1440
|
async handleJobKill(value, ctx) {
|
|
1403
1441
|
const runId = typeof value.runId === 'string' ? value.runId : '';
|
|
1404
1442
|
const jobId = typeof value.jobId === 'string' ? value.jobId : '';
|
|
1405
|
-
|
|
1443
|
+
// miss path 必回复(本文件的设计约束):旧版/异常卡片 payload 缺参时
|
|
1444
|
+
// 静默 return 会让点击毫无反应,用户无从判断。
|
|
1445
|
+
if (!runId || !jobId) {
|
|
1446
|
+
await this.bridge.sendResult({ text: CARD_PAYLOAD_MISSING }, ctx);
|
|
1406
1447
|
return;
|
|
1448
|
+
}
|
|
1407
1449
|
if (await this.bridge.killBackgroundJob(runId, jobId, ctx))
|
|
1408
1450
|
return;
|
|
1409
1451
|
await this.bridge.sendResult({ text: '停止失败:任务可能已自行结束,或记录已回收' }, ctx);
|
|
@@ -1728,12 +1770,14 @@ export class CommandRouter {
|
|
|
1728
1770
|
}
|
|
1729
1771
|
// Refresh the /ws list card in place so "recent" sort is immediately visible
|
|
1730
1772
|
await this.refreshListCard('ws', value, ctx);
|
|
1731
|
-
// Toast feedback (suppress text when a persistent message was already sent)
|
|
1773
|
+
// Toast feedback (suppress text when a persistent message was already sent).
|
|
1774
|
+
// 兜底文案必须用 warning 型:ws.use 不在 DIRECT_RETURN_CMDS,toast 经
|
|
1775
|
+
// enqueueImmediate fire-and-forget,而 forwardActionFeedback 只转发
|
|
1776
|
+
// error/warning 型——success 型兜底会被双重吞掉,点击毫无反馈。
|
|
1732
1777
|
return {
|
|
1733
|
-
toast:
|
|
1734
|
-
type: '
|
|
1735
|
-
|
|
1736
|
-
},
|
|
1778
|
+
toast: fallbackToast
|
|
1779
|
+
? { type: 'warning', content: fallbackToast }
|
|
1780
|
+
: { type: 'success', content: '' },
|
|
1737
1781
|
};
|
|
1738
1782
|
}
|
|
1739
1783
|
/**
|
|
@@ -2588,7 +2632,15 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2588
2632
|
if (!fs.existsSync(resolved)) {
|
|
2589
2633
|
return { text: `路径不存在: ${resolved}` };
|
|
2590
2634
|
}
|
|
2591
|
-
|
|
2635
|
+
// TOCTOU:existsSync 与 statSync 之间路径被删时 statSync 抛错,会落进队列
|
|
2636
|
+
// 任务的静默 catch(用户无反馈)——按路径无效回复,与下方 realpath 守卫同口径。
|
|
2637
|
+
let stat;
|
|
2638
|
+
try {
|
|
2639
|
+
stat = fs.statSync(resolved);
|
|
2640
|
+
}
|
|
2641
|
+
catch {
|
|
2642
|
+
return { text: `路径无效: ${resolved}` };
|
|
2643
|
+
}
|
|
2592
2644
|
if (!stat.isDirectory()) {
|
|
2593
2645
|
return { text: `不是目录: ${resolved}` };
|
|
2594
2646
|
}
|
|
@@ -3182,6 +3234,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3182
3234
|
return { text: `workspace "${name}" 不存在` };
|
|
3183
3235
|
if (!fs.existsSync(wsPath))
|
|
3184
3236
|
return { text: `路径不存在: ${wsPath}` };
|
|
3237
|
+
// 目录校验与 /cd、ls.switch 对齐:目录被删后同路径出现同名文件(或
|
|
3238
|
+
// workspace.json 被手改)时 realpath 照样成功——放行会让下一次 run
|
|
3239
|
+
// 以文件为 cwd spawn(ENOTDIR)。
|
|
3240
|
+
if (!isDirectorySafe(wsPath))
|
|
3241
|
+
return { text: `不是目录: ${wsPath}` };
|
|
3185
3242
|
// Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
|
|
3186
3243
|
// TOCTOU guard: mirror cmdCd — if the path vanishes
|
|
3187
3244
|
// between existsSync and realpath, reply gracefully instead of
|
|
@@ -175,6 +175,11 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
|
|
|
175
175
|
this.currentTranslator = null;
|
|
176
176
|
this.activeThreadId = null;
|
|
177
177
|
this.foreignThreadIds.clear();
|
|
178
|
+
// pendingApprovals 必须随轮次清理(与 base-acp-runner 同口径):连接长驻,
|
|
179
|
+
// 服务端请求 id 在 respawn 后会从小数字重新计数——留着旧条目等于让下一轮
|
|
180
|
+
// 迟到的卡片点击把载荷回给无关的新请求(coordinator 的超时 cancel 也可能
|
|
181
|
+
// 落在新一轮的连接上)。
|
|
182
|
+
this.pendingApprovals.clear();
|
|
178
183
|
}
|
|
179
184
|
async releaseConnection(cwd) {
|
|
180
185
|
await this.connectionManager.release(cwd);
|
|
@@ -394,6 +399,13 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
|
|
|
394
399
|
this.noteForeignThread(params);
|
|
395
400
|
return;
|
|
396
401
|
}
|
|
402
|
+
if (method === NotificationMethod.SERVER_REQUEST_RESOLVED) {
|
|
403
|
+
// 审批被外部结算(Web UI 先答 / 服务端自动放行)时同步摘除 runner 侧
|
|
404
|
+
// 条目——否则迟到的卡片点击会向已结算的 requestId 回信(服务端报错)。
|
|
405
|
+
const requestId = params?.requestId;
|
|
406
|
+
if (requestId !== undefined)
|
|
407
|
+
this.pendingApprovals.delete(requestId);
|
|
408
|
+
}
|
|
397
409
|
const events = this.currentTranslator?.handleNotification(method, params) ?? [];
|
|
398
410
|
this.pushEvents(events);
|
|
399
411
|
}
|
|
@@ -132,6 +132,10 @@ export declare abstract class BaseAcpTranslator {
|
|
|
132
132
|
protected handleToolCall(event: ToolCallEvent, _sessionId: string): AcpTranslatorEvent[];
|
|
133
133
|
/**
|
|
134
134
|
* tool_call_update → user/tool_result. status:'failed' → is_error:true.
|
|
135
|
+
*
|
|
136
|
+
* 终态(completed/failed)产出 tool_result;非终态的 tool_result 由
|
|
137
|
+
* {@link emitToolResultForNonTerminalUpdate} 裁定(reducer 见到 tool_result
|
|
138
|
+
* 即置 status:'ok'——无内容的非终态 tool_result 会把运行中的工具提前翻成 ✅)。
|
|
135
139
|
*/
|
|
136
140
|
protected handleToolCallUpdate(event: ToolCallUpdateEvent, _sessionId: string): AcpTranslatorEvent[];
|
|
137
141
|
/**
|
|
@@ -183,6 +187,14 @@ export declare abstract class BaseAcpTranslator {
|
|
|
183
187
|
* ({output} / {error}).
|
|
184
188
|
*/
|
|
185
189
|
protected extractToolResultContent(event: ToolCallUpdateEvent): unknown;
|
|
190
|
+
/**
|
|
191
|
+
* 非终态 tool_call_update 是否附带 tool_result。默认 true(opencode seam
|
|
192
|
+
* 契约:in-progress 的输出快照随 tool_result 进面板)。kimi 覆写为
|
|
193
|
+
* 「有内容才发」——它的 in-progress 只带流式 args,空 tool_result 会让
|
|
194
|
+
* reducer 把运行中的工具提前置 ✅ + 空输出(终态丢失时面板停在错误的
|
|
195
|
+
* 「已完成」)。
|
|
196
|
+
*/
|
|
197
|
+
protected emitToolResultForNonTerminalUpdate(content: unknown): boolean;
|
|
186
198
|
/**
|
|
187
199
|
* usage_update → context occupancy bookkeeping. The wire shape is
|
|
188
200
|
* {used, size} (+ opencode: cost:{amount}) — context occupancy, not
|
|
@@ -269,6 +269,10 @@ export class BaseAcpTranslator {
|
|
|
269
269
|
}
|
|
270
270
|
/**
|
|
271
271
|
* tool_call_update → user/tool_result. status:'failed' → is_error:true.
|
|
272
|
+
*
|
|
273
|
+
* 终态(completed/failed)产出 tool_result;非终态的 tool_result 由
|
|
274
|
+
* {@link emitToolResultForNonTerminalUpdate} 裁定(reducer 见到 tool_result
|
|
275
|
+
* 即置 status:'ok'——无内容的非终态 tool_result 会把运行中的工具提前翻成 ✅)。
|
|
272
276
|
*/
|
|
273
277
|
handleToolCallUpdate(event, _sessionId) {
|
|
274
278
|
const isError = event.status === 'failed';
|
|
@@ -293,6 +297,13 @@ export class BaseAcpTranslator {
|
|
|
293
297
|
else if (event.rawInput !== undefined && event.rawInput !== null) {
|
|
294
298
|
this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
|
|
295
299
|
}
|
|
300
|
+
// 非终态是否发 tool_result 由子类裁定(emitToolResultForNonTerminalUpdate):
|
|
301
|
+
// opencode 的 in-progress 带输出快照,seam 契约钉死「patch + tool_result」
|
|
302
|
+
// 都要发(面板在工具运行时显示部分输出);kimi 的 in-progress 只带流式
|
|
303
|
+
// args,空 tool_result 会让 reducer 把运行中的工具提前置 ✅ + 空输出。
|
|
304
|
+
if (!this.emitToolResultForNonTerminalUpdate(content)) {
|
|
305
|
+
return useUpdate ? [useUpdate] : [];
|
|
306
|
+
}
|
|
296
307
|
}
|
|
297
308
|
const events = [];
|
|
298
309
|
if (useUpdate)
|
|
@@ -390,6 +401,17 @@ export class BaseAcpTranslator {
|
|
|
390
401
|
extractToolResultContent(event) {
|
|
391
402
|
return event.rawOutput ?? '';
|
|
392
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* 非终态 tool_call_update 是否附带 tool_result。默认 true(opencode seam
|
|
406
|
+
* 契约:in-progress 的输出快照随 tool_result 进面板)。kimi 覆写为
|
|
407
|
+
* 「有内容才发」——它的 in-progress 只带流式 args,空 tool_result 会让
|
|
408
|
+
* reducer 把运行中的工具提前置 ✅ + 空输出(终态丢失时面板停在错误的
|
|
409
|
+
* 「已完成」)。
|
|
410
|
+
*/
|
|
411
|
+
emitToolResultForNonTerminalUpdate(content) {
|
|
412
|
+
void content;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
393
415
|
/**
|
|
394
416
|
* usage_update → context occupancy bookkeeping. The wire shape is
|
|
395
417
|
* {used, size} (+ opencode: cost:{amount}) — context occupancy, not
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getLogger } from '../../logger/index.js';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
3
|
/**
|
|
3
4
|
* Parse JSONL (newline-delimited JSON) from a readable stream.
|
|
4
5
|
*
|
|
@@ -30,6 +31,8 @@ export function createJSONLStream(stdout, options) {
|
|
|
30
31
|
// and push complete lines; the residual tail stays as array fragments until
|
|
31
32
|
// the next newline or stream end. Net allocation: O(n) instead of O(n²).
|
|
32
33
|
const partialFragments = [];
|
|
34
|
+
// 跨 chunk 多字节字符的边界安全解码器(见 stdout 'data' 处理)。
|
|
35
|
+
const decoder = new StringDecoder('utf8');
|
|
33
36
|
// accumulated byte length of partialFragments. Backpressure (above)
|
|
34
37
|
// only counts COMPLETE lines in lineQueue; a never-newline stream (malformed
|
|
35
38
|
// output, binary garbage) accumulates fragments without ever completing a
|
|
@@ -60,6 +63,11 @@ export function createJSONLStream(stdout, options) {
|
|
|
60
63
|
// Flush any trailing partial line and mark the stream finished. Shared by the
|
|
61
64
|
// 'end'/'close'/'error' handlers so flush logic lives in one place (nit).
|
|
62
65
|
function flushAndFinish() {
|
|
66
|
+
// decoder.end() 吐出被 chunk 边界切开的最后一个多字节字符的残余字节
|
|
67
|
+
//(无残尾时返回空串):不加这句,末尾被切开的字符会凭空丢失。
|
|
68
|
+
const tail = decoder.end();
|
|
69
|
+
if (tail)
|
|
70
|
+
partialFragments.push(tail);
|
|
63
71
|
if (partialFragments.length > 0) {
|
|
64
72
|
const trimmed = partialFragments.join('').trim();
|
|
65
73
|
if (trimmed)
|
|
@@ -109,7 +117,10 @@ export function createJSONLStream(stdout, options) {
|
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
stdout.on('data', (chunk) => {
|
|
112
|
-
|
|
120
|
+
// StringDecoder 按字节边界解码:多字节字符(中文/emoji)被 chunk 边界
|
|
121
|
+
// 切开时,裸 toString 会产出不可逆的 U+FFFD(同族修复见
|
|
122
|
+
// jsonrpc/transport.ts,claude 的中文输出走本流)。
|
|
123
|
+
const text = decoder.write(chunk);
|
|
113
124
|
// split on newlines. The first segment continues the pending partial
|
|
114
125
|
// line (joined with accumulated fragments — one join per chunk, not per
|
|
115
126
|
// byte); subsequent segments are complete lines. The last segment is the
|
|
@@ -147,6 +147,12 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
|
|
|
147
147
|
*
|
|
148
148
|
* `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
|
|
149
149
|
* setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
|
|
150
|
+
*
|
|
151
|
+
* 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
|
|
152
|
+
* baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
|
|
153
|
+
* 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
|
|
154
|
+
* 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
|
|
155
|
+
* 误报 "Codex app-server connection closed"。
|
|
150
156
|
*/
|
|
151
157
|
notifyIdle(cwd: string): void;
|
|
152
158
|
private createClient;
|
|
@@ -84,7 +84,17 @@ export class ConnectionManager {
|
|
|
84
84
|
if (existing) {
|
|
85
85
|
// If a create is in flight, wait for it
|
|
86
86
|
if (existing.createPromise) {
|
|
87
|
-
|
|
87
|
+
const client = await existing.createPromise;
|
|
88
|
+
// 快速路径复核(与下方建槽方的竞态复核对称):await 期间槽位被
|
|
89
|
+
// release()/disposeAll() 删掉时,建槽方会 dispose 该连接并重试——
|
|
90
|
+
// 直接返回会把「已被建槽方 dispose 的连接」交给本调用方(表现为
|
|
91
|
+
// setupTurn 抛 ConnectionLostError 走 respawn-retry 自愈)。槽位
|
|
92
|
+
// 不再持有该 client 时与本轮创建同路:重试。
|
|
93
|
+
const current = this.slots.get(cwd);
|
|
94
|
+
if (current && current.client === client) {
|
|
95
|
+
return client;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
88
98
|
}
|
|
89
99
|
// Reuse when the (session-aware) reuse predicate holds — e.g. the client
|
|
90
100
|
// is ready and its transport is alive (default), or it is bound to the
|
|
@@ -183,18 +193,27 @@ export class ConnectionManager {
|
|
|
183
193
|
*
|
|
184
194
|
* `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
|
|
185
195
|
* setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
|
|
196
|
+
*
|
|
197
|
+
* 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
|
|
198
|
+
* baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
|
|
199
|
+
* 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
|
|
200
|
+
* 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
|
|
201
|
+
* 误报 "Codex app-server connection closed"。
|
|
186
202
|
*/
|
|
187
203
|
notifyIdle(cwd) {
|
|
188
204
|
if (this.idleTtlMs <= 0)
|
|
189
205
|
return;
|
|
190
206
|
const slot = this.slots.get(cwd);
|
|
191
|
-
if (slot)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
207
|
+
if (!slot)
|
|
208
|
+
return;
|
|
209
|
+
this.clearIdleTimer(slot);
|
|
210
|
+
slot.idleTimer = setTimeout(() => {
|
|
211
|
+
slot.idleTimer = null;
|
|
212
|
+
if (this.slots.get(cwd) !== slot)
|
|
213
|
+
return;
|
|
214
|
+
getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
|
|
215
|
+
this.disposeSlot(cwd);
|
|
216
|
+
}, this.idleTtlMs);
|
|
198
217
|
}
|
|
199
218
|
// =========================================================================
|
|
200
219
|
// Internal
|
|
@@ -222,7 +241,13 @@ export class ConnectionManager {
|
|
|
222
241
|
getLogger().info(`[${this.logTag}] connection closed cwd=${cwd}`);
|
|
223
242
|
// 只清理自己创建的 slot:并发 release + 重新 acquire 后 slot 可能已被
|
|
224
243
|
// 新连接占据,误删会破坏新连接。
|
|
225
|
-
|
|
244
|
+
const current = this.slots.get(cwd);
|
|
245
|
+
if (current?.client === client) {
|
|
246
|
+
// 进程自退(codex app-server 偶发 exit 0,非本程序发起,不经
|
|
247
|
+
// disposeSlot)也要撤掉挂在旧 slot 上的 idle TTL:定时器不随 slot
|
|
248
|
+
// 删除而取消,到期时会按 cwd 误杀继任连接的在途 turn(2026-10-24
|
|
249
|
+
// 线上事故,见 notifyIdle 的身份校验与对应回归测试)。
|
|
250
|
+
this.clearIdleTimer(current);
|
|
226
251
|
this.slots.delete(cwd);
|
|
227
252
|
this.onConnectionLost?.(cwd);
|
|
228
253
|
}
|