@rivus/gateway 0.17.0 → 0.18.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/dist/bootstrap/herdr-feishu.js +24 -797
- package/dist/bootstrap/pi-feishu.js +1161 -279
- package/dist/chunks/herdr-feishu-runtime.js +1993 -0
- package/dist/chunks/rivus-node-entrypoint.js +46 -39
- package/dist/chunks/runtime-cache.js +10 -9
- package/package.json +5 -4
|
@@ -0,0 +1,1993 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { Array as Array$1, Effect, Fiber, Result, Schema, Semaphore } from "effect";
|
|
3
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, unlink } from "node:fs/promises";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
6
|
+
import { writeAtomicTextFile } from "@rivus/platform/persistence";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import { Worker } from "node:worker_threads";
|
|
9
|
+
import { eastAsianWidth } from "get-east-asian-width";
|
|
10
|
+
//#region src/core/application/terminal-session/conversation-mode.ts
|
|
11
|
+
const stateKey = (actorId, chatId) => `${actorId}\u0000${chatId}`;
|
|
12
|
+
/**
|
|
13
|
+
* Conversation mode state machine (normal/selection/locked per authorized
|
|
14
|
+
* actor+chat) with persist-before-publication and a global frozen flag: any
|
|
15
|
+
* ambiguous snapshot save freezes all mode-dependent activity until a validated
|
|
16
|
+
* restart, and an unrelated conversation's state is never overwritten afterwards.
|
|
17
|
+
* Generations advance monotonically per conversation for the whole process and
|
|
18
|
+
* are never recycled; the startup epoch invalidates every card issued by an
|
|
19
|
+
* earlier run while persisted locks stay (revalidated before writes).
|
|
20
|
+
*/
|
|
21
|
+
function createConversationMode(options) {
|
|
22
|
+
let states = new Map(options.states.initial.map((state) => [stateKey(state.actorId, state.chatId), state]));
|
|
23
|
+
const generations = /* @__PURE__ */ new Map();
|
|
24
|
+
let frozen = false;
|
|
25
|
+
const serial = Semaphore.makeUnsafe(1);
|
|
26
|
+
const generationOf = (actorId, chatId) => generations.get(stateKey(actorId, chatId)) ?? 0;
|
|
27
|
+
const advanceGeneration = (actorId, chatId) => {
|
|
28
|
+
const next = generationOf(actorId, chatId) + 1;
|
|
29
|
+
generations.set(stateKey(actorId, chatId), next);
|
|
30
|
+
return next;
|
|
31
|
+
};
|
|
32
|
+
const persistAndPublish = (candidate) => Effect.gen(function* () {
|
|
33
|
+
yield* options.states.save([...candidate.values()]);
|
|
34
|
+
states = candidate;
|
|
35
|
+
return true;
|
|
36
|
+
}).pipe(Effect.catchCause(() => Effect.sync(() => {
|
|
37
|
+
frozen = true;
|
|
38
|
+
return false;
|
|
39
|
+
})));
|
|
40
|
+
const controlRegion = (decide) => serial.withPermits(1)(Effect.uninterruptible(Effect.gen(function* () {
|
|
41
|
+
if (frozen) return {
|
|
42
|
+
frozen: true,
|
|
43
|
+
value: void 0
|
|
44
|
+
};
|
|
45
|
+
const candidate = new Map(states);
|
|
46
|
+
const decision = yield* decide(candidate);
|
|
47
|
+
if (!decision.save) return {
|
|
48
|
+
frozen: false,
|
|
49
|
+
value: decision.value
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
frozen: !(yield* persistAndPublish(candidate)),
|
|
53
|
+
value: decision.value
|
|
54
|
+
};
|
|
55
|
+
})));
|
|
56
|
+
return {
|
|
57
|
+
epoch: options.epoch,
|
|
58
|
+
stateOf: (actorId, chatId) => states.get(stateKey(actorId, chatId)),
|
|
59
|
+
generationOf,
|
|
60
|
+
isFrozen: () => frozen,
|
|
61
|
+
freeze: () => {
|
|
62
|
+
frozen = true;
|
|
63
|
+
},
|
|
64
|
+
capturedTarget: (actorId, chatId) => {
|
|
65
|
+
const state = states.get(stateKey(actorId, chatId));
|
|
66
|
+
return state?.kind === "locked" ? state.agent : void 0;
|
|
67
|
+
},
|
|
68
|
+
enterSelection: (actorId, chatId) => controlRegion((candidate) => {
|
|
69
|
+
const key = stateKey(actorId, chatId);
|
|
70
|
+
if (!candidate.has(key) && candidate.size >= 64) return Effect.succeed({
|
|
71
|
+
save: false,
|
|
72
|
+
value: "capacity"
|
|
73
|
+
});
|
|
74
|
+
advanceGeneration(actorId, chatId);
|
|
75
|
+
if (candidate.get(key) === void 0) candidate.set(key, {
|
|
76
|
+
kind: "selection",
|
|
77
|
+
actorId,
|
|
78
|
+
chatId,
|
|
79
|
+
since: options.now()
|
|
80
|
+
});
|
|
81
|
+
return Effect.succeed({
|
|
82
|
+
save: true,
|
|
83
|
+
value: "committed"
|
|
84
|
+
});
|
|
85
|
+
}).pipe(Effect.map(({ frozen: wasFrozen, value }) => wasFrozen ? "frozen" : value)),
|
|
86
|
+
refresh: (actorId, chatId) => controlRegion((_candidate) => {
|
|
87
|
+
advanceGeneration(actorId, chatId);
|
|
88
|
+
return Effect.succeed({
|
|
89
|
+
save: false,
|
|
90
|
+
value: "committed"
|
|
91
|
+
});
|
|
92
|
+
}).pipe(Effect.map(({ frozen: wasFrozen, value }) => wasFrozen ? "frozen" : value)),
|
|
93
|
+
exit: (actorId, chatId) => controlRegion((candidate) => {
|
|
94
|
+
advanceGeneration(actorId, chatId);
|
|
95
|
+
candidate.delete(stateKey(actorId, chatId));
|
|
96
|
+
return Effect.succeed({
|
|
97
|
+
save: true,
|
|
98
|
+
value: "committed"
|
|
99
|
+
});
|
|
100
|
+
}).pipe(Effect.map(({ frozen: wasFrozen, value }) => wasFrozen ? "frozen" : value)),
|
|
101
|
+
reserveSelection: (credential) => controlRegion((candidate) => {
|
|
102
|
+
if (credential.epoch !== options.epoch) return Effect.succeed({
|
|
103
|
+
save: false,
|
|
104
|
+
value: { kind: "stale" }
|
|
105
|
+
});
|
|
106
|
+
const key = stateKey(credential.actorId, credential.chatId);
|
|
107
|
+
if (!candidate.has(key) && candidate.size >= 64) return Effect.succeed({
|
|
108
|
+
save: false,
|
|
109
|
+
value: { kind: "capacity" }
|
|
110
|
+
});
|
|
111
|
+
if (credential.generation !== generationOf(credential.actorId, credential.chatId)) return Effect.succeed({
|
|
112
|
+
save: false,
|
|
113
|
+
value: { kind: "stale" }
|
|
114
|
+
});
|
|
115
|
+
const ticketGeneration = advanceGeneration(credential.actorId, credential.chatId);
|
|
116
|
+
return Effect.succeed({
|
|
117
|
+
save: false,
|
|
118
|
+
value: {
|
|
119
|
+
credential,
|
|
120
|
+
actorId: credential.actorId,
|
|
121
|
+
chatId: credential.chatId,
|
|
122
|
+
handle: credential.handle,
|
|
123
|
+
ticketGeneration
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}).pipe(Effect.map(({ frozen: wasFrozen, value }) => wasFrozen ? { kind: "frozen" } : value)),
|
|
127
|
+
inspectSelection: (ticket) => Effect.gen(function* () {
|
|
128
|
+
const binding = yield* options.bindings.lookup(ticket.handle, {
|
|
129
|
+
actorId: ticket.actorId,
|
|
130
|
+
chatId: ticket.chatId
|
|
131
|
+
});
|
|
132
|
+
if (binding.kind !== "found") return yield* Effect.fail(/* @__PURE__ */ new Error("Selection binding is no longer available"));
|
|
133
|
+
return yield* options.sessions.inspect(binding.binding.agent);
|
|
134
|
+
}),
|
|
135
|
+
commitSelection: (ticket, agent) => controlRegion((candidate) => {
|
|
136
|
+
const key = stateKey(ticket.actorId, ticket.chatId);
|
|
137
|
+
if (generationOf(ticket.actorId, ticket.chatId) !== ticket.ticketGeneration) return Effect.succeed({
|
|
138
|
+
save: false,
|
|
139
|
+
value: { kind: "stale" }
|
|
140
|
+
});
|
|
141
|
+
if (agent.kind !== "agent") return Effect.succeed({
|
|
142
|
+
save: false,
|
|
143
|
+
value: {
|
|
144
|
+
kind: "not-selectable",
|
|
145
|
+
reason: "终端面板为只读,不能作为会话目标。"
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
if (!candidate.has(key) && candidate.size >= 64) return Effect.succeed({
|
|
149
|
+
save: false,
|
|
150
|
+
value: { kind: "capacity" }
|
|
151
|
+
});
|
|
152
|
+
candidate.set(key, {
|
|
153
|
+
kind: "locked",
|
|
154
|
+
actorId: ticket.actorId,
|
|
155
|
+
chatId: ticket.chatId,
|
|
156
|
+
since: options.now(),
|
|
157
|
+
handle: ticket.handle,
|
|
158
|
+
agent,
|
|
159
|
+
stale: false
|
|
160
|
+
});
|
|
161
|
+
advanceGeneration(ticket.actorId, ticket.chatId);
|
|
162
|
+
return Effect.succeed({
|
|
163
|
+
save: true,
|
|
164
|
+
value: "committed"
|
|
165
|
+
});
|
|
166
|
+
}).pipe(Effect.flatMap(({ frozen: wasFrozen, value }) => wasFrozen ? Effect.succeed({ kind: "frozen" }) : Effect.succeed(value))),
|
|
167
|
+
markLockedStale: (actorId, chatId, expected) => controlRegion((candidate) => {
|
|
168
|
+
const state = candidate.get(stateKey(actorId, chatId));
|
|
169
|
+
if (state?.kind !== "locked") return Effect.succeed({
|
|
170
|
+
save: false,
|
|
171
|
+
value: void 0
|
|
172
|
+
});
|
|
173
|
+
if (!(expected === void 0 || state.agent.identity === expected.identity && state.agent.pane === expected.pane && state.agent.session === expected.session)) return Effect.succeed({
|
|
174
|
+
save: false,
|
|
175
|
+
value: void 0
|
|
176
|
+
});
|
|
177
|
+
candidate.set(stateKey(actorId, chatId), {
|
|
178
|
+
...state,
|
|
179
|
+
stale: true
|
|
180
|
+
});
|
|
181
|
+
return Effect.succeed({
|
|
182
|
+
save: true,
|
|
183
|
+
value: void 0
|
|
184
|
+
});
|
|
185
|
+
}).pipe(Effect.asVoid)
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/core/application/terminal-session/terminal-session-bindings.ts
|
|
190
|
+
var TerminalBindingPersistenceError = class extends Error {
|
|
191
|
+
constructor(cause) {
|
|
192
|
+
super("Terminal binding snapshot could not be saved", { cause });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
function createTerminalSessionBindings(options) {
|
|
196
|
+
let bindings = new Map((options.store?.initial ?? []).map((entry) => [entry.handle, entry]));
|
|
197
|
+
const serial = Semaphore.makeUnsafe(1);
|
|
198
|
+
const commit = (candidate) => Effect.gen(function* () {
|
|
199
|
+
if (options.store) yield* options.store.save([...candidate.values()]).pipe(Effect.mapError((cause) => new TerminalBindingPersistenceError(cause)));
|
|
200
|
+
bindings = candidate;
|
|
201
|
+
});
|
|
202
|
+
const insert = (candidate, entry) => {
|
|
203
|
+
candidate.delete(entry.handle);
|
|
204
|
+
candidate.set(entry.handle, entry);
|
|
205
|
+
if (candidate.size > 1e3) candidate.delete(candidate.keys().next().value);
|
|
206
|
+
return entry;
|
|
207
|
+
};
|
|
208
|
+
const fresh = (candidate, message, agent, expires) => {
|
|
209
|
+
let handle = options.newHandle();
|
|
210
|
+
while (candidate.has(handle)) handle = options.newHandle();
|
|
211
|
+
return insert(candidate, {
|
|
212
|
+
handle,
|
|
213
|
+
actorId: message.actorId,
|
|
214
|
+
chatId: message.chatId,
|
|
215
|
+
agent,
|
|
216
|
+
...expires === void 0 ? {} : { expires }
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
lookup: (handle, message) => serial.withPermits(1)(Effect.uninterruptible(Effect.gen(function* () {
|
|
221
|
+
const binding = bindings.get(handle);
|
|
222
|
+
if (!binding) return { kind: "missing" };
|
|
223
|
+
if (binding.actorId !== message.actorId || binding.chatId !== message.chatId) return { kind: "unauthorized" };
|
|
224
|
+
const candidate = new Map(bindings);
|
|
225
|
+
insert(candidate, binding);
|
|
226
|
+
yield* commit(candidate);
|
|
227
|
+
return {
|
|
228
|
+
kind: "found",
|
|
229
|
+
binding
|
|
230
|
+
};
|
|
231
|
+
}))),
|
|
232
|
+
selections: (message, agents) => serial.withPermits(1)(Effect.uninterruptible(Effect.gen(function* () {
|
|
233
|
+
const candidate = new Map(bindings);
|
|
234
|
+
const selected = agents.map((agent) => {
|
|
235
|
+
const existing = [...candidate.values()].find((entry) => entry.expires === void 0 && entry.actorId === message.actorId && entry.chatId === message.chatId && entry.agent.session === agent.session && entry.agent.pane === agent.pane && entry.agent.identity === agent.identity);
|
|
236
|
+
return existing ? insert(candidate, {
|
|
237
|
+
...existing,
|
|
238
|
+
agent
|
|
239
|
+
}) : fresh(candidate, message, agent);
|
|
240
|
+
});
|
|
241
|
+
if (agents.length) yield* commit(candidate);
|
|
242
|
+
return selected;
|
|
243
|
+
}))),
|
|
244
|
+
form: (message, agent, expires) => serial.withPermits(1)(Effect.uninterruptible(Effect.gen(function* () {
|
|
245
|
+
const candidate = new Map(bindings);
|
|
246
|
+
const binding = fresh(candidate, message, agent, expires);
|
|
247
|
+
yield* commit(candidate);
|
|
248
|
+
return binding;
|
|
249
|
+
})))
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/core/application/terminal-session/terminal-session-bridge.ts
|
|
254
|
+
var TerminalTargetChangedError = class extends Error {
|
|
255
|
+
constructor() {
|
|
256
|
+
super("Herdr target changed; select the target again");
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
/** A final target check refused before the prompt RPC was invoked. */
|
|
260
|
+
var TerminalPromptPreflightError = class extends Error {
|
|
261
|
+
constructor(text) {
|
|
262
|
+
super(text);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const terminalWriteRefusalText = {
|
|
266
|
+
"read-only": "Terminal input is disabled for this deployment.",
|
|
267
|
+
terminal: "目标为只读终端面板,不能接收指令。",
|
|
268
|
+
"not-ready": "目标尚未完成启动,暂不接收指令;请刷新状态。",
|
|
269
|
+
busy: "目标正在执行任务,暂不接收新指令。",
|
|
270
|
+
blocked: "目标正在等待处理(如审批或提问),不会代替你处理;未提交指令。",
|
|
271
|
+
"unknown-status": "目标状态未知,请刷新确认后再发送。"
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Shared gate for every Herdr write path (locked text, /herdr send, form submit).
|
|
275
|
+
* Herdr reports the same input-awaiting agent as `done` before it is focused and
|
|
276
|
+
* `idle` afterwards, so both statuses admit a ready agent; everything else refuses
|
|
277
|
+
* with a specific reason and no PTY input. Availability and status are evaluated
|
|
278
|
+
* before the deployment read-only flag so an offline, replaced or busy target keeps
|
|
279
|
+
* reporting its own reason instead of a generic disabled notice.
|
|
280
|
+
*/
|
|
281
|
+
function evaluateTerminalWriteInput(agent, writable) {
|
|
282
|
+
const refused = (refusal) => ({
|
|
283
|
+
allowed: false,
|
|
284
|
+
refusal,
|
|
285
|
+
text: terminalWriteRefusalText[refusal]
|
|
286
|
+
});
|
|
287
|
+
if (agent.kind !== "agent") return refused("terminal");
|
|
288
|
+
if (!agent.ready) return refused("not-ready");
|
|
289
|
+
if (agent.status === "working") return refused("busy");
|
|
290
|
+
if (agent.status === "blocked") return refused("blocked");
|
|
291
|
+
if (agent.status !== "idle" && agent.status !== "done") return refused("unknown-status");
|
|
292
|
+
if (!writable) return refused("read-only");
|
|
293
|
+
return { allowed: true };
|
|
294
|
+
}
|
|
295
|
+
function createTerminalSessionBridge(options) {
|
|
296
|
+
if (!options.actors.length || !options.chats.length) throw new Error("Explicit actor and chat allowlists required");
|
|
297
|
+
const bindings = createTerminalSessionBindings({
|
|
298
|
+
newHandle: options.newHandle,
|
|
299
|
+
store: options.bindings
|
|
300
|
+
});
|
|
301
|
+
const mode = options.conversation ? createConversationMode({
|
|
302
|
+
epoch: options.conversation.epoch,
|
|
303
|
+
states: options.conversation.states,
|
|
304
|
+
bindings,
|
|
305
|
+
sessions: options.sessions,
|
|
306
|
+
now: options.now
|
|
307
|
+
}) : void 0;
|
|
308
|
+
const watches = /* @__PURE__ */ new Map();
|
|
309
|
+
const watchExitRevisions = /* @__PURE__ */ new Map();
|
|
310
|
+
const watchScopeKey = (message) => JSON.stringify([message.actorId, message.chatId]);
|
|
311
|
+
const watchExitRevisionOf = (message) => watchExitRevisions.get(watchScopeKey(message)) ?? 0;
|
|
312
|
+
const allowed = (actor, chat) => options.actors.includes(actor) && options.chats.includes(chat);
|
|
313
|
+
const reply = (message, text) => ({
|
|
314
|
+
chatId: message.chatId,
|
|
315
|
+
text
|
|
316
|
+
});
|
|
317
|
+
const watchKey = (message, agent) => JSON.stringify([
|
|
318
|
+
message.actorId,
|
|
319
|
+
message.chatId,
|
|
320
|
+
agent.session,
|
|
321
|
+
agent.pane,
|
|
322
|
+
agent.identity
|
|
323
|
+
]);
|
|
324
|
+
const isFresh = (message) => {
|
|
325
|
+
const age = options.now() - message.sentAt;
|
|
326
|
+
return Number.isFinite(age) && age >= -3e4 && age <= 12e4;
|
|
327
|
+
};
|
|
328
|
+
const frozenReply = (message) => reply(message, "会话状态暂不可用(保存未确认);本条未处理,也未转发。");
|
|
329
|
+
const capacityReply = (message) => reply(message, "会话状态名额已满(64),请先 /exit 其他会话后再进入。");
|
|
330
|
+
const runCapturedSend = (message, agent, text) => Effect.gen(function* () {
|
|
331
|
+
const targetChanged = () => withAdmission(mode.markLockedStale(message.actorId, message.chatId, {
|
|
332
|
+
identity: agent.identity,
|
|
333
|
+
pane: agent.pane,
|
|
334
|
+
session: agent.session
|
|
335
|
+
})).pipe(Effect.as({
|
|
336
|
+
kind: "reply",
|
|
337
|
+
reply: reply(message, "原会话的目标已变化;未向替换目标提交。锁定保留,请重新 /herdr 选择。")
|
|
338
|
+
}));
|
|
339
|
+
if (mode?.isFrozen()) return {
|
|
340
|
+
kind: "reply",
|
|
341
|
+
reply: frozenReply(message)
|
|
342
|
+
};
|
|
343
|
+
const inspected = yield* options.sessions.inspect(agent).pipe(Effect.result);
|
|
344
|
+
if (Result.isFailure(inspected)) {
|
|
345
|
+
if (inspected.failure instanceof TerminalTargetChangedError) return yield* targetChanged();
|
|
346
|
+
return {
|
|
347
|
+
kind: "reply",
|
|
348
|
+
reply: reply(message, "目标暂不可用;未提交指令。可 /exit 后重新选择。")
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const verdict = evaluateTerminalWriteInput(inspected.success, options.writable);
|
|
352
|
+
if (!verdict.allowed) return {
|
|
353
|
+
kind: "reply",
|
|
354
|
+
reply: reply(message, `Input rejected: ${verdict.text}`)
|
|
355
|
+
};
|
|
356
|
+
if (mode?.isFrozen()) return {
|
|
357
|
+
kind: "reply",
|
|
358
|
+
reply: frozenReply(message)
|
|
359
|
+
};
|
|
360
|
+
return yield* options.sessions.prompt(agent, text).pipe(Effect.map(() => ({
|
|
361
|
+
kind: "reply",
|
|
362
|
+
reply: reply(message, "指令已提交一次;尚未确认 agent 已处理或完成,请查看输出。")
|
|
363
|
+
})), Effect.catch((error) => error instanceof TerminalTargetChangedError ? targetChanged() : Effect.succeed({
|
|
364
|
+
kind: "reply",
|
|
365
|
+
reply: reply(message, error instanceof TerminalPromptPreflightError ? error.message : "提交结果不确定(uncertain),不会自动重试。请先查看输出,确认后再发送新指令。")
|
|
366
|
+
})));
|
|
367
|
+
});
|
|
368
|
+
const detail = (message, agent, handle, text) => ({
|
|
369
|
+
chatId: message.chatId,
|
|
370
|
+
text: `${agent.session} / ${agent.name} | ${agent.status}\n${text}`,
|
|
371
|
+
menu: {
|
|
372
|
+
kind: "detail",
|
|
373
|
+
agents: [{
|
|
374
|
+
...agent,
|
|
375
|
+
handle
|
|
376
|
+
}],
|
|
377
|
+
writable: options.writable,
|
|
378
|
+
notice: text
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
const refreshDetail = (message, agent, text) => Effect.gen(function* () {
|
|
382
|
+
const current = yield* options.sessions.inspect(agent);
|
|
383
|
+
const [selected] = yield* bindings.selections(message, [current]);
|
|
384
|
+
return detail(message, current, selected.handle, text);
|
|
385
|
+
});
|
|
386
|
+
const inventory = (message, command, argument = "", notice, issued) => Effect.gen(function* () {
|
|
387
|
+
const { agents, unavailableSessions } = yield* options.sessions.list();
|
|
388
|
+
const page = Math.max(0, Math.min(Math.floor(Number(argument) || 0), Math.max(0, Math.ceil(agents.length / 20) - 1)));
|
|
389
|
+
const visible = command === "menu" ? agents.slice(page * 20, (page + 1) * 20) : agents;
|
|
390
|
+
const choices = (yield* bindings.selections(message, visible)).map(({ agent, handle }) => ({
|
|
391
|
+
...agent,
|
|
392
|
+
handle,
|
|
393
|
+
...issued === void 0 ? {} : { selection: {
|
|
394
|
+
epoch: issued.epoch,
|
|
395
|
+
generation: issued.generation,
|
|
396
|
+
actorId: issued.actorId,
|
|
397
|
+
chatId: issued.chatId,
|
|
398
|
+
handle
|
|
399
|
+
} }
|
|
400
|
+
}));
|
|
401
|
+
const warning = unavailableSessions.length ? `\nUnavailable sessions: ${unavailableSessions.join(", ")}; inventory is incomplete.` : "";
|
|
402
|
+
const lines = choices.map((agent) => `${agent.handle} | ${agent.session} | ${agent.name} | ${agent.status}`);
|
|
403
|
+
return {
|
|
404
|
+
...reply(message, `${notice ? notice + "\n" : ""}${new Date(options.now()).toISOString()}\n${lines.join("\n") || "No panels"}${warning}`),
|
|
405
|
+
...command === "menu" ? { menu: {
|
|
406
|
+
kind: "list",
|
|
407
|
+
agents: choices,
|
|
408
|
+
unavailableSessions,
|
|
409
|
+
page,
|
|
410
|
+
pages: Math.max(1, Math.ceil(agents.length / 20)),
|
|
411
|
+
writable: options.writable,
|
|
412
|
+
...notice ? { notice } : {}
|
|
413
|
+
} } : {}
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
const persistenceFailure = (message) => reply(message, "会话控制状态无法保存;未签发新卡片,也未提交指令。请稍后重新操作。");
|
|
417
|
+
const unavailableInventory = (message) => ({
|
|
418
|
+
...reply(message, "会话列表暂不可用,请稍后刷新列表;未提交指令。"),
|
|
419
|
+
menu: {
|
|
420
|
+
kind: "recovery",
|
|
421
|
+
writable: false,
|
|
422
|
+
agents: []
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
const recovery = (message, binding, error) => {
|
|
426
|
+
if (error instanceof TerminalTargetChangedError) return inventory(message, "menu", "", "原会话的目标已变化(target changed);未向替换目标提交指令。请从新列表明确选择会话。");
|
|
427
|
+
if (!binding) return Effect.succeed(unavailableInventory(message));
|
|
428
|
+
return bindings.selections(message, [binding.agent]).pipe(Effect.map(([selected]) => ({
|
|
429
|
+
...reply(message, "原会话离线或暂不可用;未提交指令。可刷新原会话重试,刷新不会重发旧指令。"),
|
|
430
|
+
menu: {
|
|
431
|
+
kind: "recovery",
|
|
432
|
+
writable: false,
|
|
433
|
+
agents: [{
|
|
434
|
+
...binding.agent,
|
|
435
|
+
status: "unavailable",
|
|
436
|
+
ready: false,
|
|
437
|
+
handle: selected.handle
|
|
438
|
+
}]
|
|
439
|
+
}
|
|
440
|
+
})));
|
|
441
|
+
};
|
|
442
|
+
const recover = (message, binding, error) => error instanceof TerminalBindingPersistenceError ? Effect.succeed(persistenceFailure(message)) : recovery(message, binding, error).pipe(Effect.catch((cause) => Effect.succeed(cause instanceof TerminalBindingPersistenceError ? persistenceFailure(message) : unavailableInventory(message))));
|
|
443
|
+
const form = (message, agent, draft) => Effect.gen(function* () {
|
|
444
|
+
const binding = yield* bindings.form(message, agent, options.now() + 6e5);
|
|
445
|
+
const notice = draft === void 0 ? void 0 : "此表单已过期;草稿已保留,请确认后再次点击发送。旧指令未提交。";
|
|
446
|
+
return {
|
|
447
|
+
...reply(message, notice ?? `${agent.name} | ${agent.status}`),
|
|
448
|
+
menu: {
|
|
449
|
+
kind: "form",
|
|
450
|
+
agents: [{
|
|
451
|
+
...agent,
|
|
452
|
+
handle: binding.handle
|
|
453
|
+
}],
|
|
454
|
+
writable: options.writable,
|
|
455
|
+
...draft === void 0 ? {} : {
|
|
456
|
+
draft,
|
|
457
|
+
notice
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
const writable = (agent) => evaluateTerminalWriteInput(agent, options.writable);
|
|
463
|
+
const execute = (message, admittedWatchRevision) => {
|
|
464
|
+
let binding;
|
|
465
|
+
let isWatch = false;
|
|
466
|
+
const isCurrent = () => !isWatch || watchExitRevisionOf(message) === admittedWatchRevision;
|
|
467
|
+
return Effect.gen(function* () {
|
|
468
|
+
const match = /^\/herdr(?:\s+(\S+))?(?:\s+([\s\S]*))?$/.exec(message.text.trimStart());
|
|
469
|
+
if (!match) return null;
|
|
470
|
+
const command = match[1] ?? "help";
|
|
471
|
+
const argument = match[2] ?? "";
|
|
472
|
+
isWatch = command === "watch";
|
|
473
|
+
if (!isCurrent()) return null;
|
|
474
|
+
if (command === "list" || command === "menu") return yield* inventory(message, command, argument);
|
|
475
|
+
if (command === "help") return reply(message, "从应用菜单打开会话列表,选择 agent 后填写指令并点击发送。卡片可直接查看输出、画面和关注状态。\n文字命令:/herdr menu、/herdr list、/herdr read HANDLE、/herdr screen HANDLE、/herdr send HANDLE -- TEXT、/herdr watch HANDLE、/herdr unwatch HANDLE");
|
|
476
|
+
const parts = ["send", "submit"].includes(command) ? /^(\S+)\s+--\s([\s\S]*)$/.exec(argument) : null;
|
|
477
|
+
const handle = parts?.[1] ?? argument.trim();
|
|
478
|
+
const found = yield* bindings.lookup(handle, message);
|
|
479
|
+
if (!isCurrent()) return null;
|
|
480
|
+
if (found.kind === "missing") return yield* inventory(message, "menu", "", "此卡片的目标记录不存在或已淘汰,请从新列表重新选择;旧指令未提交。");
|
|
481
|
+
if (found.kind === "unauthorized") return {
|
|
482
|
+
...reply(message, "此卡片不可用于当前操作者或聊天,请刷新列表重新选择。"),
|
|
483
|
+
menu: {
|
|
484
|
+
kind: "recovery",
|
|
485
|
+
writable: false,
|
|
486
|
+
agents: []
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
binding = found.binding;
|
|
490
|
+
if (command === "unwatch") {
|
|
491
|
+
const removed = watches.delete(watchKey(message, binding.agent));
|
|
492
|
+
const [selected] = yield* bindings.selections(message, [binding.agent]);
|
|
493
|
+
return detail(message, binding.agent, selected.handle, removed ? "已取消关注;agent 仍在运行。" : "当前没有关注此会话。");
|
|
494
|
+
}
|
|
495
|
+
if (command === "open" || command === "compose" || command === "refresh") {
|
|
496
|
+
const current = yield* options.sessions.inspect(binding.agent);
|
|
497
|
+
const verdict = writable(current);
|
|
498
|
+
if (verdict.allowed) return yield* form(message, current);
|
|
499
|
+
const [selected] = yield* bindings.selections(message, [current]);
|
|
500
|
+
return detail(message, current, selected.handle, `${verdict.text}请刷新状态后再试。`);
|
|
501
|
+
}
|
|
502
|
+
if (command === "read" || command === "screen") {
|
|
503
|
+
const read = yield* options.sessions.read(binding.agent, command === "screen");
|
|
504
|
+
const title = `${handle} | ${binding.agent.name}`;
|
|
505
|
+
const at = options.now();
|
|
506
|
+
return command === "screen" ? {
|
|
507
|
+
...reply(message, `${title} — terminal text rendering`),
|
|
508
|
+
screen: {
|
|
509
|
+
title,
|
|
510
|
+
at,
|
|
511
|
+
...read
|
|
512
|
+
}
|
|
513
|
+
} : reply(message, `${title} | ${new Date(at).toISOString()}${read.truncated || read.text.length > 12e3 ? " | truncated(已截断)" : ""}\n${read.text.slice(-12e3)}`);
|
|
514
|
+
}
|
|
515
|
+
if (command === "send" || command === "submit") {
|
|
516
|
+
if (command === "submit") {
|
|
517
|
+
if (binding.expires === void 0 || !(yield* options.claim({
|
|
518
|
+
...message,
|
|
519
|
+
id: `form:${handle}`
|
|
520
|
+
}))) return yield* refreshDetail(message, binding.agent, "此表单已提交或失效,请查看输出;新指令请重新打开输入框。");
|
|
521
|
+
} else if (binding.expires !== void 0) return yield* refreshDetail(message, binding.agent, "表单只能通过发送按钮提交一次;新指令请重新打开输入框。");
|
|
522
|
+
const text = parts?.[2];
|
|
523
|
+
if (!text?.trim() || text.length > (command === "submit" ? 1e3 : 8e3) || Array.from(text).some((char) => {
|
|
524
|
+
const code = char.charCodeAt(0);
|
|
525
|
+
return code < 32 && code !== 9 && code !== 10 || code === 127;
|
|
526
|
+
})) {
|
|
527
|
+
if (command === "submit") return yield* refreshDetail(message, binding.agent, "指令需为 1–1000 个字符,且不能包含终端控制字符;请点击“发下一条指令”重新填写。");
|
|
528
|
+
return reply(message, "Use /herdr send HANDLE -- TEXT (1–8000 characters, no terminal control characters).");
|
|
529
|
+
}
|
|
530
|
+
const current = yield* options.sessions.inspect(binding.agent);
|
|
531
|
+
const [selected] = yield* bindings.selections(message, [current]);
|
|
532
|
+
const verdict = writable(current);
|
|
533
|
+
if (!verdict.allowed) return detail(message, current, selected.handle, `Input rejected: ${verdict.text}`);
|
|
534
|
+
if (command === "submit" && binding.expires <= options.now()) return yield* form(message, current, text);
|
|
535
|
+
if (mode?.isFrozen()) return detail(message, current, selected.handle, frozenReply(message).text);
|
|
536
|
+
return yield* options.sessions.prompt(binding.agent, text).pipe(Effect.as(detail(message, current, selected.handle, "指令已提交一次;尚未确认 agent 已处理或完成,请查看输出。")), Effect.catch((error) => error instanceof TerminalTargetChangedError ? Effect.fail(error) : Effect.succeed(detail(message, current, selected.handle, error instanceof TerminalPromptPreflightError ? error.message : "提交结果不确定(uncertain),不会自动重试。请先查看输出,确认后再发送新指令。"))));
|
|
537
|
+
}
|
|
538
|
+
if (command === "watch") {
|
|
539
|
+
for (const [key, watch] of watches) if (watch.expires <= options.now()) watches.delete(key);
|
|
540
|
+
const key = watchKey(message, binding.agent);
|
|
541
|
+
if (!watches.has(key) && watches.size >= 8) return detail(message, binding.agent, handle, "已达到 8 个关注目标,请先取消其他关注。");
|
|
542
|
+
const current = yield* options.sessions.inspect(binding.agent);
|
|
543
|
+
if (!isCurrent()) return null;
|
|
544
|
+
const [selected] = yield* bindings.selections(message, [current]);
|
|
545
|
+
if (!isCurrent()) return null;
|
|
546
|
+
watches.set(key, {
|
|
547
|
+
chatId: message.chatId,
|
|
548
|
+
actorId: message.actorId,
|
|
549
|
+
binding: selected,
|
|
550
|
+
expires: options.now() + 6e5,
|
|
551
|
+
status: current.status
|
|
552
|
+
});
|
|
553
|
+
return detail(message, current, selected.handle, "已关注此会话 10 分钟;重复关注会续期,不增加名额。");
|
|
554
|
+
}
|
|
555
|
+
return reply(message, "Unknown command; use /herdr help.");
|
|
556
|
+
}).pipe(Effect.catch((error) => isCurrent() ? recover(message, binding, error) : Effect.succeed(null)), Effect.map((result) => isCurrent() ? result : null));
|
|
557
|
+
};
|
|
558
|
+
const admission = Semaphore.makeUnsafe(1);
|
|
559
|
+
const withAdmission = (work) => admission.withPermits(1)(work);
|
|
560
|
+
const inventoryWork = (message, run) => ({
|
|
561
|
+
kind: "inventory",
|
|
562
|
+
message,
|
|
563
|
+
run
|
|
564
|
+
});
|
|
565
|
+
const capturedSendWork = (message, run) => ({
|
|
566
|
+
kind: "captured-send",
|
|
567
|
+
message,
|
|
568
|
+
run
|
|
569
|
+
});
|
|
570
|
+
const commandWork = (message, run) => ({
|
|
571
|
+
kind: "command",
|
|
572
|
+
message,
|
|
573
|
+
run
|
|
574
|
+
});
|
|
575
|
+
/** Captures epoch/generation/actor/chat synchronously and returns the inventory work. */
|
|
576
|
+
const inventoryWorkWithCurrentSelection = (message, command, argument = "", notice) => inventoryWork(message, inventory(message, command, argument, notice, {
|
|
577
|
+
epoch: mode.epoch,
|
|
578
|
+
generation: mode.generationOf(message.actorId, message.chatId),
|
|
579
|
+
actorId: message.actorId,
|
|
580
|
+
chatId: message.chatId
|
|
581
|
+
}));
|
|
582
|
+
const inspectSelectionWork = (message, ticket) => ({
|
|
583
|
+
kind: "inspect-selection",
|
|
584
|
+
message,
|
|
585
|
+
ticket,
|
|
586
|
+
run: Effect.gen(function* () {
|
|
587
|
+
const inspected = yield* mode.inspectSelection(ticket).pipe(Effect.result);
|
|
588
|
+
if (Result.isFailure(inspected)) return {
|
|
589
|
+
kind: "reply",
|
|
590
|
+
reply: reply(message, "目标暂不可用或已替换,无法确认身份;未绑定。请刷新列表后重试。")
|
|
591
|
+
};
|
|
592
|
+
const committed = yield* withAdmission(mode.commitSelection(ticket, inspected.success));
|
|
593
|
+
if (committed !== "committed") {
|
|
594
|
+
const notice = committed.kind === "stale" ? "状态已变化,本次选择未生效。" : committed.kind === "capacity" ? "会话状态名额已满(64)。" : "会话状态暂不可用(保存未确认)。";
|
|
595
|
+
return {
|
|
596
|
+
kind: "reply",
|
|
597
|
+
reply: reply(message, notice)
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
kind: "reply",
|
|
602
|
+
reply: detail(message, inspected.success, ticket.handle, "已锁定该会话;之后普通消息将直接发送给它(/exit 退出)。")
|
|
603
|
+
};
|
|
604
|
+
})
|
|
605
|
+
});
|
|
606
|
+
/** Internal Route decided in one admission turn, before any claim or commit. */
|
|
607
|
+
const captureInAdmission = (message) => {
|
|
608
|
+
const text = message.text.trim();
|
|
609
|
+
const isExit = /^\/exit\s*$/.test(text);
|
|
610
|
+
const isHerdrToken = /^\/herdr(?:\s|$)/.test(text);
|
|
611
|
+
if (!allowed(message.actorId, message.chatId)) return {
|
|
612
|
+
message,
|
|
613
|
+
route: { kind: isHerdrToken || isExit ? "denial" : "pass-through" }
|
|
614
|
+
};
|
|
615
|
+
if (mode?.isFrozen()) return {
|
|
616
|
+
message,
|
|
617
|
+
route: { kind: "frozen-refusal" }
|
|
618
|
+
};
|
|
619
|
+
const stateAtAdmission = mode?.stateOf(message.actorId, message.chatId);
|
|
620
|
+
if (isExit) {
|
|
621
|
+
if (!mode || !stateAtAdmission) return {
|
|
622
|
+
message,
|
|
623
|
+
route: { kind: "pass-through" }
|
|
624
|
+
};
|
|
625
|
+
if (!isFresh(message) || !text) return {
|
|
626
|
+
message,
|
|
627
|
+
route: { kind: "consumed" }
|
|
628
|
+
};
|
|
629
|
+
return {
|
|
630
|
+
message,
|
|
631
|
+
route: { kind: "exit" }
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (mode && stateAtAdmission && !isHerdrToken) {
|
|
635
|
+
if (!isFresh(message) || !message.text.trim()) return {
|
|
636
|
+
message,
|
|
637
|
+
route: { kind: "consumed" }
|
|
638
|
+
};
|
|
639
|
+
if (stateAtAdmission.kind === "selection") return {
|
|
640
|
+
message,
|
|
641
|
+
route: {
|
|
642
|
+
kind: "selection-refusal",
|
|
643
|
+
text: "请先选择目标(回复列表卡片或 /herdr use HANDLE);本条未发送。"
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
if (message.text.length > 8e3 || Array.from(message.text).some((char) => {
|
|
647
|
+
const code = char.charCodeAt(0);
|
|
648
|
+
return code < 32 && code !== 9 && code !== 10 || code === 127;
|
|
649
|
+
})) return {
|
|
650
|
+
message,
|
|
651
|
+
route: {
|
|
652
|
+
kind: "selection-refusal",
|
|
653
|
+
text: "指令需为 1–8000 个字符,且不能包含终端控制字符;本条未发送。"
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
return {
|
|
657
|
+
message,
|
|
658
|
+
route: {
|
|
659
|
+
kind: "locked-send",
|
|
660
|
+
agent: { ...stateAtAdmission.agent },
|
|
661
|
+
sendText: message.text
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (!isHerdrToken) return {
|
|
666
|
+
message,
|
|
667
|
+
route: { kind: "pass-through" }
|
|
668
|
+
};
|
|
669
|
+
if (!isFresh(message)) return {
|
|
670
|
+
message,
|
|
671
|
+
route: { kind: "consumed" }
|
|
672
|
+
};
|
|
673
|
+
if (mode) {
|
|
674
|
+
const useMatch = /^\/herdr\s+(?:use|select)\s+([a-f0-9]{12})\s*$/.exec(text);
|
|
675
|
+
if (useMatch) {
|
|
676
|
+
const selectionHandle = useMatch[1];
|
|
677
|
+
const card = message.selection;
|
|
678
|
+
if (card && (card.actorId !== message.actorId || card.chatId !== message.chatId || card.handle !== selectionHandle)) return {
|
|
679
|
+
message,
|
|
680
|
+
route: {
|
|
681
|
+
kind: "selection-refusal",
|
|
682
|
+
text: "选择已过期(凭据与消息不一致),请重新 /herdr 后选择。"
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
const credential = card ?? {
|
|
686
|
+
epoch: mode.epoch,
|
|
687
|
+
generation: mode.generationOf(message.actorId, message.chatId),
|
|
688
|
+
actorId: message.actorId,
|
|
689
|
+
chatId: message.chatId,
|
|
690
|
+
handle: selectionHandle
|
|
691
|
+
};
|
|
692
|
+
return {
|
|
693
|
+
message,
|
|
694
|
+
route: {
|
|
695
|
+
kind: card ? "select-card" : "select-text",
|
|
696
|
+
credential
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
if (/^\/herdr$/.test(text)) return {
|
|
701
|
+
message,
|
|
702
|
+
route: { kind: "enter" }
|
|
703
|
+
};
|
|
704
|
+
const menuMatch = /^\/herdr\s+(menu|list)(?:\s+(\S+))?\s*$/.exec(text);
|
|
705
|
+
if (menuMatch) return {
|
|
706
|
+
message,
|
|
707
|
+
route: {
|
|
708
|
+
kind: "refresh",
|
|
709
|
+
command: menuMatch[1],
|
|
710
|
+
argument: menuMatch[2] ?? ""
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
message,
|
|
716
|
+
route: { kind: "legacy" }
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* Must be called while holding an admission permit (the future Router composes
|
|
721
|
+
* its durable owner write between capture and this call, in the same turn).
|
|
722
|
+
* Claims the event id exactly once, then publishes the frozen refusal or
|
|
723
|
+
* commits the local control; returns the remote work to run outside.
|
|
724
|
+
*/
|
|
725
|
+
const admitCapturedInAdmission = (captured) => Effect.gen(function* () {
|
|
726
|
+
const { message, route } = captured;
|
|
727
|
+
if (route.kind === "pass-through" || route.kind === "consumed") return {
|
|
728
|
+
kind: "done",
|
|
729
|
+
result: { kind: route.kind }
|
|
730
|
+
};
|
|
731
|
+
if (!isFresh(message)) return {
|
|
732
|
+
kind: "done",
|
|
733
|
+
result: { kind: "consumed" }
|
|
734
|
+
};
|
|
735
|
+
if (!(yield* options.claim(message))) return {
|
|
736
|
+
kind: "done",
|
|
737
|
+
result: { kind: "consumed" }
|
|
738
|
+
};
|
|
739
|
+
switch (route.kind) {
|
|
740
|
+
case "denial": return {
|
|
741
|
+
kind: "done",
|
|
742
|
+
result: {
|
|
743
|
+
kind: "reply",
|
|
744
|
+
reply: reply(message, "此聊天未启用 Herdr 会话控制。")
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
case "frozen-refusal": return {
|
|
748
|
+
kind: "done",
|
|
749
|
+
result: {
|
|
750
|
+
kind: "reply",
|
|
751
|
+
reply: frozenReply(message)
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
case "selection-refusal": return {
|
|
755
|
+
kind: "done",
|
|
756
|
+
result: {
|
|
757
|
+
kind: "reply",
|
|
758
|
+
reply: reply(message, route.text)
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
case "exit": {
|
|
762
|
+
const outcome = yield* mode.exit(message.actorId, message.chatId);
|
|
763
|
+
if (outcome === "committed") {
|
|
764
|
+
watchExitRevisions.set(watchScopeKey(message), watchExitRevisionOf(message) + 1);
|
|
765
|
+
for (const [key, watch] of watches) if (watch.actorId === message.actorId && watch.chatId === message.chatId) watches.delete(key);
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
kind: "done",
|
|
769
|
+
result: {
|
|
770
|
+
kind: "reply",
|
|
771
|
+
reply: outcome === "frozen" ? frozenReply(message) : reply(message, "已退出会话模式;消息恢复由 Rivus 处理。")
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
case "locked-send": return {
|
|
776
|
+
kind: "work",
|
|
777
|
+
work: capturedSendWork(message, runCapturedSend(message, route.agent, route.sendText))
|
|
778
|
+
};
|
|
779
|
+
case "select-card":
|
|
780
|
+
case "select-text": {
|
|
781
|
+
const reserved = yield* mode.reserveSelection(route.credential);
|
|
782
|
+
if ("kind" in reserved) {
|
|
783
|
+
const notice = reserved.kind === "stale" ? "选择已过期(状态已变化或服务重启),请重新 /herdr 后选择。" : reserved.kind === "capacity" ? "会话状态名额已满(64)。" : reserved.kind === "not-selectable" ? reserved.reason : "会话状态暂不可用(保存未确认);本条未处理,也未转发。";
|
|
784
|
+
return {
|
|
785
|
+
kind: "done",
|
|
786
|
+
result: {
|
|
787
|
+
kind: "reply",
|
|
788
|
+
reply: reply(message, notice)
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
kind: "work",
|
|
794
|
+
work: inspectSelectionWork(message, reserved)
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
case "enter": {
|
|
798
|
+
const outcome = yield* mode.enterSelection(message.actorId, message.chatId);
|
|
799
|
+
if (outcome === "frozen") return {
|
|
800
|
+
kind: "done",
|
|
801
|
+
result: {
|
|
802
|
+
kind: "reply",
|
|
803
|
+
reply: frozenReply(message)
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
if (outcome === "capacity") return {
|
|
807
|
+
kind: "done",
|
|
808
|
+
result: {
|
|
809
|
+
kind: "reply",
|
|
810
|
+
reply: capacityReply(message)
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
return {
|
|
814
|
+
kind: "work",
|
|
815
|
+
work: inventoryWorkWithCurrentSelection(message, "menu", "", "已进入会话选择模式;选择目标后,普通消息将直接发送给它(/exit 退出)。")
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
case "refresh":
|
|
819
|
+
if ((yield* mode.refresh(message.actorId, message.chatId)) === "frozen") return {
|
|
820
|
+
kind: "done",
|
|
821
|
+
result: {
|
|
822
|
+
kind: "reply",
|
|
823
|
+
reply: frozenReply(message)
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
return {
|
|
827
|
+
kind: "work",
|
|
828
|
+
work: inventoryWorkWithCurrentSelection(message, route.command, route.argument)
|
|
829
|
+
};
|
|
830
|
+
case "legacy": return {
|
|
831
|
+
kind: "work",
|
|
832
|
+
work: commandWork(message, execute(message, watchExitRevisionOf(message)))
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
/** Runs after the permit is released; a freeze that appeared after admission refuses here. */
|
|
837
|
+
const runAdmitted = (work) => Effect.suspend(() => {
|
|
838
|
+
if (mode?.isFrozen()) return Effect.succeed({
|
|
839
|
+
kind: "reply",
|
|
840
|
+
reply: reply(work.message, "会话状态暂不可用(保存未确认);本条未处理,也未转发。")
|
|
841
|
+
});
|
|
842
|
+
switch (work.kind) {
|
|
843
|
+
case "inventory": return Effect.map(work.run, (replyResult) => ({
|
|
844
|
+
kind: "reply",
|
|
845
|
+
reply: replyResult
|
|
846
|
+
}));
|
|
847
|
+
case "inspect-selection": return work.run;
|
|
848
|
+
case "captured-send": return work.run;
|
|
849
|
+
case "command": return Effect.map(work.run, (replyResult) => replyResult === null ? { kind: "consumed" } : {
|
|
850
|
+
kind: "reply",
|
|
851
|
+
reply: replyResult
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
/** Single-shot composition for the compatibility handlers; Router composes the
|
|
856
|
+
* three explicit entries itself inside one withAdmission turn. */
|
|
857
|
+
const admit = (message) => withAdmission(Effect.gen(function* () {
|
|
858
|
+
const captured = captureInAdmission(message);
|
|
859
|
+
return yield* admitCapturedInAdmission(captured);
|
|
860
|
+
}));
|
|
861
|
+
const handleDetailed = (message) => Effect.gen(function* () {
|
|
862
|
+
const outcome = yield* admit(message);
|
|
863
|
+
if (outcome.kind === "done") return outcome.result;
|
|
864
|
+
return yield* runAdmitted(outcome.work);
|
|
865
|
+
});
|
|
866
|
+
return {
|
|
867
|
+
handle: (message) => Effect.map(handleDetailed(message), (detailed) => detailed.kind === "reply" ? detailed.reply : null),
|
|
868
|
+
handleDetailed,
|
|
869
|
+
submitCaptured: runCapturedSend,
|
|
870
|
+
admit,
|
|
871
|
+
withAdmission,
|
|
872
|
+
captureInAdmission,
|
|
873
|
+
admitCapturedInAdmission,
|
|
874
|
+
runAdmitted,
|
|
875
|
+
conversation: mode ? { mode } : void 0,
|
|
876
|
+
tick: () => Effect.gen(function* () {
|
|
877
|
+
const results = [];
|
|
878
|
+
for (const [key, watch] of watches) {
|
|
879
|
+
const isCurrent = () => watches.get(key) === watch && watch.expires > options.now() && allowed(watch.actorId, watch.chatId);
|
|
880
|
+
if (!isCurrent()) {
|
|
881
|
+
if (watches.get(key) === watch) watches.delete(key);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
const current = yield* options.sessions.inspect(watch.binding.agent).pipe(Effect.map((agent) => ({
|
|
885
|
+
status: agent.status,
|
|
886
|
+
failed: false
|
|
887
|
+
})), Effect.catch((error) => Effect.succeed({
|
|
888
|
+
status: error instanceof TerminalTargetChangedError ? "target changed" : "unavailable",
|
|
889
|
+
failed: true,
|
|
890
|
+
error
|
|
891
|
+
})));
|
|
892
|
+
if (!isCurrent() || current.status === watch.status) continue;
|
|
893
|
+
const notification = yield* (current.failed ? recovery({
|
|
894
|
+
...watch,
|
|
895
|
+
id: "watch",
|
|
896
|
+
sentAt: options.now(),
|
|
897
|
+
text: ""
|
|
898
|
+
}, watch.binding, current.error) : bindings.selections(watch, [watch.binding.agent]).pipe(Effect.map(([selected]) => detail(watch, {
|
|
899
|
+
...watch.binding.agent,
|
|
900
|
+
status: current.status,
|
|
901
|
+
ready: false
|
|
902
|
+
}, selected.handle, `状态有变化 · ${new Date(options.now()).toISOString()}。这是观察结果,不代表任务成功;操作前请刷新状态。`)))).pipe(Effect.catch(() => Effect.succeed(void 0)));
|
|
903
|
+
if (!notification || !isCurrent()) continue;
|
|
904
|
+
results.push({
|
|
905
|
+
reply: notification,
|
|
906
|
+
isCurrent,
|
|
907
|
+
acknowledge: () => {
|
|
908
|
+
if (isCurrent()) watch.status = current.status;
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
return results;
|
|
913
|
+
})
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/adapters/feishu/terminal-session/feishu-terminal-session.ts
|
|
918
|
+
function parseFeishuTerminalMessage(event) {
|
|
919
|
+
if (!event || typeof event !== "object") return null;
|
|
920
|
+
const value = event;
|
|
921
|
+
const message = value.message;
|
|
922
|
+
if (value.sender?.sender_type !== "user" || !value.sender.sender_id?.open_id || !message?.message_id || !message.chat_id || message.message_type !== "text") return null;
|
|
923
|
+
try {
|
|
924
|
+
const content = JSON.parse(message.content ?? "{}");
|
|
925
|
+
if (!content || typeof content !== "object" || !("text" in content) || typeof content.text !== "string") return null;
|
|
926
|
+
const sentAt = Number(message.create_time);
|
|
927
|
+
if (!Number.isFinite(sentAt)) return null;
|
|
928
|
+
return {
|
|
929
|
+
id: message.message_id,
|
|
930
|
+
actorId: value.sender.sender_id.open_id,
|
|
931
|
+
chatId: message.chat_id,
|
|
932
|
+
sentAt,
|
|
933
|
+
text: content.text.replace(/^@_user_\d+\s*/, "")
|
|
934
|
+
};
|
|
935
|
+
} catch {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async function deliverTerminalBridgeReply(reply, delivery, render) {
|
|
940
|
+
if (reply.menu && delivery.sendCard) return delivery.sendCard(reply.chatId, renderTerminalMenu({
|
|
941
|
+
...reply.menu,
|
|
942
|
+
...reply.menu.kind === "recovery" ? { notice: reply.text } : {}
|
|
943
|
+
}));
|
|
944
|
+
if (!reply.screen) return delivery.sendText(reply.chatId, reply.text);
|
|
945
|
+
try {
|
|
946
|
+
await delivery.sendImage(reply.chatId, await render(reply.screen));
|
|
947
|
+
} catch {
|
|
948
|
+
await delivery.sendText(reply.chatId, `${reply.text} | ${new Date(reply.screen.at).toISOString()}${reply.screen.truncated || reply.screen.text.length > 12e3 ? " | truncated(已截断)" : ""}\n图片暂不可用,以下是终端文字:\n${reply.screen.text.slice(-12e3)}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const V2_FORM_NAME = /^rivus_herdr_v2_submit_([a-f0-9]{12})$/;
|
|
952
|
+
const HANDLE_PATTERN = /^[a-f0-9]{12}$/;
|
|
953
|
+
/**
|
|
954
|
+
* Issued selection credential carried verbatim from the rendered row; the
|
|
955
|
+
* schema rejects malformed payloads instead of trusting typeof checks.
|
|
956
|
+
*/
|
|
957
|
+
const selectionCredentialSchema = Schema.Struct({
|
|
958
|
+
epoch: Schema.Trimmed.check(Schema.isNonEmpty()),
|
|
959
|
+
generation: Schema.Number.check(Schema.makeFilter((generation) => Number.isSafeInteger(generation) && generation >= 0)),
|
|
960
|
+
actorId: Schema.Trimmed.check(Schema.isNonEmpty()),
|
|
961
|
+
chatId: Schema.Trimmed.check(Schema.isNonEmpty()),
|
|
962
|
+
handle: Schema.String.check(Schema.makeFilter((handle) => HANDLE_PATTERN.test(handle)))
|
|
963
|
+
});
|
|
964
|
+
/** Flattened (or pre-flattened) card callback envelope as the SDK hands it to handlers. */
|
|
965
|
+
const cardActionEventSchema = Schema.Struct({
|
|
966
|
+
event_id: Schema.optional(Schema.String),
|
|
967
|
+
create_time: Schema.optional(Schema.String),
|
|
968
|
+
operator: Schema.optional(Schema.Struct({
|
|
969
|
+
open_id: Schema.optional(Schema.String),
|
|
970
|
+
operator_id: Schema.optional(Schema.Struct({ open_id: Schema.optional(Schema.String) }))
|
|
971
|
+
})),
|
|
972
|
+
event_key: Schema.optional(Schema.String),
|
|
973
|
+
context: Schema.optional(Schema.Struct({ open_chat_id: Schema.optional(Schema.String) })),
|
|
974
|
+
action: Schema.optional(Schema.Struct({
|
|
975
|
+
tag: Schema.optional(Schema.String),
|
|
976
|
+
name: Schema.optional(Schema.String),
|
|
977
|
+
value: Schema.optional(Schema.Struct({
|
|
978
|
+
bridge: Schema.optional(Schema.Literal("herdr")),
|
|
979
|
+
rivus_action: Schema.optional(Schema.String),
|
|
980
|
+
command: Schema.optional(Schema.String),
|
|
981
|
+
handle: Schema.optional(Schema.String.check(Schema.makeFilter((handle) => HANDLE_PATTERN.test(handle)))),
|
|
982
|
+
page: Schema.optional(Schema.Number),
|
|
983
|
+
selection: Schema.optional(selectionCredentialSchema)
|
|
984
|
+
})),
|
|
985
|
+
form_value: Schema.optional(Schema.Struct({ prompt: Schema.optional(Schema.String) }))
|
|
986
|
+
}))
|
|
987
|
+
});
|
|
988
|
+
const decodeCardActionEvent = (event) => {
|
|
989
|
+
try {
|
|
990
|
+
return Schema.decodeUnknownSync(cardActionEventSchema)(event);
|
|
991
|
+
} catch {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
/** The wrapped v2 envelope shape: the card callback lives under `event`. */
|
|
996
|
+
const eventEnvelopeSchema = Schema.Struct({ event: Schema.Record(Schema.String, Schema.Unknown) });
|
|
997
|
+
const decodeEventEnvelope = Schema.decodeUnknownSync(eventEnvelopeSchema);
|
|
998
|
+
/**
|
|
999
|
+
* Card callbacks arrive either flattened by the SDK (action at top level) or
|
|
1000
|
+
* wrapped under `event` (Pi intake shape); both carry the same payload.
|
|
1001
|
+
*/
|
|
1002
|
+
const readCardEnvelope = (event) => {
|
|
1003
|
+
const flat = decodeCardActionEvent(event);
|
|
1004
|
+
if (flat !== void 0 && (flat.action !== void 0 || flat.event_key !== void 0 || flat.operator !== void 0)) return flat;
|
|
1005
|
+
let inner;
|
|
1006
|
+
try {
|
|
1007
|
+
inner = decodeEventEnvelope(event).event;
|
|
1008
|
+
} catch {
|
|
1009
|
+
return flat;
|
|
1010
|
+
}
|
|
1011
|
+
return decodeCardActionEvent(inner);
|
|
1012
|
+
};
|
|
1013
|
+
const hasPiMarker = (value) => {
|
|
1014
|
+
const rivusAction = value?.value?.rivus_action;
|
|
1015
|
+
return rivusAction !== void 0 && rivusAction.length > 0;
|
|
1016
|
+
};
|
|
1017
|
+
/**
|
|
1018
|
+
* Classifies a raw card callback before any handler runs: Pi cards carry
|
|
1019
|
+
* `value.rivus_action`, Herdr cards carry `value.bridge === "herdr"` or the v2
|
|
1020
|
+
* form name (a submit button cannot carry a value, so its explicit v2 name is
|
|
1021
|
+
* the marker). Both markers must be refused by both sides; unmarked legacy
|
|
1022
|
+
* cards are invalid and get a refresh instruction.
|
|
1023
|
+
*/
|
|
1024
|
+
function classifyFeishuCardOwnership(event) {
|
|
1025
|
+
const decoded = readCardEnvelope(event);
|
|
1026
|
+
if (!decoded) return "unmarked";
|
|
1027
|
+
const pi = hasPiMarker(decoded.action);
|
|
1028
|
+
const herdr = decoded.action?.value?.bridge === "herdr" || V2_FORM_NAME.test(decoded.action?.name ?? "");
|
|
1029
|
+
if (pi && herdr) return "conflict";
|
|
1030
|
+
if (pi) return "pi";
|
|
1031
|
+
return herdr ? "herdr" : "unmarked";
|
|
1032
|
+
}
|
|
1033
|
+
function parseFeishuTerminalAction(event, menuChats) {
|
|
1034
|
+
const decoded = readCardEnvelope(event);
|
|
1035
|
+
if (!decoded) return null;
|
|
1036
|
+
const actorId = decoded.operator?.open_id ?? decoded.operator?.operator_id?.open_id;
|
|
1037
|
+
if (!actorId || !decoded.event_id || !decoded.create_time) return null;
|
|
1038
|
+
let text;
|
|
1039
|
+
let chatId;
|
|
1040
|
+
let selection;
|
|
1041
|
+
if (decoded.event_key) {
|
|
1042
|
+
if (!["herdr_list", "herdr_help"].includes(decoded.event_key)) return null;
|
|
1043
|
+
chatId = menuChats[actorId];
|
|
1044
|
+
text = decoded.event_key === "herdr_list" ? "/herdr menu" : "/herdr help";
|
|
1045
|
+
} else {
|
|
1046
|
+
chatId = decoded.context?.open_chat_id;
|
|
1047
|
+
const action = decoded.action;
|
|
1048
|
+
const form = V2_FORM_NAME.exec(action?.name ?? "");
|
|
1049
|
+
if (form) {
|
|
1050
|
+
if (hasPiMarker(action) || action?.form_value?.prompt === void 0) return null;
|
|
1051
|
+
text = `/herdr submit ${form[1]} -- ${action.form_value.prompt}`;
|
|
1052
|
+
} else {
|
|
1053
|
+
if (action?.tag !== "button" || action.value?.bridge !== "herdr" || hasPiMarker(action)) return null;
|
|
1054
|
+
const command = action.value.command;
|
|
1055
|
+
if (command === "use" || command === "select") {
|
|
1056
|
+
const handle = action.value.handle;
|
|
1057
|
+
const credential = action.value.selection;
|
|
1058
|
+
if (handle === void 0 || credential === void 0 || credential.handle !== handle) return null;
|
|
1059
|
+
text = `/herdr use ${handle}`;
|
|
1060
|
+
selection = credential;
|
|
1061
|
+
} else if (command === "menu") {
|
|
1062
|
+
const page = action.value.page ?? 0;
|
|
1063
|
+
if (!Number.isSafeInteger(page) || page < 0) return null;
|
|
1064
|
+
text = `/herdr menu ${page}`;
|
|
1065
|
+
} else {
|
|
1066
|
+
if (command === void 0 || ![
|
|
1067
|
+
"open",
|
|
1068
|
+
"refresh",
|
|
1069
|
+
"compose",
|
|
1070
|
+
"read",
|
|
1071
|
+
"screen",
|
|
1072
|
+
"watch",
|
|
1073
|
+
"unwatch"
|
|
1074
|
+
].includes(command)) return null;
|
|
1075
|
+
const handle = action.value.handle;
|
|
1076
|
+
if (handle === void 0) return null;
|
|
1077
|
+
text = `/herdr ${command} ${handle}`;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const sentAt = Number(decoded.create_time) / (decoded.event_key ? 1 : 1e3);
|
|
1082
|
+
if (!Number.isFinite(sentAt)) return null;
|
|
1083
|
+
return chatId ? {
|
|
1084
|
+
id: `action:${decoded.event_id}`,
|
|
1085
|
+
actorId,
|
|
1086
|
+
chatId,
|
|
1087
|
+
sentAt,
|
|
1088
|
+
text,
|
|
1089
|
+
...selection === void 0 ? {} : { selection }
|
|
1090
|
+
} : null;
|
|
1091
|
+
}
|
|
1092
|
+
function renderTerminalMenu(menu) {
|
|
1093
|
+
const plain = (content) => ({
|
|
1094
|
+
tag: "plain_text",
|
|
1095
|
+
content
|
|
1096
|
+
});
|
|
1097
|
+
const button = (label, command, handle, page, selection) => ({
|
|
1098
|
+
tag: "button",
|
|
1099
|
+
text: plain(label),
|
|
1100
|
+
type: "default",
|
|
1101
|
+
behaviors: [{
|
|
1102
|
+
type: "callback",
|
|
1103
|
+
value: {
|
|
1104
|
+
bridge: "herdr",
|
|
1105
|
+
command,
|
|
1106
|
+
handle,
|
|
1107
|
+
page,
|
|
1108
|
+
...selection === void 0 ? {} : { selection }
|
|
1109
|
+
}
|
|
1110
|
+
}]
|
|
1111
|
+
});
|
|
1112
|
+
const rowButton = (label, agent) => agent.selection === void 0 ? button(label, "open", agent.handle) : button(label, "use", agent.handle, void 0, agent.selection);
|
|
1113
|
+
const status = (agent) => {
|
|
1114
|
+
if (agent.status === "unavailable") return "离线/暂不可用";
|
|
1115
|
+
if (agent.kind === "terminal") return "终端只读";
|
|
1116
|
+
if (agent.ready && ["idle", "done"].includes(agent.status)) return "等待输入";
|
|
1117
|
+
return {
|
|
1118
|
+
working: "执行中",
|
|
1119
|
+
blocked: "等待处理",
|
|
1120
|
+
idle: "空闲,尚未确认可输入",
|
|
1121
|
+
done: "运行已结束,请刷新确认",
|
|
1122
|
+
unknown: "状态未知"
|
|
1123
|
+
}[agent.status] ?? "暂不可输入";
|
|
1124
|
+
};
|
|
1125
|
+
const elements = [];
|
|
1126
|
+
if (menu.notice) elements.push({
|
|
1127
|
+
tag: "div",
|
|
1128
|
+
text: plain(menu.notice)
|
|
1129
|
+
});
|
|
1130
|
+
if (menu.kind === "list") {
|
|
1131
|
+
elements.push({
|
|
1132
|
+
tag: "markdown",
|
|
1133
|
+
content: `**选择会话** · 第 ${(menu.page ?? 0) + 1}/${menu.pages ?? 1} 页`
|
|
1134
|
+
});
|
|
1135
|
+
const groups = Array$1.groupBy(menu.agents, (agent) => `${agent.session} / ${agent.workspace ?? "会话"}`);
|
|
1136
|
+
for (const [group, agents] of Object.entries(groups)) {
|
|
1137
|
+
elements.push({
|
|
1138
|
+
tag: "div",
|
|
1139
|
+
text: plain(group)
|
|
1140
|
+
});
|
|
1141
|
+
for (const agent of agents) elements.push(rowButton(`${agent.pane} · ${agent.tab ? agent.tab + " / " : ""}${agent.title ?? agent.name} · ${status(agent)}`.slice(0, 100), agent));
|
|
1142
|
+
}
|
|
1143
|
+
if (menu.unavailableSessions?.length) elements.push({
|
|
1144
|
+
tag: "div",
|
|
1145
|
+
text: plain(`以下 session 无法读取,列表不完整:${menu.unavailableSessions.join(", ")}`)
|
|
1146
|
+
});
|
|
1147
|
+
if (!menu.agents.length) elements.push({
|
|
1148
|
+
tag: "markdown",
|
|
1149
|
+
content: "当前没有可用会话。"
|
|
1150
|
+
});
|
|
1151
|
+
if ((menu.page ?? 0) > 0) elements.push(button("上一页", "menu", void 0, menu.page - 1));
|
|
1152
|
+
if ((menu.page ?? 0) + 1 < (menu.pages ?? 1)) elements.push(button("下一页", "menu", void 0, menu.page + 1));
|
|
1153
|
+
} else if (menu.kind === "recovery") {
|
|
1154
|
+
if (menu.agents[0]) elements.push(button("刷新原会话", "refresh", menu.agents[0].handle));
|
|
1155
|
+
} else {
|
|
1156
|
+
const agent = menu.agents[0];
|
|
1157
|
+
elements.push({
|
|
1158
|
+
tag: "div",
|
|
1159
|
+
text: plain(`目标:${agent.session} / ${agent.name}\n状态:${status(agent)}`)
|
|
1160
|
+
});
|
|
1161
|
+
if (menu.kind === "form") {
|
|
1162
|
+
elements.push({
|
|
1163
|
+
tag: "form",
|
|
1164
|
+
name: "herdr_prompt",
|
|
1165
|
+
elements: [{
|
|
1166
|
+
tag: "input",
|
|
1167
|
+
name: "prompt",
|
|
1168
|
+
required: true,
|
|
1169
|
+
input_type: "multiline_text",
|
|
1170
|
+
max_length: 1e3,
|
|
1171
|
+
...menu.draft === void 0 ? {} : { default_value: menu.draft },
|
|
1172
|
+
placeholder: plain("输入要交给此 agent 的指令"),
|
|
1173
|
+
label: plain("指令内容")
|
|
1174
|
+
}, {
|
|
1175
|
+
tag: "button",
|
|
1176
|
+
name: `rivus_herdr_v2_submit_${agent.handle}`,
|
|
1177
|
+
text: plain("发送"),
|
|
1178
|
+
type: "primary_filled",
|
|
1179
|
+
form_action_type: "submit"
|
|
1180
|
+
}]
|
|
1181
|
+
});
|
|
1182
|
+
elements.push({
|
|
1183
|
+
tag: "markdown",
|
|
1184
|
+
content: "每个表单只提交一次;提交后可通过“看输出”核对执行结果。"
|
|
1185
|
+
});
|
|
1186
|
+
} else {
|
|
1187
|
+
const verdict = evaluateTerminalWriteInput(agent, menu.writable);
|
|
1188
|
+
if (verdict.allowed) elements.push(button("发下一条指令", "compose", agent.handle));
|
|
1189
|
+
else elements.push({
|
|
1190
|
+
tag: "div",
|
|
1191
|
+
text: plain(menu.writable && agent.kind === "agent" ? `${verdict.text}请查看输出,或刷新状态后再试。` : "此会话为只读模式。")
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
elements.push(button("关注状态", "watch", agent.handle), button("取消关注", "unwatch", agent.handle), button("看画面", "screen", agent.handle), button("看输出", "read", agent.handle), button("刷新当前会话", "refresh", agent.handle));
|
|
1195
|
+
}
|
|
1196
|
+
elements.push(button("刷新会话列表", "menu"));
|
|
1197
|
+
return {
|
|
1198
|
+
schema: "2.0",
|
|
1199
|
+
config: {
|
|
1200
|
+
width_mode: "default",
|
|
1201
|
+
enable_forward: false
|
|
1202
|
+
},
|
|
1203
|
+
header: {
|
|
1204
|
+
title: plain(menu.kind === "form" ? "向会话发送指令" : "Herdr 会话"),
|
|
1205
|
+
template: "blue"
|
|
1206
|
+
},
|
|
1207
|
+
body: { elements }
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function createFeishuTerminalDelivery(client, options) {
|
|
1211
|
+
const refuseWhenStopped = () => {
|
|
1212
|
+
if (options?.isStopped?.() === true) throw new Error("Herdr runtime is stopped; delivery refused");
|
|
1213
|
+
};
|
|
1214
|
+
return {
|
|
1215
|
+
sendCard: async (chatId, card) => {
|
|
1216
|
+
refuseWhenStopped();
|
|
1217
|
+
if ((await client.im.message.create({
|
|
1218
|
+
params: { receive_id_type: "chat_id" },
|
|
1219
|
+
data: {
|
|
1220
|
+
receive_id: chatId,
|
|
1221
|
+
msg_type: "interactive",
|
|
1222
|
+
content: JSON.stringify(card)
|
|
1223
|
+
}
|
|
1224
|
+
})).code !== 0) throw new Error("Feishu card delivery failed");
|
|
1225
|
+
},
|
|
1226
|
+
sendText: async (chatId, text) => {
|
|
1227
|
+
refuseWhenStopped();
|
|
1228
|
+
if ((await client.im.message.create({
|
|
1229
|
+
params: { receive_id_type: "chat_id" },
|
|
1230
|
+
data: {
|
|
1231
|
+
receive_id: chatId,
|
|
1232
|
+
msg_type: "text",
|
|
1233
|
+
content: JSON.stringify({ text })
|
|
1234
|
+
}
|
|
1235
|
+
})).code !== 0) throw new Error("Feishu text delivery failed");
|
|
1236
|
+
},
|
|
1237
|
+
sendImage: async (chatId, bytes) => {
|
|
1238
|
+
refuseWhenStopped();
|
|
1239
|
+
const value = await client.im.image.create({ data: {
|
|
1240
|
+
image_type: "message",
|
|
1241
|
+
image: Buffer.from(bytes)
|
|
1242
|
+
} });
|
|
1243
|
+
const imageKey = value?.image_key ?? value?.data?.image_key;
|
|
1244
|
+
if (!imageKey) throw new Error("Feishu image upload failed");
|
|
1245
|
+
refuseWhenStopped();
|
|
1246
|
+
if ((await client.im.message.create({
|
|
1247
|
+
params: { receive_id_type: "chat_id" },
|
|
1248
|
+
data: {
|
|
1249
|
+
receive_id: chatId,
|
|
1250
|
+
msg_type: "image",
|
|
1251
|
+
content: JSON.stringify({ image_key: imageKey })
|
|
1252
|
+
}
|
|
1253
|
+
})).code !== 0) throw new Error("Feishu image delivery failed");
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
//#endregion
|
|
1258
|
+
//#region src/adapters/herdr/session/herdr-socket-request.ts
|
|
1259
|
+
const responseRecord = Schema.Record(Schema.String, Schema.Unknown);
|
|
1260
|
+
const isResponseRecord = Schema.is(responseRecord);
|
|
1261
|
+
const isString = Schema.is(Schema.String);
|
|
1262
|
+
function object(value) {
|
|
1263
|
+
if (!isResponseRecord(value)) throw new Error("Invalid Herdr response");
|
|
1264
|
+
return value;
|
|
1265
|
+
}
|
|
1266
|
+
function string(value) {
|
|
1267
|
+
if (!isString(value)) throw new Error("Invalid Herdr response field");
|
|
1268
|
+
return value;
|
|
1269
|
+
}
|
|
1270
|
+
function requestHerdrSocket(path, method, params, timeoutMs, signal) {
|
|
1271
|
+
return new Promise((resolve, reject) => {
|
|
1272
|
+
const id = randomUUID();
|
|
1273
|
+
const socket = createConnection({ path });
|
|
1274
|
+
let settled = false;
|
|
1275
|
+
let bytes = 0;
|
|
1276
|
+
let buffer = "";
|
|
1277
|
+
const onError = (error) => finish(error);
|
|
1278
|
+
socket.once("close", () => socket.removeListener("error", onError));
|
|
1279
|
+
const finish = (error, result) => {
|
|
1280
|
+
if (settled) return;
|
|
1281
|
+
settled = true;
|
|
1282
|
+
clearTimeout(timer);
|
|
1283
|
+
signal.removeEventListener("abort", abort);
|
|
1284
|
+
socket.removeAllListeners("connect");
|
|
1285
|
+
socket.removeAllListeners("data");
|
|
1286
|
+
socket.removeAllListeners("end");
|
|
1287
|
+
socket.destroy();
|
|
1288
|
+
if (error) reject(error);
|
|
1289
|
+
else resolve(result);
|
|
1290
|
+
};
|
|
1291
|
+
const abort = () => finish(/* @__PURE__ */ new Error("Herdr request aborted; input may have been submitted"));
|
|
1292
|
+
const timer = setTimeout(() => finish(/* @__PURE__ */ new Error("Herdr request timed out; input may have been submitted")), timeoutMs);
|
|
1293
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1294
|
+
socket.setEncoding("utf8");
|
|
1295
|
+
socket.on("connect", () => socket.write(`${JSON.stringify({
|
|
1296
|
+
id,
|
|
1297
|
+
method,
|
|
1298
|
+
params
|
|
1299
|
+
})}\n`));
|
|
1300
|
+
socket.on("error", onError);
|
|
1301
|
+
socket.on("end", () => finish(/* @__PURE__ */ new Error("Herdr closed without a response; outcome unknown")));
|
|
1302
|
+
socket.on("data", (chunk) => {
|
|
1303
|
+
bytes += Buffer.byteLength(chunk);
|
|
1304
|
+
if (bytes > 1048576) return finish(/* @__PURE__ */ new Error("Herdr response exceeds one MiB"));
|
|
1305
|
+
buffer += chunk;
|
|
1306
|
+
const newline = buffer.indexOf("\n");
|
|
1307
|
+
if (newline < 0) return;
|
|
1308
|
+
try {
|
|
1309
|
+
const response = object(JSON.parse(buffer.slice(0, newline)));
|
|
1310
|
+
if (response.id !== id) throw new Error("Herdr response identity mismatch");
|
|
1311
|
+
if (response.error) throw new Error(`Herdr error: ${string(object(response.error).code)}`);
|
|
1312
|
+
finish(void 0, object(response.result));
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
finish(error);
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
if (signal.aborted) abort();
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
//#endregion
|
|
1321
|
+
//#region src/adapters/herdr/session/herdr-socket-client.ts
|
|
1322
|
+
function createHerdrSocketClient(sessions, timeoutMs = 5e3, discoverSessionsIn) {
|
|
1323
|
+
if (!sessions.length && !discoverSessionsIn || new Set(sessions.map((s) => s.name)).size !== sessions.length) throw new Error("Herdr requires unique configured session names");
|
|
1324
|
+
if (discoverSessionsIn && !isAbsolute(discoverSessionsIn)) throw new Error("Herdr discovery root must be absolute");
|
|
1325
|
+
let configs = new Map(sessions.map((session) => [session.name, session]));
|
|
1326
|
+
const refresh = () => Effect.tryPromise({
|
|
1327
|
+
try: async () => {
|
|
1328
|
+
const discovered = discoverSessionsIn ? await discoverSessions(discoverSessionsIn) : [];
|
|
1329
|
+
const next = /* @__PURE__ */ new Map();
|
|
1330
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1331
|
+
for (const config of [...sessions, ...discovered]) {
|
|
1332
|
+
const path = resolve(config.socketPath);
|
|
1333
|
+
if (paths.has(path)) continue;
|
|
1334
|
+
if (next.has(config.name)) throw new Error("Herdr session aliases collide");
|
|
1335
|
+
next.set(config.name, {
|
|
1336
|
+
...config,
|
|
1337
|
+
socketPath: path
|
|
1338
|
+
});
|
|
1339
|
+
paths.add(path);
|
|
1340
|
+
}
|
|
1341
|
+
configs = next;
|
|
1342
|
+
return [...next.keys()];
|
|
1343
|
+
},
|
|
1344
|
+
catch: (error) => error
|
|
1345
|
+
});
|
|
1346
|
+
const invoke = (session, method, params) => Effect.tryPromise({
|
|
1347
|
+
try: async (signal) => {
|
|
1348
|
+
const config = configs.get(session);
|
|
1349
|
+
if (!config) throw new Error("Unknown Herdr session");
|
|
1350
|
+
const socket = await lstat(config.socketPath);
|
|
1351
|
+
if (!socket.isSocket() || (socket.mode & 63) !== 0 || socket.uid !== process.getuid?.()) throw new Error("Herdr socket must be private and owned by the current user");
|
|
1352
|
+
return {
|
|
1353
|
+
result: await requestHerdrSocket(config.socketPath, method, params, timeoutMs, signal),
|
|
1354
|
+
generation: `${socket.dev}:${socket.ino}:${socket.birthtimeMs}`
|
|
1355
|
+
};
|
|
1356
|
+
},
|
|
1357
|
+
catch: (error) => error
|
|
1358
|
+
});
|
|
1359
|
+
const listSession = (session) => invoke(session, "session.snapshot", {}).pipe(Effect.flatMap(({ result, generation }) => Effect.try(() => {
|
|
1360
|
+
const snapshot = object(result.snapshot);
|
|
1361
|
+
if (snapshot.protocol !== 20 && snapshot.protocol !== 22 || !Array.isArray(snapshot.panes) || !Array.isArray(snapshot.agents)) throw new Error("Herdr protocol 20 or 22 session snapshot required");
|
|
1362
|
+
const agents = snapshot.agents.map(object);
|
|
1363
|
+
const labels = (values, key) => new Map((Array.isArray(values) ? values : []).map((item) => {
|
|
1364
|
+
const entry = object(item);
|
|
1365
|
+
return [string(entry[key]), string(entry.label)];
|
|
1366
|
+
}));
|
|
1367
|
+
const workspaces = labels(snapshot.workspaces, "workspace_id");
|
|
1368
|
+
const tabs = labels(snapshot.tabs, "tab_id");
|
|
1369
|
+
return snapshot.panes.map((entry) => {
|
|
1370
|
+
const value = object(entry);
|
|
1371
|
+
const pane = string(value.pane_id);
|
|
1372
|
+
const terminal = string(value.terminal_id);
|
|
1373
|
+
const agent = agents.find((item) => item.pane_id === pane && item.terminal_id === terminal);
|
|
1374
|
+
const kind = typeof value.agent === "string" && value.agent ? value.agent : "terminal";
|
|
1375
|
+
const workspace = string(value.workspace_id);
|
|
1376
|
+
const tab = string(value.tab_id);
|
|
1377
|
+
const title = [
|
|
1378
|
+
agent?.name,
|
|
1379
|
+
value.label,
|
|
1380
|
+
value.terminal_title_stripped,
|
|
1381
|
+
value.title
|
|
1382
|
+
].find((item) => typeof item === "string" && item.length > 0) ?? kind;
|
|
1383
|
+
return {
|
|
1384
|
+
session,
|
|
1385
|
+
pane,
|
|
1386
|
+
identity: createHash("sha256").update(JSON.stringify([
|
|
1387
|
+
generation,
|
|
1388
|
+
session,
|
|
1389
|
+
pane,
|
|
1390
|
+
terminal,
|
|
1391
|
+
kind,
|
|
1392
|
+
value.agent_session ?? null
|
|
1393
|
+
])).digest("hex"),
|
|
1394
|
+
kind: kind === "terminal" ? "terminal" : "agent",
|
|
1395
|
+
name: `${pane} / ${workspaces.get(workspace) ?? workspace} / ${tabs.get(tab) ?? tab} / ${title}`,
|
|
1396
|
+
workspace: workspaces.get(workspace) ?? workspace,
|
|
1397
|
+
tab: tabs.get(tab) ?? tab,
|
|
1398
|
+
title,
|
|
1399
|
+
status: string(value.agent_status),
|
|
1400
|
+
ready: kind !== "terminal" && agent !== void 0 && (agent.launch_pending === void 0 || agent.launch_pending === false)
|
|
1401
|
+
};
|
|
1402
|
+
});
|
|
1403
|
+
})));
|
|
1404
|
+
const resolve$1 = (agent) => refresh().pipe(Effect.flatMap(() => listSession(agent.session)), Effect.flatMap((agents) => {
|
|
1405
|
+
const current = agents.find((item) => item.pane === agent.pane && item.identity === agent.identity);
|
|
1406
|
+
return current ? Effect.succeed(current) : Effect.fail(new TerminalTargetChangedError());
|
|
1407
|
+
}));
|
|
1408
|
+
return {
|
|
1409
|
+
list: () => refresh().pipe(Effect.flatMap((names) => Effect.all(names.map((name) => listSession(name).pipe(Effect.map((agents) => ({
|
|
1410
|
+
agents,
|
|
1411
|
+
unavailableSessions: []
|
|
1412
|
+
})), Effect.catch(() => Effect.succeed({
|
|
1413
|
+
agents: [],
|
|
1414
|
+
unavailableSessions: [name]
|
|
1415
|
+
})))), { concurrency: 4 })), Effect.map((results) => ({
|
|
1416
|
+
agents: results.flatMap((result) => result.agents),
|
|
1417
|
+
unavailableSessions: results.flatMap((result) => result.unavailableSessions)
|
|
1418
|
+
}))),
|
|
1419
|
+
inspect: resolve$1,
|
|
1420
|
+
read: (agent, visible) => resolve$1(agent).pipe(Effect.flatMap(() => invoke(agent.session, "pane.read", {
|
|
1421
|
+
pane_id: agent.pane,
|
|
1422
|
+
source: visible ? "visible" : "recent_unwrapped",
|
|
1423
|
+
format: "text",
|
|
1424
|
+
lines: visible ? 80 : 120
|
|
1425
|
+
})), Effect.flatMap(({ result }) => Effect.try(() => {
|
|
1426
|
+
const read = object(result.read);
|
|
1427
|
+
return {
|
|
1428
|
+
text: string(read.text),
|
|
1429
|
+
truncated: read.truncated === true
|
|
1430
|
+
};
|
|
1431
|
+
}))),
|
|
1432
|
+
prompt: (agent, text) => resolve$1(agent).pipe(Effect.catch((error) => Effect.fail(error instanceof TerminalTargetChangedError ? error : new TerminalPromptPreflightError("目标暂不可用;未提交指令。可 /exit 后重新选择。"))), Effect.flatMap((current) => {
|
|
1433
|
+
const verdict = evaluateTerminalWriteInput(current, true);
|
|
1434
|
+
return verdict.allowed ? Effect.void : Effect.fail(new TerminalPromptPreflightError(`Input rejected: ${verdict.text}`));
|
|
1435
|
+
}), Effect.flatMap(() => invoke(agent.session, "agent.prompt", {
|
|
1436
|
+
target: agent.pane,
|
|
1437
|
+
text
|
|
1438
|
+
})), Effect.asVoid)
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
async function discoverSessions(root) {
|
|
1442
|
+
const directory = await lstat(root);
|
|
1443
|
+
if (!directory.isDirectory() || directory.uid !== process.getuid?.() || (directory.mode & 18) !== 0) throw new Error("Herdr discovery root must be an owned, non-writable directory");
|
|
1444
|
+
const candidates = [{
|
|
1445
|
+
name: "default",
|
|
1446
|
+
socketPath: join(root, "herdr.sock")
|
|
1447
|
+
}];
|
|
1448
|
+
const named = join(root, "sessions");
|
|
1449
|
+
const info = await lstat(named).catch((error) => {
|
|
1450
|
+
if (error.code === "ENOENT") return null;
|
|
1451
|
+
throw error;
|
|
1452
|
+
});
|
|
1453
|
+
if (info) {
|
|
1454
|
+
if (!info.isDirectory() || info.uid !== process.getuid?.() || (info.mode & 18) !== 0) throw new Error("Invalid Herdr sessions directory");
|
|
1455
|
+
for (const entry of (await readdir(named, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) if (entry.isDirectory()) candidates.push({
|
|
1456
|
+
name: entry.name,
|
|
1457
|
+
socketPath: join(named, entry.name, "herdr.sock")
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
const found = [];
|
|
1461
|
+
for (const candidate of candidates) if (await lstat(candidate.socketPath).catch((error) => {
|
|
1462
|
+
if (error.code === "ENOENT") return null;
|
|
1463
|
+
throw error;
|
|
1464
|
+
})) found.push(candidate);
|
|
1465
|
+
return found;
|
|
1466
|
+
}
|
|
1467
|
+
//#endregion
|
|
1468
|
+
//#region src/adapters/outbound/persistence/terminal-session/terminal-agent-schema.ts
|
|
1469
|
+
/**
|
|
1470
|
+
* File schema for the Application `TerminalAgent` snapshot, shared by every
|
|
1471
|
+
* terminal-session store that persists an occupant identity (bindings, conversation
|
|
1472
|
+
* states) so validation cannot drift between stores.
|
|
1473
|
+
*/
|
|
1474
|
+
const terminalAgentSchema = Schema.Struct({
|
|
1475
|
+
kind: Schema.Literals(["agent", "terminal"]),
|
|
1476
|
+
session: Schema.String,
|
|
1477
|
+
pane: Schema.String,
|
|
1478
|
+
identity: Schema.String,
|
|
1479
|
+
name: Schema.String,
|
|
1480
|
+
workspace: Schema.optional(Schema.String),
|
|
1481
|
+
tab: Schema.optional(Schema.String),
|
|
1482
|
+
title: Schema.optional(Schema.String),
|
|
1483
|
+
status: Schema.String,
|
|
1484
|
+
ready: Schema.Boolean
|
|
1485
|
+
});
|
|
1486
|
+
//#endregion
|
|
1487
|
+
//#region src/adapters/outbound/persistence/terminal-session/terminal-bindings-store.ts
|
|
1488
|
+
const bindingSchema = Schema.Struct({
|
|
1489
|
+
handle: Schema.String,
|
|
1490
|
+
actorId: Schema.String,
|
|
1491
|
+
chatId: Schema.String,
|
|
1492
|
+
expires: Schema.optional(Schema.Number),
|
|
1493
|
+
agent: terminalAgentSchema
|
|
1494
|
+
});
|
|
1495
|
+
const snapshotSchema = Schema.Struct({
|
|
1496
|
+
version: Schema.Literal(1),
|
|
1497
|
+
appId: Schema.String,
|
|
1498
|
+
bindings: Schema.Array(bindingSchema)
|
|
1499
|
+
});
|
|
1500
|
+
async function openTerminalBindingsStore(directory, appId) {
|
|
1501
|
+
const digest = createHash("sha256").update(appId).digest("hex");
|
|
1502
|
+
const filePath = join(directory, `bindings-${digest}.json`);
|
|
1503
|
+
const decode = (value) => {
|
|
1504
|
+
const snapshot = Schema.decodeUnknownSync(snapshotSchema)(value);
|
|
1505
|
+
if (snapshot.appId !== appId || snapshot.bindings.length > 1e3 || new Set(snapshot.bindings.map((binding) => binding.handle)).size !== snapshot.bindings.length || snapshot.bindings.some((binding) => !/^[a-f0-9]{12}$/.test(binding.handle) || binding.expires !== void 0 && !Number.isFinite(binding.expires))) throw new Error("Invalid terminal binding snapshot");
|
|
1506
|
+
return snapshot.bindings;
|
|
1507
|
+
};
|
|
1508
|
+
let initial = [];
|
|
1509
|
+
try {
|
|
1510
|
+
const raw = await readFile(filePath, "utf8");
|
|
1511
|
+
initial = decode(JSON.parse(raw));
|
|
1512
|
+
await chmod(filePath, 384);
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
if (error.code !== "ENOENT") throw new Error("Cannot restore terminal bindings", { cause: error });
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
initial,
|
|
1518
|
+
save: (bindings) => Effect.uninterruptible(Effect.tryPromise({
|
|
1519
|
+
try: async () => {
|
|
1520
|
+
const snapshot = {
|
|
1521
|
+
version: 1,
|
|
1522
|
+
appId,
|
|
1523
|
+
bindings
|
|
1524
|
+
};
|
|
1525
|
+
const validated = decode(snapshot);
|
|
1526
|
+
await writeAtomicTextFile(filePath, JSON.stringify({
|
|
1527
|
+
...snapshot,
|
|
1528
|
+
bindings: validated
|
|
1529
|
+
}), {
|
|
1530
|
+
mode: 384,
|
|
1531
|
+
durable: true
|
|
1532
|
+
});
|
|
1533
|
+
},
|
|
1534
|
+
catch: (error) => error
|
|
1535
|
+
}))
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/adapters/outbound/persistence/terminal-session/terminal-message-claims.ts
|
|
1540
|
+
const isErrnoCode = Schema.Struct({ code: Schema.String });
|
|
1541
|
+
/**
|
|
1542
|
+
* Exclusive state-directory lifetime lock plus durable source-message and form
|
|
1543
|
+
* claims. The caller owns the lock for the store's lifetime: every terminal-session
|
|
1544
|
+
* or routing store in the same directory must be opened while this lock is held and
|
|
1545
|
+
* must never acquire its own duplicate. Normal stop joins all work before `close`
|
|
1546
|
+
* releases the lock; a crashed process leaves the lock on disk and recovery is
|
|
1547
|
+
* manual — never an automatic PID/age-based steal. An operator may remove the lock
|
|
1548
|
+
* only after the previous owner is verified stopped.
|
|
1549
|
+
*/
|
|
1550
|
+
function createDirectoryUnlessPresent(path) {
|
|
1551
|
+
return mkdir(path, {
|
|
1552
|
+
recursive: false,
|
|
1553
|
+
mode: 448
|
|
1554
|
+
}).then(() => void 0, (error) => {
|
|
1555
|
+
if (!(Schema.is(isErrnoCode)(error) && error.code === "EEXIST")) throw error;
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
async function openTerminalMessageClaims(directory, appId) {
|
|
1559
|
+
if (await lstat(join(directory, "..")).then(() => false, (error) => {
|
|
1560
|
+
if (Schema.is(isErrnoCode)(error) && error.code === "ENOENT") return true;
|
|
1561
|
+
throw error;
|
|
1562
|
+
})) throw new Error(`State directory parent does not exist: provision the deployment directory before starting (${join(directory, "..")})`);
|
|
1563
|
+
const parentInfo = await lstat(join(directory, ".."));
|
|
1564
|
+
if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) throw new Error("State directory parent is not a provisioned directory");
|
|
1565
|
+
await createDirectoryUnlessPresent(directory);
|
|
1566
|
+
const directoryInfo = await lstat(directory);
|
|
1567
|
+
if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) throw new Error("Claims state directory is not a regular directory");
|
|
1568
|
+
if (directoryInfo.uid !== process.getuid?.() || (directoryInfo.mode & 63) !== 0) throw new Error("Unsafe claims state directory permissions or owner");
|
|
1569
|
+
const parentHandle = await open(join(directory, ".."), "r");
|
|
1570
|
+
try {
|
|
1571
|
+
await parentHandle.sync();
|
|
1572
|
+
} finally {
|
|
1573
|
+
await parentHandle.close();
|
|
1574
|
+
}
|
|
1575
|
+
const lockPath = join(directory, "bridge.lock");
|
|
1576
|
+
const lock = await open(lockPath, "wx", 384);
|
|
1577
|
+
await lock.writeFile(JSON.stringify({
|
|
1578
|
+
pid: process.pid,
|
|
1579
|
+
appId
|
|
1580
|
+
}));
|
|
1581
|
+
const claims = join(directory, "claims");
|
|
1582
|
+
try {
|
|
1583
|
+
await createDirectoryUnlessPresent(claims);
|
|
1584
|
+
const claimsInfo = await lstat(claims);
|
|
1585
|
+
if (claimsInfo.isSymbolicLink() || !claimsInfo.isDirectory()) throw new Error("Claims directory is not a regular directory");
|
|
1586
|
+
if (claimsInfo.uid !== process.getuid?.() || (claimsInfo.mode & 63) !== 0) throw new Error("Unsafe claims directory permissions or owner");
|
|
1587
|
+
const folder = await open(directory, "r");
|
|
1588
|
+
try {
|
|
1589
|
+
await folder.sync();
|
|
1590
|
+
} finally {
|
|
1591
|
+
await folder.close();
|
|
1592
|
+
}
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
await lock.close().catch(() => void 0);
|
|
1595
|
+
await unlink(lockPath);
|
|
1596
|
+
throw error;
|
|
1597
|
+
}
|
|
1598
|
+
const claimPath = (messageId) => {
|
|
1599
|
+
const digest = createHash("sha256").update(JSON.stringify([appId, messageId])).digest("hex");
|
|
1600
|
+
return join(claims, digest);
|
|
1601
|
+
};
|
|
1602
|
+
return {
|
|
1603
|
+
claim: (message) => Effect.uninterruptible(Effect.tryPromise({
|
|
1604
|
+
try: async () => {
|
|
1605
|
+
let file;
|
|
1606
|
+
try {
|
|
1607
|
+
file = await open(claimPath(message.id), "wx", 384);
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
if (Schema.is(isErrnoCode)(error) && error.code === "EEXIST") return false;
|
|
1610
|
+
throw error;
|
|
1611
|
+
}
|
|
1612
|
+
try {
|
|
1613
|
+
await file.writeFile(JSON.stringify({
|
|
1614
|
+
state: "claimed",
|
|
1615
|
+
at: Date.now()
|
|
1616
|
+
}));
|
|
1617
|
+
await file.sync();
|
|
1618
|
+
const folder = await open(claims, "r");
|
|
1619
|
+
try {
|
|
1620
|
+
await folder.sync();
|
|
1621
|
+
} finally {
|
|
1622
|
+
await folder.close();
|
|
1623
|
+
}
|
|
1624
|
+
await file.close();
|
|
1625
|
+
return true;
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
await file.close().catch(() => void 0);
|
|
1628
|
+
throw error;
|
|
1629
|
+
}
|
|
1630
|
+
},
|
|
1631
|
+
catch: (error) => error
|
|
1632
|
+
})),
|
|
1633
|
+
/**
|
|
1634
|
+
* Read-only legacy fence lookup with the exact claim digest. Existence is the
|
|
1635
|
+
* fence: arbitrary or invalid file contents still count as claimed. `false` is
|
|
1636
|
+
* only returned for a missing entry (ENOENT); every other I/O failure
|
|
1637
|
+
* propagates. Never creates, mutates or deletes historic files.
|
|
1638
|
+
*/
|
|
1639
|
+
hasClaim: (messageId) => Effect.tryPromise({
|
|
1640
|
+
try: async () => {
|
|
1641
|
+
try {
|
|
1642
|
+
await lstat(claimPath(messageId));
|
|
1643
|
+
return true;
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
if (Schema.is(isErrnoCode)(error) && error.code === "ENOENT") return false;
|
|
1646
|
+
throw error;
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
catch: (error) => error
|
|
1650
|
+
}),
|
|
1651
|
+
close: async () => {
|
|
1652
|
+
await lock.close();
|
|
1653
|
+
if (JSON.parse(await readFile(lockPath, "utf8")).pid === process.pid) await unlink(lockPath);
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
//#endregion
|
|
1658
|
+
//#region src/bootstrap/feishu/herdr-config.ts
|
|
1659
|
+
/** Shared Herdr field schemas for the standalone options and the embedded route config. */
|
|
1660
|
+
const herdrSessionSchema = Schema.Struct({
|
|
1661
|
+
name: Schema.Trimmed.check(Schema.isNonEmpty()),
|
|
1662
|
+
socketPath: Schema.String.check(Schema.makeFilter(isAbsolute))
|
|
1663
|
+
});
|
|
1664
|
+
const nonEmptyStringArray = Schema.NonEmptyArray(Schema.Trimmed.check(Schema.isNonEmpty()));
|
|
1665
|
+
const stringRecord = Schema.Record(Schema.String, Schema.Trimmed.check(Schema.isNonEmpty()));
|
|
1666
|
+
const absolutePath = Schema.String.check(Schema.makeFilter(isAbsolute));
|
|
1667
|
+
const optionalAbsolutePathArray = Schema.optional(Schema.Array(Schema.String.check(Schema.makeFilter(isAbsolute))));
|
|
1668
|
+
const sharedHerdrFields = {
|
|
1669
|
+
appId: Schema.Trimmed.check(Schema.isNonEmpty()),
|
|
1670
|
+
sessions: Schema.optional(Schema.Array(herdrSessionSchema)),
|
|
1671
|
+
discoverSessionsIn: Schema.optional(Schema.String.check(Schema.makeFilter(isAbsolute))),
|
|
1672
|
+
allowedActors: nonEmptyStringArray,
|
|
1673
|
+
allowedChats: nonEmptyStringArray,
|
|
1674
|
+
menuChats: Schema.optional(stringRecord),
|
|
1675
|
+
stateDirectory: absolutePath,
|
|
1676
|
+
writable: Schema.optional(Schema.Boolean),
|
|
1677
|
+
fontFiles: optionalAbsolutePathArray
|
|
1678
|
+
};
|
|
1679
|
+
const standaloneHerdrOptionsSchema = Schema.Struct({
|
|
1680
|
+
...sharedHerdrFields,
|
|
1681
|
+
appSecret: Schema.Trimmed.check(Schema.isNonEmpty())
|
|
1682
|
+
});
|
|
1683
|
+
const embeddedHerdrRouteSchema = Schema.Struct({ ...sharedHerdrFields });
|
|
1684
|
+
//#endregion
|
|
1685
|
+
//#region src/adapters/herdr/rendering/terminal-text-image.ts
|
|
1686
|
+
const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
1687
|
+
const emoji = /* @__PURE__ */ new RegExp("^\\p{RGI_Emoji}$", "v");
|
|
1688
|
+
const maxRows = 80;
|
|
1689
|
+
const maxColumns = 160;
|
|
1690
|
+
const maxGraphemes = 160;
|
|
1691
|
+
const maxLineCodeUnits = 1024;
|
|
1692
|
+
const maxUniqueGraphemes = 1024;
|
|
1693
|
+
const cellWidth = 10.8;
|
|
1694
|
+
const shapeWorkerUrl = new URL(`data:text/javascript,${encodeURIComponent(`
|
|
1695
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
1696
|
+
const { Resvg } = await import(workerData.resvgModuleUrl);
|
|
1697
|
+
parentPort.postMessage(new Resvg(workerData.svg, workerData.options).toString());
|
|
1698
|
+
parentPort.close();
|
|
1699
|
+
`)}`);
|
|
1700
|
+
async function renderTerminalTextImage(screen, fontFiles) {
|
|
1701
|
+
const { renderAsync } = await import("@resvg/resvg-js");
|
|
1702
|
+
const glyphs = /* @__PURE__ */ new Map();
|
|
1703
|
+
const allLines = stripVTControlCharacters(screen.text).replace(/[^\P{Cc}\n\t]/gu, "").split("\n");
|
|
1704
|
+
const title = renderLine(stripVTControlCharacters(screen.title).replace(/\p{Cc}/gu, ""), 30, glyphs);
|
|
1705
|
+
const lines = allLines.slice(0, maxRows).map((line, index) => renderLine(line, 96 + index * 24, glyphs));
|
|
1706
|
+
const clipped = screen.truncated || allLines.length > maxRows || title.clipped || lines.some((line) => line.clipped);
|
|
1707
|
+
const width = 1800;
|
|
1708
|
+
const height = 100 + Math.max(1, lines.length) * 24;
|
|
1709
|
+
const label = `Terminal text rendering | ${new Date(screen.at).toISOString()}${clipped ? " | truncated" : ""}`;
|
|
1710
|
+
const body = lines.map((line) => line.svg).join("");
|
|
1711
|
+
return (await renderAsync(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><defs>${[1, 2].map((columns) => `<clipPath id="cell-${columns}"><rect width="${columns * cellWidth}" height="24"/></clipPath>`).join("")}<clipPath id="label"><rect width="1752" height="24"/></clipPath>${await shapeAtlas(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="24"><g fill="#e5e7eb" font-family="monospace" font-size="18" xml:space="preserve">${[...glyphs].map(([glyph, index]) => `<g id="terminal-glyph-${index}"><text x="0" y="20">${escapeXml(glyph)}</text></g>`).join("")}<g id="terminal-label" fill="#94a3b8"><text x="0" y="20">${escapeXml(label)}</text></g></g></svg>`, { font: {
|
|
1712
|
+
loadSystemFonts: !fontFiles?.length,
|
|
1713
|
+
...fontFiles ? { fontFiles: [...fontFiles] } : {},
|
|
1714
|
+
defaultFontFamily: "monospace"
|
|
1715
|
+
} })}</defs><rect width="100%" height="100%" fill="#101827"/>${title.svg}<g transform="translate(24 38)" clip-path="url(#label)"><use href="#terminal-label"/></g>${body}</svg>`, { font: { loadSystemFonts: false } })).asPng();
|
|
1716
|
+
}
|
|
1717
|
+
function shapeAtlas(svg, options) {
|
|
1718
|
+
return new Promise((resolve, reject) => {
|
|
1719
|
+
const worker = new Worker(shapeWorkerUrl, { workerData: {
|
|
1720
|
+
svg,
|
|
1721
|
+
options,
|
|
1722
|
+
resvgModuleUrl: import.meta.resolve("@resvg/resvg-js")
|
|
1723
|
+
} });
|
|
1724
|
+
let settled = false;
|
|
1725
|
+
const deadline = setTimeout(() => finish(/* @__PURE__ */ new Error("Terminal image shaping timed out.")), 3e4);
|
|
1726
|
+
function finish(error, paths) {
|
|
1727
|
+
if (settled) return;
|
|
1728
|
+
settled = true;
|
|
1729
|
+
clearTimeout(deadline);
|
|
1730
|
+
worker.terminate().then(() => {
|
|
1731
|
+
worker.removeAllListeners();
|
|
1732
|
+
if (error) reject(error);
|
|
1733
|
+
else resolve(paths);
|
|
1734
|
+
}, (terminationError) => {
|
|
1735
|
+
worker.removeAllListeners();
|
|
1736
|
+
reject(error ?? terminationError);
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
worker.once("message", (paths) => {
|
|
1740
|
+
if (typeof paths !== "string") finish(/* @__PURE__ */ new Error("Terminal image shaping returned invalid paths."));
|
|
1741
|
+
else finish(void 0, paths);
|
|
1742
|
+
});
|
|
1743
|
+
worker.once("error", (error) => finish(error));
|
|
1744
|
+
worker.once("exit", (code) => finish(/* @__PURE__ */ new Error(`Terminal image shaping exited before replying (${code}).`)));
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
function renderLine(line, y, glyphs) {
|
|
1748
|
+
const nodes = [];
|
|
1749
|
+
let column = 0;
|
|
1750
|
+
let graphemes = 0;
|
|
1751
|
+
let codeUnits = 0;
|
|
1752
|
+
for (const { segment } of segmenter.segment(line)) {
|
|
1753
|
+
const columns = segment === " " ? 4 - column % 4 : graphemeColumns(segment);
|
|
1754
|
+
if (column + columns > maxColumns || graphemes >= maxGraphemes || codeUnits + segment.length > maxLineCodeUnits) return {
|
|
1755
|
+
svg: nodes.join(""),
|
|
1756
|
+
clipped: true
|
|
1757
|
+
};
|
|
1758
|
+
if (segment !== " " && segment !== " ") {
|
|
1759
|
+
let index = glyphs.get(segment);
|
|
1760
|
+
if (index === void 0) {
|
|
1761
|
+
if (glyphs.size >= maxUniqueGraphemes) throw new Error("Terminal image exceeds its unique grapheme budget.");
|
|
1762
|
+
index = glyphs.size;
|
|
1763
|
+
glyphs.set(segment, index);
|
|
1764
|
+
}
|
|
1765
|
+
nodes.push(`<g transform="translate(${24 + column * cellWidth} ${y - 20})" clip-path="url(#cell-${Math.max(1, columns)})"><use href="#terminal-glyph-${index}"/></g>`);
|
|
1766
|
+
}
|
|
1767
|
+
column += columns;
|
|
1768
|
+
graphemes += 1;
|
|
1769
|
+
codeUnits += segment.length;
|
|
1770
|
+
}
|
|
1771
|
+
return {
|
|
1772
|
+
svg: nodes.join(""),
|
|
1773
|
+
clipped: false
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
function graphemeColumns(grapheme) {
|
|
1777
|
+
if (emoji.test(grapheme)) return 2;
|
|
1778
|
+
for (const codePoint of grapheme) if (!/[\p{Mark}\p{Format}]/u.test(codePoint)) return eastAsianWidth(codePoint.codePointAt(0));
|
|
1779
|
+
return 0;
|
|
1780
|
+
}
|
|
1781
|
+
function escapeXml(text) {
|
|
1782
|
+
return text.replace(/[&<>"']/g, (char) => ({
|
|
1783
|
+
"&": "&",
|
|
1784
|
+
"<": "<",
|
|
1785
|
+
">": ">",
|
|
1786
|
+
"\"": """,
|
|
1787
|
+
"'": "'"
|
|
1788
|
+
})[char]);
|
|
1789
|
+
}
|
|
1790
|
+
//#endregion
|
|
1791
|
+
//#region src/bootstrap/herdr-feishu-runtime.ts
|
|
1792
|
+
const QUEUE_LIMIT = 32;
|
|
1793
|
+
const WATCH_INTERVAL_MS = 5e3;
|
|
1794
|
+
const STOP_TIMEOUT_MS = 1e4;
|
|
1795
|
+
/**
|
|
1796
|
+
* Connection-less Herdr bridge core shared by the standalone entrypoint and the
|
|
1797
|
+
* embedded single-WebSocket route. Owns the bounded serial work queue, the
|
|
1798
|
+
* watch loop, every admitted network promise and the claims-store lifetime.
|
|
1799
|
+
*/
|
|
1800
|
+
function createHerdrFeishuBridgeRuntime(options) {
|
|
1801
|
+
let stopped = false;
|
|
1802
|
+
const bridge = createTerminalSessionBridge({
|
|
1803
|
+
sessions: {
|
|
1804
|
+
...options.sessions,
|
|
1805
|
+
prompt: (agent, text) => stopped ? Effect.fail(/* @__PURE__ */ new Error("Herdr runtime is stopped; prompt refused")) : options.sessions.prompt(agent, text)
|
|
1806
|
+
},
|
|
1807
|
+
actors: options.actors,
|
|
1808
|
+
chats: options.chats,
|
|
1809
|
+
writable: options.writable,
|
|
1810
|
+
now: options.now ?? Date.now,
|
|
1811
|
+
newHandle: options.newHandle ?? (() => randomBytes(6).toString("hex")),
|
|
1812
|
+
claim: options.claim,
|
|
1813
|
+
...options.bindings === void 0 ? {} : { bindings: options.bindings },
|
|
1814
|
+
...options.conversation === void 0 ? {} : { conversation: options.conversation }
|
|
1815
|
+
});
|
|
1816
|
+
let pending = 0;
|
|
1817
|
+
let tail = Promise.resolve();
|
|
1818
|
+
let watchTimer;
|
|
1819
|
+
const activeFibers = /* @__PURE__ */ new Set();
|
|
1820
|
+
const activeSends = /* @__PURE__ */ new Set();
|
|
1821
|
+
const activeAdmissions = /* @__PURE__ */ new Set();
|
|
1822
|
+
let scheduledAdmissions = 0;
|
|
1823
|
+
const trackAdmission = (start) => {
|
|
1824
|
+
const admission = Promise.resolve().then(start);
|
|
1825
|
+
const tracked = admission.then(() => void 0, () => void 0);
|
|
1826
|
+
activeAdmissions.add(tracked);
|
|
1827
|
+
tracked.then(() => {
|
|
1828
|
+
activeAdmissions.delete(tracked);
|
|
1829
|
+
});
|
|
1830
|
+
return admission;
|
|
1831
|
+
};
|
|
1832
|
+
const gatedSend = (send) => {
|
|
1833
|
+
if (stopped) return Promise.reject(/* @__PURE__ */ new Error("Herdr runtime is stopped; delivery refused"));
|
|
1834
|
+
const started = send();
|
|
1835
|
+
const tracked = started.then(() => void 0, () => void 0);
|
|
1836
|
+
activeSends.add(tracked);
|
|
1837
|
+
tracked.finally(() => {
|
|
1838
|
+
activeSends.delete(tracked);
|
|
1839
|
+
});
|
|
1840
|
+
return started;
|
|
1841
|
+
};
|
|
1842
|
+
const deliverEffect = (reply, messageId, isCurrent = () => true) => Effect.tryPromise({
|
|
1843
|
+
try: async () => {
|
|
1844
|
+
if (!isCurrent()) return false;
|
|
1845
|
+
const delivery = {
|
|
1846
|
+
sendText: (chatId, text) => isCurrent() ? gatedSend(() => options.delivery.sendText(chatId, text)) : Promise.resolve(),
|
|
1847
|
+
sendImage: (chatId, image) => isCurrent() ? gatedSend(() => options.delivery.sendImage(chatId, image)) : Promise.resolve(),
|
|
1848
|
+
...options.delivery.sendCard === void 0 ? {} : { sendCard: (chatId, card) => isCurrent() ? gatedSend(() => options.delivery.sendCard(chatId, card)) : Promise.resolve() }
|
|
1849
|
+
};
|
|
1850
|
+
try {
|
|
1851
|
+
await deliverTerminalBridgeReply(reply, delivery, (screen) => renderTerminalTextImage(screen, options.fontFiles));
|
|
1852
|
+
} catch (error) {
|
|
1853
|
+
if (!isCurrent()) return false;
|
|
1854
|
+
throw error;
|
|
1855
|
+
}
|
|
1856
|
+
if (!isCurrent()) return false;
|
|
1857
|
+
options.onObservation?.({
|
|
1858
|
+
messageId,
|
|
1859
|
+
type: "replied"
|
|
1860
|
+
});
|
|
1861
|
+
return true;
|
|
1862
|
+
},
|
|
1863
|
+
catch: (error) => error
|
|
1864
|
+
});
|
|
1865
|
+
const enqueue = (work) => {
|
|
1866
|
+
if (stopped || pending >= QUEUE_LIMIT) return false;
|
|
1867
|
+
pending++;
|
|
1868
|
+
tail = tail.then(async () => {
|
|
1869
|
+
if (stopped) {
|
|
1870
|
+
pending--;
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
try {
|
|
1874
|
+
await Effect.runPromise(Effect.gen(function* () {
|
|
1875
|
+
const fiber = yield* Effect.forkDetach(work);
|
|
1876
|
+
activeFibers.add(fiber);
|
|
1877
|
+
try {
|
|
1878
|
+
yield* Fiber.join(fiber);
|
|
1879
|
+
} finally {
|
|
1880
|
+
activeFibers.delete(fiber);
|
|
1881
|
+
}
|
|
1882
|
+
}));
|
|
1883
|
+
} catch {
|
|
1884
|
+
options.onObservation?.({ type: "operation_failed" });
|
|
1885
|
+
} finally {
|
|
1886
|
+
pending--;
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
return true;
|
|
1890
|
+
};
|
|
1891
|
+
const start = async () => {
|
|
1892
|
+
if (stopped) throw new Error("Herdr runtime is stopped; construct a new runtime instead");
|
|
1893
|
+
if (watchTimer !== void 0) return;
|
|
1894
|
+
watchTimer = setInterval(() => {
|
|
1895
|
+
if (pending) return;
|
|
1896
|
+
enqueue(Effect.gen(function* () {
|
|
1897
|
+
for (const notification of yield* bridge.tick()) yield* deliverEffect(notification.reply, "watch", notification.isCurrent).pipe(Effect.tap((delivered) => delivered ? Effect.sync(() => notification.acknowledge()) : Effect.void), Effect.catch(() => Effect.sync(() => {
|
|
1898
|
+
options.onObservation?.({ type: "notification_failed" });
|
|
1899
|
+
})));
|
|
1900
|
+
}));
|
|
1901
|
+
}, WATCH_INTERVAL_MS);
|
|
1902
|
+
};
|
|
1903
|
+
const stopIntake = () => {
|
|
1904
|
+
stopped = true;
|
|
1905
|
+
if (options.stopState !== void 0) options.stopState.stopped = true;
|
|
1906
|
+
if (watchTimer !== void 0) {
|
|
1907
|
+
clearInterval(watchTimer);
|
|
1908
|
+
watchTimer = void 0;
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
const scheduleAdmission = async (start) => {
|
|
1912
|
+
if (stopped) {
|
|
1913
|
+
options.onObservation?.({ type: "admission_refused_stopped" });
|
|
1914
|
+
return "stopped";
|
|
1915
|
+
}
|
|
1916
|
+
if (scheduledAdmissions >= QUEUE_LIMIT) {
|
|
1917
|
+
options.onObservation?.({ type: "admission_refused_full" });
|
|
1918
|
+
return "full";
|
|
1919
|
+
}
|
|
1920
|
+
scheduledAdmissions++;
|
|
1921
|
+
trackAdmission(start).catch((error) => {
|
|
1922
|
+
options.onObservation?.({ type: "admission_failed" });
|
|
1923
|
+
console.error("Herdr background admission failed", error);
|
|
1924
|
+
}).then(() => {
|
|
1925
|
+
scheduledAdmissions--;
|
|
1926
|
+
});
|
|
1927
|
+
return "accepted";
|
|
1928
|
+
};
|
|
1929
|
+
const runAdmission = async (start) => {
|
|
1930
|
+
if (stopped) {
|
|
1931
|
+
options.onObservation?.({ type: "admission_refused_stopped" });
|
|
1932
|
+
return { kind: "stopped" };
|
|
1933
|
+
}
|
|
1934
|
+
return {
|
|
1935
|
+
kind: "accepted",
|
|
1936
|
+
value: await trackAdmission(start)
|
|
1937
|
+
};
|
|
1938
|
+
};
|
|
1939
|
+
let stopPromise;
|
|
1940
|
+
const completeStop = async () => {
|
|
1941
|
+
stopIntake();
|
|
1942
|
+
if (stopPromise) return stopPromise;
|
|
1943
|
+
stopPromise = (async () => {
|
|
1944
|
+
let timeoutHandle;
|
|
1945
|
+
try {
|
|
1946
|
+
await Promise.race([Promise.all([
|
|
1947
|
+
tail.catch(() => void 0),
|
|
1948
|
+
...[...activeFibers].map((fiber) => Effect.runPromise(Fiber.interrupt(fiber)).catch(() => void 0)),
|
|
1949
|
+
...activeAdmissions,
|
|
1950
|
+
...activeSends
|
|
1951
|
+
]), new Promise((_, reject) => {
|
|
1952
|
+
timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error("Herdr runtime stop timed out; the state lock is retained")), options.stopTimeoutMs ?? STOP_TIMEOUT_MS);
|
|
1953
|
+
})]);
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
|
|
1956
|
+
stopPromise = void 0;
|
|
1957
|
+
throw error;
|
|
1958
|
+
}
|
|
1959
|
+
if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
|
|
1960
|
+
await options.closeStores();
|
|
1961
|
+
})();
|
|
1962
|
+
return stopPromise;
|
|
1963
|
+
};
|
|
1964
|
+
return {
|
|
1965
|
+
bridge,
|
|
1966
|
+
submit: async (message) => enqueue(Effect.gen(function* () {
|
|
1967
|
+
const response = yield* bridge.handle(message);
|
|
1968
|
+
if (response !== null) yield* deliverEffect(response, message.id);
|
|
1969
|
+
})),
|
|
1970
|
+
execute: async (entry) => {
|
|
1971
|
+
const outcome = entry.outcome;
|
|
1972
|
+
if (outcome.kind === "done") return enqueue(outcome.result.kind === "reply" ? deliverEffect(outcome.result.reply, entry.message.id) : Effect.void);
|
|
1973
|
+
return enqueue(Effect.gen(function* () {
|
|
1974
|
+
const result = yield* bridge.runAdmitted(outcome.work);
|
|
1975
|
+
if (result.kind === "reply") yield* deliverEffect(result.reply, entry.message.id);
|
|
1976
|
+
}));
|
|
1977
|
+
},
|
|
1978
|
+
reply: async (chatId, text) => {
|
|
1979
|
+
try {
|
|
1980
|
+
await gatedSend(() => options.delivery.sendText(chatId, text));
|
|
1981
|
+
} catch {}
|
|
1982
|
+
},
|
|
1983
|
+
runAdmission,
|
|
1984
|
+
scheduleAdmission,
|
|
1985
|
+
start,
|
|
1986
|
+
stopIntake,
|
|
1987
|
+
completeStop,
|
|
1988
|
+
stop: () => completeStop()
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
//#endregion
|
|
1992
|
+
export { openTerminalBindingsStore as a, classifyFeishuCardOwnership as c, parseFeishuTerminalMessage as d, openTerminalMessageClaims as i, createFeishuTerminalDelivery as l, embeddedHerdrRouteSchema as n, terminalAgentSchema as o, standaloneHerdrOptionsSchema as r, createHerdrSocketClient as s, createHerdrFeishuBridgeRuntime as t, parseFeishuTerminalAction as u };
|
|
1993
|
+
|