lark-remote 0.3.0 → 0.4.1
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 +35 -0
- package/README.en.md +6 -6
- package/README.md +8 -7
- package/dist/binder.d.ts +1 -1
- package/dist/binder.js +1 -1
- package/dist/bridge/final-usage.js +2 -2
- package/dist/bridge/inbound-media.d.ts +13 -20
- package/dist/bridge/inbound-media.js +107 -91
- package/dist/bridge/index.d.ts +6 -6
- package/dist/bridge/index.js +81 -87
- package/dist/cli.js +5 -1
- package/dist/clone.d.ts +1 -1
- package/dist/common/fs.js +1 -1
- package/dist/config/dir.d.ts +9 -0
- package/dist/config/dir.js +69 -0
- package/dist/config/index.d.ts +22 -3
- package/dist/config/index.js +26 -4
- package/dist/connector/index.d.ts +28 -1
- package/dist/connector/index.js +155 -31
- package/dist/error-classification.d.ts +7 -0
- package/dist/error-classification.js +37 -1
- package/dist/inbound/event.d.ts +41 -0
- package/dist/inbound/event.js +11 -0
- package/dist/inbound/placeholder.d.ts +26 -0
- package/dist/inbound/placeholder.js +149 -0
- package/dist/inbound/prompt.d.ts +20 -0
- package/dist/inbound/prompt.js +47 -0
- package/dist/inbound/receipt.d.ts +28 -0
- package/dist/inbound/receipt.js +62 -0
- package/dist/inbound/turn-assembler.d.ts +63 -0
- package/dist/inbound/turn-assembler.js +233 -0
- package/dist/inbound/turn.d.ts +48 -0
- package/dist/inbound/turn.js +10 -0
- package/dist/index.js +108 -73
- package/dist/platform/command.js +1 -1
- package/dist/platform/sleep-blocker.d.ts +17 -0
- package/dist/platform/sleep-blocker.js +73 -0
- package/dist/platform/terminator-win32.js +1 -1
- package/dist/restart.d.ts +4 -4
- package/dist/restart.js +5 -5
- package/dist/router/config/kimi.js +4 -3
- package/dist/router/config/opencode.d.ts +1 -1
- package/dist/router/config/opencode.js +11 -0
- package/dist/router/index.d.ts +17 -0
- package/dist/router/index.js +53 -15
- package/dist/runner/common/acp/base-acp-runner.js +7 -2
- package/dist/runner/common/acp/base-translator.d.ts +19 -0
- package/dist/runner/common/acp/base-translator.js +82 -0
- package/dist/runner/common/acp/protocol-types.d.ts +16 -2
- package/dist/runner/common/acp/protocol-types.js +3 -0
- package/dist/runner/common/connection-based-runner.js +6 -2
- package/dist/runner/kimi/acp/runner.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.js +1 -1
- package/dist/runner/kimi/acp/translator.js +60 -4
- package/dist/runner/opencode/acp/runner.d.ts +1 -1
- package/dist/session/session-store.d.ts +1 -1
- package/dist/session/session-store.js +1 -1
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/index.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboundTurnAssembler:统一静默期窗口 + 合并 + 下载等待 + commit。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`docs/zh/architecture/inbound-unified-input-design.md` §4/§5.2。
|
|
5
|
+
*
|
|
6
|
+
* 规则:
|
|
7
|
+
* - 任何事件到达 → **重置** 700ms 定时器(按 `userId:chatId` 分桶),保证图/文任意顺序等价;
|
|
8
|
+
* - 定时器到点 → 若本 turn 仍有 in-flight 下载 → 等待,落定后再 commit;
|
|
9
|
+
* - 下载失败/超时 → 该项进 `rejected`,然后照常 commit;
|
|
10
|
+
* - commit → 构造 prompt(`prompt.ts`)→ `onCommit`;
|
|
11
|
+
* - **无文本且只有附件 → 不 commit**(决策 2),只发 `onReceipt` 回执;
|
|
12
|
+
* - 命令消息由调用方 `flush()` 强制提交,不等窗口。
|
|
13
|
+
*/
|
|
14
|
+
import { getLogger } from '../logger/index.js';
|
|
15
|
+
import { isDownloadablePlaceholder } from './placeholder.js';
|
|
16
|
+
import { buildPrompt } from './prompt.js';
|
|
17
|
+
import { buildReceipt, unsupportedReason } from './receipt.js';
|
|
18
|
+
import { bucketKey, } from './turn.js';
|
|
19
|
+
/** 统一静默期窗口(ms)——决策 1:不做「纯文本零延迟」快路径。 */
|
|
20
|
+
export const DEFAULT_TURN_WINDOW_MS = 700;
|
|
21
|
+
export class InboundTurnAssembler {
|
|
22
|
+
deps;
|
|
23
|
+
buckets = new Map();
|
|
24
|
+
windowMs;
|
|
25
|
+
setTimer;
|
|
26
|
+
clearTimer;
|
|
27
|
+
disposed = false;
|
|
28
|
+
constructor(deps) {
|
|
29
|
+
this.deps = deps;
|
|
30
|
+
this.windowMs = deps.windowMs ?? DEFAULT_TURN_WINDOW_MS;
|
|
31
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
32
|
+
this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
33
|
+
}
|
|
34
|
+
/** 投递一个入站事件(文本 / 媒体[in-flight] / 拒绝)。 */
|
|
35
|
+
ingest(event) {
|
|
36
|
+
if (this.disposed) {
|
|
37
|
+
// 已 dispose(进程收尾):不再装配,但必须吞掉 in-flight promise 的 rejection,
|
|
38
|
+
// 否则它会以 unhandledRejection 形式打死进程。
|
|
39
|
+
if (event.kind === 'media')
|
|
40
|
+
void event.outcome.catch(() => { });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const key = bucketKey(event.userId, event.chatId);
|
|
44
|
+
const bucket = this.ensureBucket(key, event);
|
|
45
|
+
if (!bucket.messageIds.includes(event.messageId))
|
|
46
|
+
bucket.messageIds.push(event.messageId);
|
|
47
|
+
if (bucket.replyToMessageId === undefined && event.replyToMessageId !== undefined) {
|
|
48
|
+
bucket.replyToMessageId = event.replyToMessageId;
|
|
49
|
+
}
|
|
50
|
+
switch (event.kind) {
|
|
51
|
+
case 'text': {
|
|
52
|
+
if (event.text !== '')
|
|
53
|
+
bucket.texts.push(event.text);
|
|
54
|
+
// 可下载占位符(图/文件/视频/语音/表情)由媒体通道处理,不在此记 rejected;
|
|
55
|
+
// 其余(位置/名片/投票/合并转发/降级态/未知)拿不到,明确回执。
|
|
56
|
+
for (const kind of event.placeholders) {
|
|
57
|
+
if (isDownloadablePlaceholder(kind))
|
|
58
|
+
continue;
|
|
59
|
+
// 合并转发的子消息文本已被 SDK 渲染(text 非空)→ 只保留文本;
|
|
60
|
+
// 附件能不能拿到由媒体通道的 outcome 决定,不在这里重复报「不支持」。
|
|
61
|
+
if (kind === 'forwarded' && event.text !== '')
|
|
62
|
+
continue;
|
|
63
|
+
this.pushRejected(bucket, {
|
|
64
|
+
kind,
|
|
65
|
+
reason: unsupportedReason(kind),
|
|
66
|
+
sourceMsgId: event.messageId,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'media': {
|
|
72
|
+
this.trackMediaOutcome(bucket, key, event.messageId, event.outcome);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'rejected': {
|
|
76
|
+
this.pushRejected(bucket, {
|
|
77
|
+
kind: event.rejectedKind ?? 'unknown',
|
|
78
|
+
reason: event.reason,
|
|
79
|
+
sourceMsgId: event.messageId,
|
|
80
|
+
});
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.armTimer(key, bucket);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 强制提交窗口内已装配的内容(命令到达、退出前冲刷)。
|
|
88
|
+
* 仍有 in-flight 下载时先等它落定(避免丢掉附件路径)。
|
|
89
|
+
*/
|
|
90
|
+
async flush(userId, chatId, reason = 'flush') {
|
|
91
|
+
const key = bucketKey(userId, chatId);
|
|
92
|
+
const bucket = this.buckets.get(key);
|
|
93
|
+
if (!bucket)
|
|
94
|
+
return;
|
|
95
|
+
bucket.windowElapsed = true;
|
|
96
|
+
bucket.forcedReason = reason;
|
|
97
|
+
if (bucket.pendingOutcomes.size > 0) {
|
|
98
|
+
await Promise.all([...bucket.pendingOutcomes]);
|
|
99
|
+
}
|
|
100
|
+
await (bucket.commitPromise ?? this.tryCommit(key, reason));
|
|
101
|
+
}
|
|
102
|
+
/** 冲刷全部窗口(/exit、/restart 干净退出前调用)。 */
|
|
103
|
+
async flushAll(reason = 'flush') {
|
|
104
|
+
const buckets = [...this.buckets.values()];
|
|
105
|
+
await Promise.all(buckets.map((b) => this.flush(b.userId, b.chatId, reason)));
|
|
106
|
+
}
|
|
107
|
+
/** 进程退出:丢弃定时器,避免悬挂。 */
|
|
108
|
+
dispose() {
|
|
109
|
+
this.disposed = true;
|
|
110
|
+
for (const bucket of this.buckets.values()) {
|
|
111
|
+
if (bucket.timer !== undefined)
|
|
112
|
+
this.clearTimer(bucket.timer);
|
|
113
|
+
}
|
|
114
|
+
this.buckets.clear();
|
|
115
|
+
}
|
|
116
|
+
ensureBucket(key, event) {
|
|
117
|
+
const existing = this.buckets.get(key);
|
|
118
|
+
if (existing)
|
|
119
|
+
return existing;
|
|
120
|
+
const bucket = {
|
|
121
|
+
userId: event.userId,
|
|
122
|
+
chatId: event.chatId,
|
|
123
|
+
texts: [],
|
|
124
|
+
attachments: [],
|
|
125
|
+
rejected: [],
|
|
126
|
+
messageIds: [],
|
|
127
|
+
pendingOutcomes: new Set(),
|
|
128
|
+
windowElapsed: false,
|
|
129
|
+
committed: false,
|
|
130
|
+
};
|
|
131
|
+
this.buckets.set(key, bucket);
|
|
132
|
+
return bucket;
|
|
133
|
+
}
|
|
134
|
+
pushRejected(bucket, item) {
|
|
135
|
+
const dup = bucket.rejected.some((r) => r.sourceMsgId === item.sourceMsgId && r.reason === item.reason);
|
|
136
|
+
if (!dup)
|
|
137
|
+
bucket.rejected.push(item);
|
|
138
|
+
}
|
|
139
|
+
armTimer(key, bucket) {
|
|
140
|
+
if (bucket.timer !== undefined)
|
|
141
|
+
this.clearTimer(bucket.timer);
|
|
142
|
+
bucket.windowElapsed = false;
|
|
143
|
+
bucket.timer = this.setTimer(() => {
|
|
144
|
+
bucket.timer = undefined;
|
|
145
|
+
bucket.windowElapsed = true;
|
|
146
|
+
if (bucket.pendingOutcomes.size > 0)
|
|
147
|
+
return; // 等 in-flight 下载落定
|
|
148
|
+
void this.tryCommit(key, bucket.forcedReason ?? 'idle');
|
|
149
|
+
}, this.windowMs);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 登记一次 in-flight 媒体下载:落定后合并结果,并在窗口已到期时触发 commit。
|
|
153
|
+
* 下载异常(含超时)记入 rejected,不阻断本 turn 的其它内容。
|
|
154
|
+
*/
|
|
155
|
+
trackMediaOutcome(bucket, key, messageId, outcome) {
|
|
156
|
+
const tracked = Promise.resolve(outcome)
|
|
157
|
+
.then((result) => {
|
|
158
|
+
bucket.attachments.push(...result.attachments);
|
|
159
|
+
for (const item of result.rejected)
|
|
160
|
+
this.pushRejected(bucket, item);
|
|
161
|
+
}, (err) => {
|
|
162
|
+
this.pushRejected(bucket, {
|
|
163
|
+
kind: 'file',
|
|
164
|
+
reason: `下载失败: ${err?.message ?? String(err)}`,
|
|
165
|
+
sourceMsgId: messageId,
|
|
166
|
+
});
|
|
167
|
+
})
|
|
168
|
+
.then(() => {
|
|
169
|
+
bucket.pendingOutcomes.delete(tracked);
|
|
170
|
+
if (bucket.windowElapsed && bucket.pendingOutcomes.size === 0 && !bucket.committed) {
|
|
171
|
+
void this.tryCommit(key, bucket.forcedReason ?? 'idle');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
bucket.pendingOutcomes.add(tracked);
|
|
175
|
+
}
|
|
176
|
+
tryCommit(key, reason) {
|
|
177
|
+
const bucket = this.buckets.get(key);
|
|
178
|
+
if (!bucket || bucket.committed)
|
|
179
|
+
return Promise.resolve();
|
|
180
|
+
if (bucket.pendingOutcomes.size > 0)
|
|
181
|
+
return Promise.resolve();
|
|
182
|
+
bucket.committed = true;
|
|
183
|
+
this.buckets.delete(key);
|
|
184
|
+
if (bucket.timer !== undefined) {
|
|
185
|
+
this.clearTimer(bucket.timer);
|
|
186
|
+
bucket.timer = undefined;
|
|
187
|
+
}
|
|
188
|
+
const promise = this.commit(bucket, reason);
|
|
189
|
+
bucket.commitPromise = promise;
|
|
190
|
+
return promise;
|
|
191
|
+
}
|
|
192
|
+
async commit(bucket, reason) {
|
|
193
|
+
const noText = bucket.texts.length === 0;
|
|
194
|
+
const turn = {
|
|
195
|
+
userId: bucket.userId,
|
|
196
|
+
chatId: bucket.chatId,
|
|
197
|
+
replyToMessageId: bucket.replyToMessageId,
|
|
198
|
+
texts: bucket.texts,
|
|
199
|
+
attachments: bucket.attachments,
|
|
200
|
+
rejected: bucket.rejected,
|
|
201
|
+
messageIds: bucket.messageIds,
|
|
202
|
+
commitReason: noText ? 'no-text' : reason,
|
|
203
|
+
};
|
|
204
|
+
const ctx = {
|
|
205
|
+
userId: bucket.userId,
|
|
206
|
+
chatId: bucket.chatId,
|
|
207
|
+
messageId: bucket.messageIds[bucket.messageIds.length - 1] ?? '',
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
if (noText) {
|
|
211
|
+
// 决策 2:纯附件不自动起 turn,只回执并引导用户说一句话。
|
|
212
|
+
const text = buildReceipt({
|
|
213
|
+
saved: bucket.attachments.map((a) => a.path),
|
|
214
|
+
rejected: bucket.rejected,
|
|
215
|
+
startWorkHint: true,
|
|
216
|
+
});
|
|
217
|
+
if (text)
|
|
218
|
+
await this.deps.onReceipt?.(ctx, text);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await this.deps.onCommit(turn, buildPrompt(turn));
|
|
222
|
+
if (bucket.rejected.length > 0) {
|
|
223
|
+
const text = buildReceipt({ saved: [], rejected: bucket.rejected, startWorkHint: false });
|
|
224
|
+
if (text)
|
|
225
|
+
await this.deps.onReceipt?.(ctx, text);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
getLogger().error('[inbound] turn commit failed:', err?.message ?? String(err));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=turn-assembler.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一入站回合(InboundTurn)模型。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`docs/zh/architecture/inbound-unified-input-design.md` §4。
|
|
5
|
+
* 核心不变量:**一个用户意图 = 一个 turn**,且任何进 agent 的内容都必须已落到本地路径。
|
|
6
|
+
*/
|
|
7
|
+
/** 附件种类(命名/展示语义)。与下载语义 `InboundMediaItem.type` 分离(§5.3)。 */
|
|
8
|
+
export type AttachmentKind = 'image' | 'file' | 'video' | 'audio' | 'sticker';
|
|
9
|
+
export interface InboundAttachment {
|
|
10
|
+
/** 绝对路径(落盘后位置)。 */
|
|
11
|
+
path: string;
|
|
12
|
+
kind: AttachmentKind;
|
|
13
|
+
/** 来源飞书 messageId:下载凭据的一部分,也是排障锚点。 */
|
|
14
|
+
sourceMsgId: string;
|
|
15
|
+
/** 原始文件名(有则保留,用于 prompt 标注与排障)。 */
|
|
16
|
+
originalName?: string;
|
|
17
|
+
/** 视频/语音时长(毫秒)。 */
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** 拿不到、不支持、超限、失败的项 —— 需要回执给人(不进 agent)。 */
|
|
21
|
+
export interface RejectedItem {
|
|
22
|
+
/** 原 msg_type / 占位符种类 / 资源种类。 */
|
|
23
|
+
kind: string;
|
|
24
|
+
/** 面向人的说明。 */
|
|
25
|
+
reason: string;
|
|
26
|
+
sourceMsgId: string;
|
|
27
|
+
}
|
|
28
|
+
/** 提交原因,用于观测与测试断言。 */
|
|
29
|
+
export type CommitReason = 'idle' | 'timeout' | 'flush' | 'no-text';
|
|
30
|
+
export interface InboundTurn {
|
|
31
|
+
userId: string;
|
|
32
|
+
chatId: string;
|
|
33
|
+
replyToMessageId?: string;
|
|
34
|
+
/** 用户文本片段,按到达顺序;已剥离结构占位符。 */
|
|
35
|
+
texts: string[];
|
|
36
|
+
/** 已落盘的附件(绝对路径),按到达顺序。 */
|
|
37
|
+
attachments: InboundAttachment[];
|
|
38
|
+
rejected: RejectedItem[];
|
|
39
|
+
/** 本 turn 覆盖的飞书 messageId(回执、去重、排障)。 */
|
|
40
|
+
messageIds: string[];
|
|
41
|
+
commitReason: CommitReason;
|
|
42
|
+
}
|
|
43
|
+
/** 媒体下载 + 落盘的结果(装配器等待它落定后才 commit)。 */
|
|
44
|
+
export interface MediaOutcome {
|
|
45
|
+
attachments: InboundAttachment[];
|
|
46
|
+
rejected: RejectedItem[];
|
|
47
|
+
}
|
|
48
|
+
export declare function bucketKey(userId: string, chatId: string): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一入站回合(InboundTurn)模型。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`docs/zh/architecture/inbound-unified-input-design.md` §4。
|
|
5
|
+
* 核心不变量:**一个用户意图 = 一个 turn**,且任何进 agent 的内容都必须已落到本地路径。
|
|
6
|
+
*/
|
|
7
|
+
export function bucketKey(userId, chatId) {
|
|
8
|
+
return `${userId}:${chatId}`;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=turn.js.map
|
package/dist/index.js
CHANGED
|
@@ -19,9 +19,13 @@ import { OwnerBinder, formatBindGuidance } from './binder.js';
|
|
|
19
19
|
import { CloneSession } from './clone.js';
|
|
20
20
|
import { InstanceAlreadyRunningError, InstanceLock } from './instance-lock.js';
|
|
21
21
|
import { spawnReplacementBridge, waitForPreviousInstance } from './restart.js';
|
|
22
|
+
import { currentPlatform } from './platform/select.js';
|
|
23
|
+
import { startSleepBlocker } from './platform/sleep-blocker.js';
|
|
22
24
|
import { checkLatestVersion, isNewer, runInstallLatest, formatUpdateHint } from './update/index.js';
|
|
23
25
|
import { classifyRejection } from './error-classification.js';
|
|
24
26
|
import { WorkspaceStore } from './workspace/index.js';
|
|
27
|
+
import { InboundTurnAssembler } from './inbound/turn-assembler.js';
|
|
28
|
+
import { stripPlaceholders } from './inbound/placeholder.js';
|
|
25
29
|
import { buildSessionHistoryCard } from './router/card-helpers.js';
|
|
26
30
|
import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/card-shared.js';
|
|
27
31
|
import path from 'node:path';
|
|
@@ -195,6 +199,12 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
195
199
|
model,
|
|
196
200
|
sessionReader: opencodeSessionReader,
|
|
197
201
|
mode: ocConfig?.mode ?? 'build',
|
|
202
|
+
binary: ocConfig?.acp?.binary,
|
|
203
|
+
requestTimeoutMs: ocConfig?.acp?.requestTimeoutMs,
|
|
204
|
+
idleTtlMs: ocConfig?.acp?.idleTtlMs,
|
|
205
|
+
turnIdleTimeoutMs: ocConfig?.acp?.turnIdleTimeoutMinutes != null
|
|
206
|
+
? ocConfig.acp.turnIdleTimeoutMinutes * 60_000
|
|
207
|
+
: undefined,
|
|
198
208
|
});
|
|
199
209
|
});
|
|
200
210
|
sessionReaderRegistry.register('opencode', opencodeSessionReader);
|
|
@@ -290,9 +300,48 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
290
300
|
}
|
|
291
301
|
/** Setup message and card action handlers. */
|
|
292
302
|
function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession) {
|
|
293
|
-
//
|
|
303
|
+
// 入站统一装配器(2026-09-15):图/文任意顺序 → 同一个 turn、同一个 prompt,
|
|
304
|
+
// 附件路径统一注入 prompt;无文本纯附件只回执(决策 2)。
|
|
305
|
+
const assembler = new InboundTurnAssembler({
|
|
306
|
+
onCommit: (turn, prompt) => {
|
|
307
|
+
// 入队时刻(T0)快照 workspace + agent/session 绑定,语义与旧路径一致
|
|
308
|
+
// (排队期间 /cd、/config 不再导致语义漂移)。
|
|
309
|
+
const messageId = turn.messageIds[turn.messageIds.length - 1] ?? '';
|
|
310
|
+
const ctx = { userId: turn.userId, chatId: turn.chatId, messageId };
|
|
311
|
+
let workspace = sessionStore.getCwd(turn.userId) ?? '';
|
|
312
|
+
if (!workspace && workspaceStore) {
|
|
313
|
+
const workspaces = workspaceStore.list();
|
|
314
|
+
if (workspaces.length > 0)
|
|
315
|
+
workspace = workspaces[0].path;
|
|
316
|
+
}
|
|
317
|
+
const binding = bridge.currentBinding(turn.userId);
|
|
318
|
+
bridge.enqueue(workspace, async () => {
|
|
319
|
+
// allowCommandPrefix: false —— prompt 已剥离占位符,绝不再做命令分发。
|
|
320
|
+
await router.handle(prompt, ctx, {
|
|
321
|
+
cwdOverride: workspace,
|
|
322
|
+
binding,
|
|
323
|
+
allowCommandPrefix: false,
|
|
324
|
+
});
|
|
325
|
+
}, {
|
|
326
|
+
taskMeta: {
|
|
327
|
+
userId: turn.userId,
|
|
328
|
+
chatId: turn.chatId,
|
|
329
|
+
messageId,
|
|
330
|
+
messagePreview: prompt.slice(0, 3000),
|
|
331
|
+
binding,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
onReceipt: async (ctx, text) => {
|
|
336
|
+
await bridge.sendResult({ text }, ctx);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
router.setInboundFlusher(() => assembler.flushAll('flush'));
|
|
340
|
+
// 入站媒体(图片/文件/视频/语音/表情):先认证后下载(P1 review 修复)。
|
|
294
341
|
// connector 只上报"媒体到达",这里先过 owner + enabled 闸门,通过后才
|
|
295
342
|
// 下载——未认证/关闭配置时不会发生任何网络下载或内存/磁盘占用。
|
|
343
|
+
// 下载是异步的:立刻把在途 promise 交给装配器(`kind: 'media'`),
|
|
344
|
+
// 保证「先图后文」不会先提交一个只含文本的 turn。
|
|
296
345
|
connector.setInboundMediaDetectedHandler((msg) => {
|
|
297
346
|
if (!binder.isOwner(msg.userId)) {
|
|
298
347
|
logger.warn(`[media] rejected inbound media from non-owner ${msg.userId}`);
|
|
@@ -302,35 +351,45 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
302
351
|
// 启动时捕获的 config 参数会过期(P2 review 修复)。
|
|
303
352
|
if (!router.config.inboundMedia.enabled) {
|
|
304
353
|
// 不静默(P3 review):关闭时给 owner 明确反馈,避免发图后无任何反应。
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
text: '⚠️ 入站媒体保存已关闭(inboundMedia.enabled: false),未保存文件。' +
|
|
309
|
-
'如需自动保存图片/文件,请开启后重试',
|
|
310
|
-
}, {
|
|
354
|
+
// 不下载、不落盘,只进 rejected(随 turn 回执一起发出)。
|
|
355
|
+
assembler.ingest({
|
|
356
|
+
kind: 'rejected',
|
|
311
357
|
userId: msg.userId,
|
|
312
358
|
chatId: msg.chatId,
|
|
313
359
|
messageId: msg.messageId,
|
|
314
|
-
|
|
315
|
-
|
|
360
|
+
replyToMessageId: msg.replyToMessageId,
|
|
361
|
+
rawContentType: msg.rawContentType,
|
|
362
|
+
rejectedKind: 'file',
|
|
363
|
+
reason: '入站媒体保存已关闭(inboundMedia.enabled: false),未保存文件。' +
|
|
364
|
+
'如需自动保存图片/文件,请开启后重试',
|
|
365
|
+
});
|
|
316
366
|
return;
|
|
317
367
|
}
|
|
318
|
-
|
|
368
|
+
const outcome = (async () => {
|
|
319
369
|
const payload = await connector.downloadInboundMedia(msg, {
|
|
320
370
|
maxFileSizeMb: router.config.inboundMedia.maxFileSizeMb,
|
|
321
371
|
});
|
|
322
372
|
try {
|
|
323
|
-
await bridge.
|
|
373
|
+
return await bridge.saveInboundMedia(payload);
|
|
324
374
|
}
|
|
325
375
|
catch (err) {
|
|
326
|
-
//
|
|
376
|
+
// save() 的正常路径会清理临时文件;这里兜底 save 进入 mkdir
|
|
327
377
|
// try 之前意外抛错的情况,避免 os.tmpdir 残留。
|
|
328
378
|
for (const item of payload.media) {
|
|
329
379
|
silentlyUnlink(item.tempPath);
|
|
330
380
|
}
|
|
331
381
|
throw err;
|
|
332
382
|
}
|
|
333
|
-
})()
|
|
383
|
+
})();
|
|
384
|
+
assembler.ingest({
|
|
385
|
+
kind: 'media',
|
|
386
|
+
userId: msg.userId,
|
|
387
|
+
chatId: msg.chatId,
|
|
388
|
+
messageId: msg.messageId,
|
|
389
|
+
replyToMessageId: msg.replyToMessageId,
|
|
390
|
+
rawContentType: msg.rawContentType,
|
|
391
|
+
outcome,
|
|
392
|
+
});
|
|
334
393
|
});
|
|
335
394
|
connector.setMessageHandler((msg) => {
|
|
336
395
|
// 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息(任意内容)认领
|
|
@@ -385,10 +444,7 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
385
444
|
return;
|
|
386
445
|
}
|
|
387
446
|
// decision.kind === 'owner':正常处理(不再每条覆盖 startup-contact)
|
|
388
|
-
logger.info(`message from ${msg.userId}: ${msg.content.slice(0, 100)}`);
|
|
389
|
-
// 文本到达先冲刷待合批的媒体保存提示(先图后文字时文字到达
|
|
390
|
-
// 先冲刷批次,避免合批提示被后续消息淹没/丢图)。
|
|
391
|
-
bridge.flushMediaNotifications(msg.userId, msg.chatId);
|
|
447
|
+
logger.info(`message from ${msg.userId} (${msg.rawContentType}): ${msg.content.slice(0, 100)}`);
|
|
392
448
|
// Add Typing reaction to indicate "still alive"
|
|
393
449
|
void connector.addReaction(msg.messageId, 'Typing');
|
|
394
450
|
// 复制分身流程活跃期:一切消息先经 clone 状态机(stop 命令除外,保留其
|
|
@@ -423,67 +479,38 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
423
479
|
})().catch((err) => logger.error('[control] /stop failed:', err));
|
|
424
480
|
return;
|
|
425
481
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
})
|
|
433
|
-
.catch((err) => logger.error('[control] slash command failed:', err));
|
|
434
|
-
return;
|
|
482
|
+
// 先清洗,后判语义(§5.6):结构占位符永远不能触发命令分发。
|
|
483
|
+
// `` 的首字符恰好是 `!` —— 2026-09-15 事故就是这么进 shell 的。
|
|
484
|
+
const stripped = stripPlaceholders(content);
|
|
485
|
+
if (stripped.unknownTags.length > 0) {
|
|
486
|
+
logger.warn(`[inbound] unknown placeholder tags stripped: ${stripped.unknownTags.join(', ')} ` +
|
|
487
|
+
`(msg_type=${msg.rawContentType})`);
|
|
435
488
|
}
|
|
436
|
-
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (content.trim().startsWith('!')) {
|
|
489
|
+
const commandEligible = msg.rawContentType === 'text' && stripped.kinds.length === 0 && stripped.clean !== '';
|
|
490
|
+
// 命令消息旁路装配窗口:其它 /命令 立即执行,窗口内已装配的内容
|
|
491
|
+
// 作为独立提交冲刷(/stop 已在上面处理,立即生效不等待)。
|
|
492
|
+
if (commandEligible && (stripped.clean.startsWith('/') || stripped.clean.startsWith('!'))) {
|
|
493
|
+
void assembler.flush(msg.userId, msg.chatId, 'flush');
|
|
442
494
|
void router
|
|
443
|
-
.handle(
|
|
495
|
+
.handle(stripped.clean, {
|
|
444
496
|
userId: msg.userId,
|
|
445
497
|
chatId: msg.chatId,
|
|
446
498
|
messageId: msg.messageId,
|
|
447
|
-
})
|
|
448
|
-
.catch((err) => logger.error('[control]
|
|
499
|
+
}, { allowCommandPrefix: true })
|
|
500
|
+
.catch((err) => logger.error('[control] command failed:', err));
|
|
449
501
|
return;
|
|
450
502
|
}
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// Step4/D4: 入队时刻(T0)快照 agent+session,随任务闭包带到 T1 执行时刻,
|
|
463
|
-
// 避免排队期间 /new、/config 改写 live 状态导致语义漂移(方案 D4)。唯一
|
|
464
|
-
// 捕获点 Bridge.currentBinding。
|
|
465
|
-
const binding = bridge.currentBinding(msg.userId);
|
|
466
|
-
bridge.enqueue(workspace, async () => {
|
|
467
|
-
await router.handle(content, {
|
|
468
|
-
userId: msg.userId,
|
|
469
|
-
chatId: msg.chatId,
|
|
470
|
-
messageId: msg.messageId,
|
|
471
|
-
},
|
|
472
|
-
// P1-14: lane 与执行 cwd 同源 —— 入队时捕获的 workspace 显式传给
|
|
473
|
-
// forwardToClaude,排队期间 /cd 不再导致旧 lane 消息被 busy-drop。
|
|
474
|
-
// D4/Step4: binding 同源 —— 入队时快照的 agent+session 一并透传,
|
|
475
|
-
// 排队期间 /new、/config 不再导致旧 lane 消息语义漂移。
|
|
476
|
-
{ cwdOverride: workspace, binding });
|
|
477
|
-
}, {
|
|
478
|
-
taskMeta: {
|
|
479
|
-
userId: msg.userId,
|
|
480
|
-
chatId: msg.chatId,
|
|
481
|
-
messageId: msg.messageId,
|
|
482
|
-
messagePreview: content.slice(0, 3000),
|
|
483
|
-
// D3/Step4: binding 随 taskMeta 存进 QueuedTask,供替换闭包
|
|
484
|
-
// (queue.edit/queue.immediate)复用,不重新快照。
|
|
485
|
-
binding,
|
|
486
|
-
},
|
|
503
|
+
// 普通消息:进装配器,等静默期窗口到期(下载落定)后合并成一个 turn。
|
|
504
|
+
assembler.ingest({
|
|
505
|
+
kind: 'text',
|
|
506
|
+
userId: msg.userId,
|
|
507
|
+
chatId: msg.chatId,
|
|
508
|
+
messageId: msg.messageId,
|
|
509
|
+
replyToMessageId: msg.replyToMessageId,
|
|
510
|
+
rawContentType: msg.rawContentType,
|
|
511
|
+
text: stripped.clean,
|
|
512
|
+
placeholders: stripped.kinds,
|
|
513
|
+
unknownTags: stripped.unknownTags,
|
|
487
514
|
});
|
|
488
515
|
});
|
|
489
516
|
connector.setCardActionHandler(async (action) => {
|
|
@@ -622,9 +649,17 @@ async function main() {
|
|
|
622
649
|
}
|
|
623
650
|
const { config, configDir, logger, instanceLock } = await initializeCliAndConfig();
|
|
624
651
|
// Restart handoff: if spawned as a /restart replacement, wait for the old
|
|
625
|
-
//
|
|
652
|
+
// lark-remote to die (and release the instance lock) before acquiring it.
|
|
626
653
|
await waitForPreviousInstance();
|
|
627
654
|
setupInstanceLockAndHandlers(instanceLock, logger, configDir);
|
|
655
|
+
// 阻止系统休眠(macOS caffeinate / Windows SetThreadExecutionState):lark-remote
|
|
656
|
+
// 的场景是人不在电脑前远程使用,系统休眠即失联。恒开启、无配置开关;helper 进程
|
|
657
|
+
// 绑定本进程生命周期(caffeinate -w / powershell 轮询父 pid),崩溃也能自清理;
|
|
658
|
+
// 'exit' 钩子里的 stop() 仅覆盖优雅退出路径。
|
|
659
|
+
const sleepBlocker = startSleepBlocker({ platform: currentPlatform, pid: process.pid });
|
|
660
|
+
if (sleepBlocker) {
|
|
661
|
+
process.on('exit', () => sleepBlocker.stop());
|
|
662
|
+
}
|
|
628
663
|
logger.info('config loaded');
|
|
629
664
|
logger.info(`configDir = ${configDir}`);
|
|
630
665
|
logger.info(`feishu.appId = ${config.feishu.appId}`);
|
|
@@ -741,7 +776,7 @@ async function main() {
|
|
|
741
776
|
}
|
|
742
777
|
}
|
|
743
778
|
}
|
|
744
|
-
logger.info('
|
|
779
|
+
logger.info('lark-remote is running, press Ctrl+C to exit');
|
|
745
780
|
}
|
|
746
781
|
main().catch((err) => {
|
|
747
782
|
getLogger().error('fatal error:', err);
|
package/dist/platform/command.js
CHANGED
|
@@ -46,7 +46,7 @@ function isBashQuery(name) {
|
|
|
46
46
|
return lower === 'bash' || lower === 'bash.exe';
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* 缓存 TTL:probe 层(runner/probe.ts)5 分钟,本层更短——
|
|
49
|
+
* 缓存 TTL:probe 层(runner/probe.ts)5 分钟,本层更短——lark-remote 运行期间
|
|
50
50
|
* 用户新装/卸载 agent(典型场景:按 /config 不可用提示去安装)后,最迟
|
|
51
51
|
* TTL 过期即可被重新探测到,不必重启。null(未找到)结果同样过期重查。
|
|
52
52
|
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { spawnProcess } from './spawn.js';
|
|
2
|
+
export interface SleepBlocker {
|
|
3
|
+
/** 防御性停止 helper(正常路径 helper 随 lark-remote 死亡自行退出)。 */
|
|
4
|
+
stop(): void;
|
|
5
|
+
}
|
|
6
|
+
export interface SleepBlockerOptions {
|
|
7
|
+
platform: NodeJS.Platform;
|
|
8
|
+
pid: number;
|
|
9
|
+
/** 测试注入点:默认走 platform/spawn.ts 的 cross-spawn 收口。 */
|
|
10
|
+
spawnFn?: typeof spawnProcess;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* PowerShell 托管脚本:声明 P/Invoke → 设置持续唤醒 → 轮询父 pid 直至父进程消失。
|
|
14
|
+
* 单字符串经 `-Command` 传入(不经 shell,内嵌双引号安全);父 pid 是 number,插值安全。
|
|
15
|
+
*/
|
|
16
|
+
export declare function win32SleepBlockerScript(parentPid: number): string;
|
|
17
|
+
export declare function startSleepBlocker(opts: SleepBlockerOptions): SleepBlocker | null;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawnProcess } from './spawn.js';
|
|
2
|
+
import { getLogger } from '../logger/index.js';
|
|
3
|
+
/** ES_CONTINUOUS (0x80000000) | ES_SYSTEM_REQUIRED (0x00000001)。 */
|
|
4
|
+
const ES_FLAGS = '0x80000001';
|
|
5
|
+
/**
|
|
6
|
+
* PowerShell 托管脚本:声明 P/Invoke → 设置持续唤醒 → 轮询父 pid 直至父进程消失。
|
|
7
|
+
* 单字符串经 `-Command` 传入(不经 shell,内嵌双引号安全);父 pid 是 number,插值安全。
|
|
8
|
+
*/
|
|
9
|
+
export function win32SleepBlockerScript(parentPid) {
|
|
10
|
+
return [
|
|
11
|
+
`Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public static class SleepBlocker { [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags); }'`,
|
|
12
|
+
`[SleepBlocker]::SetThreadExecutionState(${ES_FLAGS}) | Out-Null`,
|
|
13
|
+
`while (Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 30 }`,
|
|
14
|
+
].join('; ');
|
|
15
|
+
}
|
|
16
|
+
export function startSleepBlocker(opts) {
|
|
17
|
+
const { platform, pid } = opts;
|
|
18
|
+
const spawnFn = opts.spawnFn ?? spawnProcess;
|
|
19
|
+
const logger = getLogger();
|
|
20
|
+
let command;
|
|
21
|
+
let args;
|
|
22
|
+
if (platform === 'darwin') {
|
|
23
|
+
command = 'caffeinate';
|
|
24
|
+
args = ['-i', '-w', String(pid)];
|
|
25
|
+
}
|
|
26
|
+
else if (platform === 'win32') {
|
|
27
|
+
command = 'powershell.exe';
|
|
28
|
+
args = ['-NoProfile', '-NonInteractive', '-Command', win32SleepBlockerScript(pid)];
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
let proc;
|
|
34
|
+
try {
|
|
35
|
+
proc = spawnFn(command, args, { stdio: 'ignore' });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
logger.warn(`[sleep-blocker] failed to spawn ${command}:`, err);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// spawn 二进制缺失早检 + error 事件兜底(同 runner 层纪律)
|
|
42
|
+
if (proc.pid === undefined) {
|
|
43
|
+
logger.warn(`[sleep-blocker] ${command} unavailable (spawn failed), sleep prevention off`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
proc.on('error', (err) => {
|
|
47
|
+
logger.warn(`[sleep-blocker] ${command} error:`, err);
|
|
48
|
+
});
|
|
49
|
+
// helper 意外死亡 = 防休眠静默失效(win32 上 Add-Type 动态编译被企业策略/
|
|
50
|
+
// 杀软拦掉、Get-Process 权限失败;darwin 上 caffeinate 早退)。桥此时还活着,
|
|
51
|
+
// 是唯一能留痕的时机——否则日志里只剩启动那句 "active",误以为防休眠在生效。
|
|
52
|
+
let stopping = false;
|
|
53
|
+
proc.on('exit', (code, signal) => {
|
|
54
|
+
if (stopping)
|
|
55
|
+
return;
|
|
56
|
+
logger.warn(`[sleep-blocker] ${command} exited unexpectedly (code=${code}, signal=${signal}), sleep prevention off`);
|
|
57
|
+
});
|
|
58
|
+
// helper 不得阻止 lark-remote 自身退出;父进程死后 helper 走各自的自清理机制退出
|
|
59
|
+
proc.unref();
|
|
60
|
+
logger.info(`[sleep-blocker] active via ${command} pid=${proc.pid}`);
|
|
61
|
+
return {
|
|
62
|
+
stop() {
|
|
63
|
+
stopping = true;
|
|
64
|
+
try {
|
|
65
|
+
proc.kill();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// helper 已死或不可杀——正常路径下它会自行退出,忽略
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=sleep-blocker.js.map
|