scream-code 0.19.3 → 0.19.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{app-NpW56jQ9.mjs → app-JxeaGaIc.mjs} +807 -156
- package/dist/{dispatch-BziMqJ_r.mjs → dispatch-Brb_yK-J.mjs} +94 -34
- package/dist/{dispatch-CB9ZdDBD.mjs → dispatch-Cqw-jMk5.mjs} +1 -1
- package/dist/main.mjs +1 -1
- package/dist/{text-input-dialog-CD4xCwLd.mjs → text-input-dialog-1CeiIQzf.mjs} +1 -1
- package/dist/{text-input-dialog-yvsx17gg.mjs → text-input-dialog-D6PfbOws.mjs} +9 -1
- package/docs/line.md +10 -1
- package/package.json +1 -1
|
@@ -5,8 +5,8 @@ const __filename = __cjsShimFileURLToPath(import.meta.url);
|
|
|
5
5
|
const __dirname = __cjsShimDirname(__filename);
|
|
6
6
|
import "./suppress-sqlite-warning-C2VB0doZ.mjs";
|
|
7
7
|
import { A as sharedMemoStore } from "./src-Cu3sUIJK.mjs";
|
|
8
|
-
import { $
|
|
9
|
-
import { i as t, n as getLocale, r as setLocale } from "./text-input-dialog-
|
|
8
|
+
import { $n as validateFeishuCredentials, A as langFromPath, An as hasEmbeddingModelCache, Ar as resolveGlobalLogPath, At as createThemeStyles, B as getSharedSpeedTracker, Bn as isSendThrottled, Bt as OSC11_RESPONSE, Cn as BUILTIN_SLASH_COMMANDS, Cr as ScreamHarness, D as renderDiffLines, Dn as embeddingProgressLabel, Dr as resolveScreamHome, Dt as isEmptySessionHintDismissed, E as ToolCallComponent, En as setExperimentalFlags, Er as isRealUserPrompt, Et as createMarkdownTheme, F as SkillActivationComponent, Fn as startLineDaemonDetached, Ft as DISABLE_TERMINAL_FOCUS_REPORTING, G as AgentGroupComponent, Gn as parseAesKey, Gt as TERMINAL_FOCUS_OUT, H as lerpGradient, Ht as OSC11_RESPONSE_PREFIX_NO_ESC, I as AssistantMessageComponent, In as stopLineDaemon, It as DISABLE_TERMINAL_THEME_REPORTING, J as getBreathingFrame, Jn as TuiConfigParseError, Jt as safeUsageRatio, K as WelcomeComponent, Kn as replaceTabs, Kt as TERMINAL_THEME_DARK, L as ActivityGroupComponent, Ln as UPLOAD_MEDIA_TYPE, Lt as ENABLE_TERMINAL_FOCUS_REPORTING, M as ThinkingComponent, Mn as PIXEL_PULSE_FRAMES, Mr as isOrphanedToolCallError, Mt as getColorPalette, N as NoticeMessageComponent, Nn as PULSE_WAVE_FRAMES, Nr as SCREAM_ERROR_INFO, Nt as detectTerminalTheme, O as renderDiffLinesClustered, On as embeddingFailureDetail, Or as flushDiagnosticLogs, Ot as isTurnElapsedEnabled, P as StatusMessageComponent, Pn as readLineDaemonStatus, Pt as parseOsc11BackgroundTheme, Qn as feishuFileType, R as charsForTokenBudget, Rn as WeixinApiError, Rt as ENABLE_TERMINAL_THEME_REPORTING, S as readJsonlFile, Sn as buildSkillSlashCommands, Sr as saveCatalogCache, T as UserMessageComponent, Tn as isExperimentalFlagEnabled, Tt as createEditorTheme, U as lerpHex, Un as buildCdnDownloadUrl, Ut as QUERY_TERMINAL_THEME, V as GRADIENT_CYCLE_MS, Vn as validateWeixinToken, Vt as OSC11_RESPONSE_PREFIX, W as BackgroundAgentStatusComponent, Wn as decryptAesEcb, Wt as TERMINAL_FOCUS_IN, X as CompactionComponent, Xn as FeishuClient, Y as resetBreathingClock, Yn as loadTuiConfig, Yt as SubagentSlots, Z as clearGoalState, Zn as FeishuCredentialError, _n as TIP_ROTATION_INTERVAL_MS, _r as CLI_UI_MODE, a as writeTextClipboard, an as appendStreamingArgsPreview, ar as getWeixinCursorFile, at as getModelCycleLevel, b as readUpdateCache, bn as getLlmNotSetMessage, br as DEFAULT_CATALOG_URL, bt as supportsBalance, cn as isTodoItemShape, cr as resolveCustomCommands, d as isExpandable, dn as stringValue, dr as detectShellEnvironment, en as isBusy, er as LineConfigError, f as isPlanExpandable, fn as truncateErrorMessage, fr as getDataDir, ft as handlePlanCommand, gn as SESSION_TIPS, gr as CLI_COMMAND_NAME, hn as MAIN_AGENT_ID, hr as detectInstallSource, i as openUrl, in as appendSessionHeaderHint, ir as getWeixinContextTokensFile, it as changeThinkingLevel, j as CachedContainer, jn as startManualEmbeddingDownload, jr as isScreamError, jt as contrastTextHex, k as highlightLines, kn as getKnowledgeStore, kr as log, kt as toggleEmptySessionHint, l as disposeChildren, ln as parseStreamingArgs, lr as resolveFeishuOptions, m as formatMemoryMemoForInjection, mn as EXIT_CONFIRM_WINDOW_MS, mr as getLogDir, n as clearEvalPanelState, nn as printableChar, nr as getLineConfigPath, o as syncDiagramCapabilityPrompt, on as argsRecord, or as getWeixinStateDir, p as MoonLoader, pr as getInputHistoryFile, q as BREATHE_CYCLE_MS, qn as sanitizeShellOutput, qt as TERMINAL_THEME_LIGHT, rn as STATUS_BULLET, rr as getLinePidFile, rt as toTerminalHyperlink, sn as formatErrorMessage, sr as loadLineConfig, t as dispatchInput, tn as isStreaming, tr as assertSinglePlatformConfig, u as hasDispose, un as serializeToolResultOutput, ur as resolveWeixinOptions, ut as handleFusionPlanCommand, v as selectUpdateTarget, vn as getCtrlCHint, vr as CLI_USER_AGENT_PRODUCT, wn as sortSlashCommands, wr as normalizeWorkDir, wt as resolveThemeSync, x as appendJsonlLine, xn as getNoActiveSessionMessage, xr as fetchCatalog, xt as clearInfoPanelState, y as refreshUpdateCache, yn as getCtrlDHint, yr as PRODUCT_NAME, yt as refreshProviderBalance, z as estimateTokens, zn as WeixinClient, zt as OSC11_QUERY } from "./dispatch-Brb_yK-J.mjs";
|
|
9
|
+
import { i as t, n as getLocale, r as setLocale } from "./text-input-dialog-D6PfbOws.mjs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { createHash, randomInt, randomUUID } from "node:crypto";
|
|
12
12
|
import { mkdir, mkdtemp, open, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -21,14 +21,14 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
21
21
|
import { Command, Option } from "commander";
|
|
22
22
|
import { createInterface } from "node:readline/promises";
|
|
23
23
|
import { createInterface as createInterface$1 } from "node:readline";
|
|
24
|
-
import chalk, { chalkStderr } from "chalk";
|
|
25
24
|
import { CombinedAutocompleteProvider, Container, Editor, HStack, Input, Key, ProcessTerminal, ScrollView, Spacer, Text, TuiAltScreen, VStack, decodeKittyPrintable, deleteAllKittyImages, fuzzyFilter, fuzzyMatch, getCapabilities, isKeyRelease, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@liutod-scream/pi-tui";
|
|
25
|
+
import chalk, { chalkStderr } from "chalk";
|
|
26
26
|
//#region src/cli/build-info.ts
|
|
27
27
|
function optionalBuildString(value) {
|
|
28
28
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
29
29
|
}
|
|
30
30
|
const SCREAM_BUILD_INFO = {
|
|
31
|
-
version: optionalBuildString("0.19.
|
|
31
|
+
version: optionalBuildString("0.19.5"),
|
|
32
32
|
channel: optionalBuildString(""),
|
|
33
33
|
commit: optionalBuildString(""),
|
|
34
34
|
buildTarget: optionalBuildString("darwin-arm64")
|
|
@@ -719,6 +719,31 @@ var FeishuPlatform = class {
|
|
|
719
719
|
await this.client.sendToChat(chatId, "file", JSON.stringify({ file_key: fileKey }));
|
|
720
720
|
return kind === "voice" ? "(飞书按文件发送,语音气泡仅微信支持)" : "";
|
|
721
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* Sends text to the chat that owns this session with no inbound message to
|
|
724
|
+
* reply to — the scheduled-task path. Lark only needs the chat id, which is
|
|
725
|
+
* cached per session on every inbound message, so a fire at 3am still lands.
|
|
726
|
+
*/
|
|
727
|
+
async sendProactive(sessionKey, text) {
|
|
728
|
+
const body = text.trim();
|
|
729
|
+
if (body === "") return "no-target";
|
|
730
|
+
const chatId = this.chatBySession.get(sessionKey);
|
|
731
|
+
if (chatId === void 0) {
|
|
732
|
+
log.warn("line-feishu: no chat recorded for proactive send", { sessionKey });
|
|
733
|
+
return "no-target";
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const outbound = buildReplyContent(truncateForLine(body));
|
|
737
|
+
await this.client.sendToChat(chatId, outbound.msgType, outbound.body);
|
|
738
|
+
return "sent";
|
|
739
|
+
} catch (error) {
|
|
740
|
+
log.warn("line-feishu: proactive send failed", {
|
|
741
|
+
sessionKey,
|
|
742
|
+
error: String(error)
|
|
743
|
+
});
|
|
744
|
+
return "failed";
|
|
745
|
+
}
|
|
746
|
+
}
|
|
722
747
|
/** Cancels pending batch timers (daemon shutdown). */
|
|
723
748
|
stop() {
|
|
724
749
|
for (const batch of this.pendingImages.values()) clearTimeout(batch.timer);
|
|
@@ -1667,12 +1692,66 @@ function buildChannelPrompt(platform) {
|
|
|
1667
1692
|
].join("\n");
|
|
1668
1693
|
}
|
|
1669
1694
|
//#endregion
|
|
1695
|
+
//#region src/line/cron-commands.ts
|
|
1696
|
+
const PROMPT_PREVIEW_CHARS = 60;
|
|
1697
|
+
/** `add <min hour dom mon dow> <prompt…>` → the two fields the RPC wants. */
|
|
1698
|
+
function parseCronAdd(parts) {
|
|
1699
|
+
const rest = [...parts];
|
|
1700
|
+
let silent = false;
|
|
1701
|
+
let mute = false;
|
|
1702
|
+
while (rest[0] === "--silent" || rest[0] === "--mute") if (rest.shift() === "--silent") silent = true;
|
|
1703
|
+
else mute = true;
|
|
1704
|
+
if (rest.length < 6) return null;
|
|
1705
|
+
const cron = rest.slice(0, 5).join(" ");
|
|
1706
|
+
const prompt = rest.slice(5).join(" ").trim();
|
|
1707
|
+
if (prompt === "") return null;
|
|
1708
|
+
return {
|
|
1709
|
+
cron,
|
|
1710
|
+
prompt,
|
|
1711
|
+
...silent ? { silent: true } : {},
|
|
1712
|
+
...mute ? { mute: true } : {}
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
function describeNext(task, now) {
|
|
1716
|
+
if (task.nextFireAt === null) return task.recurring ? "不再触发" : "已触发";
|
|
1717
|
+
const minutes = Math.max(0, Math.round((task.nextFireAt - now) / 6e4));
|
|
1718
|
+
if (minutes <= 1) return "即将触发";
|
|
1719
|
+
return `下次 ${minutes} 分钟后`;
|
|
1720
|
+
}
|
|
1721
|
+
function shorten$1(text) {
|
|
1722
|
+
const chars = Array.from(text);
|
|
1723
|
+
return chars.length > PROMPT_PREVIEW_CHARS ? `${chars.slice(0, PROMPT_PREVIEW_CHARS - 1).join("")}…` : text;
|
|
1724
|
+
}
|
|
1725
|
+
/** Listing for `/cron` / `/cron list`; `now` is injectable for tests. */
|
|
1726
|
+
function formatCronList(tasks, now = Date.now()) {
|
|
1727
|
+
if (tasks.length === 0) return "还没有定时任务。\n/cron add <分 时 日 月 周> <要做的事>";
|
|
1728
|
+
const lines = [`定时任务(${tasks.length})`];
|
|
1729
|
+
for (const task of tasks) {
|
|
1730
|
+
const flags = [
|
|
1731
|
+
describeNext(task, now),
|
|
1732
|
+
task.stale ? "已过期" : "",
|
|
1733
|
+
task.recurring ? "" : "一次性",
|
|
1734
|
+
task.silent === true ? "静默" : "",
|
|
1735
|
+
task.mute === true ? "静音" : ""
|
|
1736
|
+
].filter((part) => part !== "").join(" · ");
|
|
1737
|
+
lines.push(`${task.id} ${task.cron} ${flags}`);
|
|
1738
|
+
lines.push(` ${shorten$1(task.prompt)}`);
|
|
1739
|
+
if (task.lastError !== void 0 && task.lastError !== null) lines.push(` 上次失败:${shorten$1(task.lastError)}`);
|
|
1740
|
+
}
|
|
1741
|
+
lines.push("回复 /cron rm <id> 删除。");
|
|
1742
|
+
return lines.join("\n");
|
|
1743
|
+
}
|
|
1744
|
+
const CRON_USAGE = "用法:/cron list · /cron add [--silent|--mute] <分 时 日 月 周> <要做的事> · /cron rm <id>";
|
|
1745
|
+
//#endregion
|
|
1670
1746
|
//#region src/line/command-router.ts
|
|
1671
1747
|
const HELP_TEXT = [
|
|
1672
1748
|
"可用命令:",
|
|
1673
1749
|
"/status 查看当前模型、权限与上下文占用",
|
|
1674
1750
|
"/model <名称> 切换模型(不带参数查看当前值)",
|
|
1675
1751
|
"/compact 压缩上下文",
|
|
1752
|
+
"/stop 停止正在运行的任务",
|
|
1753
|
+
"/cron 查看定时任务(add/rm 见用法)",
|
|
1754
|
+
"/cancel 停止并开始新会话",
|
|
1676
1755
|
"/new 开启新会话",
|
|
1677
1756
|
"/help 显示本帮助",
|
|
1678
1757
|
"",
|
|
@@ -1686,7 +1765,7 @@ const DESKTOP_ONLY = new Set([
|
|
|
1686
1765
|
"/login",
|
|
1687
1766
|
"/image"
|
|
1688
1767
|
]);
|
|
1689
|
-
const BUSY_TEXT = "
|
|
1768
|
+
const BUSY_TEXT = "当前有任务正在处理中:回复 /stop 可中止,或等这一轮结束后再发该命令。";
|
|
1690
1769
|
/** Built-ins plus the user's configured commands. */
|
|
1691
1770
|
function helpText(commands) {
|
|
1692
1771
|
if (commands.length === 0) return HELP_TEXT;
|
|
@@ -1738,6 +1817,71 @@ async function runLineCommand$1(rawText, deps) {
|
|
|
1738
1817
|
handled: true,
|
|
1739
1818
|
text: await modelText(deps, arg)
|
|
1740
1819
|
};
|
|
1820
|
+
case "/stop":
|
|
1821
|
+
if (!(deps.stopTurn?.() ?? false)) return {
|
|
1822
|
+
handled: true,
|
|
1823
|
+
text: "当前没有正在运行的任务。"
|
|
1824
|
+
};
|
|
1825
|
+
return {
|
|
1826
|
+
handled: true,
|
|
1827
|
+
text: "已停止当前任务。"
|
|
1828
|
+
};
|
|
1829
|
+
case "/cron": {
|
|
1830
|
+
let session;
|
|
1831
|
+
try {
|
|
1832
|
+
session = await deps.getSession();
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
return {
|
|
1835
|
+
handled: true,
|
|
1836
|
+
text: `读取会话失败:${describeError(error)}`
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
const tokens = arg.trim() === "" ? [] : arg.trim().split(/\s+/);
|
|
1840
|
+
const sub = tokens[0];
|
|
1841
|
+
const parts = tokens.slice(1);
|
|
1842
|
+
try {
|
|
1843
|
+
if (sub === void 0 || sub === "list") return {
|
|
1844
|
+
handled: true,
|
|
1845
|
+
text: formatCronList(await session.listCronTasks())
|
|
1846
|
+
};
|
|
1847
|
+
if (sub === "add") {
|
|
1848
|
+
const parsed = parseCronAdd(parts);
|
|
1849
|
+
if (parsed === null) return {
|
|
1850
|
+
handled: true,
|
|
1851
|
+
text: CRON_USAGE
|
|
1852
|
+
};
|
|
1853
|
+
const task = await session.createCronTask(parsed);
|
|
1854
|
+
return {
|
|
1855
|
+
handled: true,
|
|
1856
|
+
text: `已创建定时任务 ${task.cron}:\n${task.prompt}`
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
if (sub === "rm" || sub === "remove") {
|
|
1860
|
+
if (parts.length === 0) return {
|
|
1861
|
+
handled: true,
|
|
1862
|
+
text: CRON_USAGE
|
|
1863
|
+
};
|
|
1864
|
+
const removed = await session.removeCronTasks(parts);
|
|
1865
|
+
return {
|
|
1866
|
+
handled: true,
|
|
1867
|
+
text: removed.length === 0 ? "没有找到该任务。" : `已删除 ${removed.length} 个任务。`
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
return {
|
|
1871
|
+
handled: true,
|
|
1872
|
+
text: CRON_USAGE
|
|
1873
|
+
};
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
return {
|
|
1876
|
+
handled: true,
|
|
1877
|
+
text: `定时任务操作失败:${describeError(error)}`
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
case "/cancel": return {
|
|
1882
|
+
handled: true,
|
|
1883
|
+
text: await deps.cancelSession?.() ?? false ? "已停止当前任务并开始新会话。" : "已开始新会话。"
|
|
1884
|
+
};
|
|
1741
1885
|
case "/compact":
|
|
1742
1886
|
if (deps.isBusy()) return {
|
|
1743
1887
|
handled: true,
|
|
@@ -1813,7 +1957,8 @@ async function statusText$1(deps) {
|
|
|
1813
1957
|
`模型:${status.model ?? (deps.defaultModel || "未知")}`,
|
|
1814
1958
|
`模式:${modes.join(" · ")}`,
|
|
1815
1959
|
`上下文:${percent}(${formatTokens(status.contextTokens)}/${formatTokens(status.maxContextTokens)})`,
|
|
1816
|
-
`思考级别:${status.thinkingLevel}
|
|
1960
|
+
`思考级别:${status.thinkingLevel}`,
|
|
1961
|
+
...deps.voiceStatus === void 0 || deps.voiceStatus === null ? [] : [deps.voiceStatus]
|
|
1817
1962
|
].join("\n");
|
|
1818
1963
|
} catch (error) {
|
|
1819
1964
|
return `读取状态失败:${describeError(error)}`;
|
|
@@ -1846,19 +1991,174 @@ function expandPrompt(template, arg) {
|
|
|
1846
1991
|
return arg.length > 0 ? `${template}\n\n${arg}` : template;
|
|
1847
1992
|
}
|
|
1848
1993
|
//#endregion
|
|
1849
|
-
//#region src/line/
|
|
1994
|
+
//#region src/line/voice-support.ts
|
|
1850
1995
|
/**
|
|
1851
|
-
*
|
|
1996
|
+
* Startup capability probe for outbound voice.
|
|
1852
1997
|
*
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
1857
|
-
*
|
|
1858
|
-
*
|
|
1998
|
+
* Voice sending is the one path that depends on the host's ffmpeg being built
|
|
1999
|
+
* with an AMR-NB *encoder* — plenty of package-manager builds ship the decoder
|
|
2000
|
+
* only. Discovering that at send time turns every voice reply into a fallback
|
|
2001
|
+
* file plus an apology, so the daemon probes once at startup and reports the
|
|
2002
|
+
* answer in `/status`.
|
|
2003
|
+
*
|
|
2004
|
+
* The probe never throws: a failure to answer is `unknown`, which is treated as
|
|
2005
|
+
* "try anyway" rather than "unavailable".
|
|
1859
2006
|
*/
|
|
1860
|
-
const
|
|
1861
|
-
const
|
|
2007
|
+
const execFileAsync$2 = promisify(execFile);
|
|
2008
|
+
const PROBE_TIMEOUT_MS = 5e3;
|
|
2009
|
+
/** Parses `ffmpeg -encoders` output for an AMR-NB encoder. */
|
|
2010
|
+
function parseAmrEncoderListing(stdout) {
|
|
2011
|
+
return stdout.split(/\r?\n/).some((line) => /\bamr_nb\b|\blibopencore_amrnb\b/i.test(line) && /^\s*A/.test(line));
|
|
2012
|
+
}
|
|
2013
|
+
/** One-shot probe; callers cache the result (ffmpeg does not appear mid-run). */
|
|
2014
|
+
async function probeVoiceSupport() {
|
|
2015
|
+
try {
|
|
2016
|
+
const { stdout } = await execFileAsync$2("ffmpeg", ["-hide_banner", "-encoders"], {
|
|
2017
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
2018
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
2019
|
+
windowsHide: true
|
|
2020
|
+
});
|
|
2021
|
+
return parseAmrEncoderListing(stdout) ? "ready" : "no-encoder";
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
return error.code === "ENOENT" ? "no-ffmpeg" : "unknown";
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
/** One-line description for `/status`; `null` when no probe has run yet. */
|
|
2027
|
+
function describeVoiceSupport(support) {
|
|
2028
|
+
switch (support) {
|
|
2029
|
+
case "ready": return "语音:可用(AMR-NB)";
|
|
2030
|
+
case "no-ffmpeg": return "语音:不可用(未找到 ffmpeg,将改发文件)";
|
|
2031
|
+
case "no-encoder": return "语音:不可用(ffmpeg 缺少 AMR-NB 编码器,将改发文件)";
|
|
2032
|
+
case "unknown": return "语音:未确认(ffmpeg 探测失败,发送时再判断)";
|
|
2033
|
+
case null: return null;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
//#endregion
|
|
2037
|
+
//#region src/tui/utils/child-request-notice.ts
|
|
2038
|
+
const CHILD_REQUEST_KINDS = [
|
|
2039
|
+
"info",
|
|
2040
|
+
"handoff",
|
|
2041
|
+
"escalate"
|
|
2042
|
+
];
|
|
2043
|
+
/**
|
|
2044
|
+
* Longest previewed value kept on one row. The kernel allows 8192 chars
|
|
2045
|
+
* (`contact-parent.ts`); the ticker is one terminal line, so anything longer is
|
|
2046
|
+
* cut here and the full text remains reachable via the agent card.
|
|
2047
|
+
*/
|
|
2048
|
+
const TEXT_PREVIEW = 200;
|
|
2049
|
+
/**
|
|
2050
|
+
* Collapse the request onto previewable single-paragraph form.
|
|
2051
|
+
*
|
|
2052
|
+
* The text comes from a child agent's model output, i.e. untrusted input that
|
|
2053
|
+
* is about to be written into a terminal row: control sequences (CSI/OSC/DCS —
|
|
2054
|
+
* screen clears, cursor moves, OSC 52 clipboard writes) are stripped first, the
|
|
2055
|
+
* same way shell output is sanitized elsewhere. The cap counts code points, not
|
|
2056
|
+
* UTF-16 units, so a surrogate pair is never split into a replacement glyph.
|
|
2057
|
+
*/
|
|
2058
|
+
function previewText(value) {
|
|
2059
|
+
const flattened = sanitizeShellOutput(value).replaceAll(/\s*\n\s*/g, " ").trim();
|
|
2060
|
+
const codePoints = Array.from(flattened);
|
|
2061
|
+
return codePoints.length > TEXT_PREVIEW ? `${codePoints.slice(0, TEXT_PREVIEW).join("")}…` : flattened;
|
|
2062
|
+
}
|
|
2063
|
+
function kindOf(raw) {
|
|
2064
|
+
return typeof raw === "string" && CHILD_REQUEST_KINDS.includes(raw) ? raw : "info";
|
|
2065
|
+
}
|
|
2066
|
+
function textOf(raw) {
|
|
2067
|
+
if (typeof raw !== "string") return void 0;
|
|
2068
|
+
const trimmed = raw.trim();
|
|
2069
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Live producer: the delivery event the kernel emits once a request landed in
|
|
2073
|
+
* the parent's turn. Events cross the wire as untyped JSON, so every field is
|
|
2074
|
+
* re-checked here rather than trusted from the declared type.
|
|
2075
|
+
*/
|
|
2076
|
+
function childRequestFieldsFromEvent(event) {
|
|
2077
|
+
const record = event;
|
|
2078
|
+
const message = textOf(record["message"]);
|
|
2079
|
+
if (message === void 0) return null;
|
|
2080
|
+
const listOf = (key) => {
|
|
2081
|
+
const raw = record[key];
|
|
2082
|
+
if (!Array.isArray(raw)) return void 0;
|
|
2083
|
+
const items = raw.map((item) => textOf(item)).filter((item) => item !== void 0);
|
|
2084
|
+
return items.length > 0 ? items : void 0;
|
|
2085
|
+
};
|
|
2086
|
+
return {
|
|
2087
|
+
requestType: kindOf(record["requestType"]),
|
|
2088
|
+
message,
|
|
2089
|
+
needs: textOf(record["needs"]),
|
|
2090
|
+
expecting: textOf(record["expecting"]),
|
|
2091
|
+
artifacts: listOf("artifacts"),
|
|
2092
|
+
evidence: listOf("evidence"),
|
|
2093
|
+
missing: listOf("missing")
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
//#endregion
|
|
2097
|
+
//#region src/tui/utils/cron-chip.ts
|
|
2098
|
+
/**
|
|
2099
|
+
* Scheduled-task reminder → one-line status chip.
|
|
2100
|
+
*
|
|
2101
|
+
* A fired cron task already owns a transcript block (`CronMessageComponent`) and
|
|
2102
|
+
* a turn. The status bar adds the *live* signal: the reminder is time-sensitive,
|
|
2103
|
+
* so it must be visible above the editor for a few seconds even when the user is
|
|
2104
|
+
* looking at the input box, not the transcript.
|
|
2105
|
+
*
|
|
2106
|
+
* Copy follows the shared chip grammar `<symbol> <label>:<value>` — the symbol
|
|
2107
|
+
* lives in the copy, the colour comes from the caller (`accent` for a normal
|
|
2108
|
+
* fire, `warning` for a stale/overdue one).
|
|
2109
|
+
*/
|
|
2110
|
+
/** Longest task prompt shown on the row. The full text stays in the transcript
|
|
2111
|
+
* block, so the chip only needs enough words to recognise the task. */
|
|
2112
|
+
const CRON_PREVIEW_CHARS = 40;
|
|
2113
|
+
function shorten(value, max) {
|
|
2114
|
+
const codePoints = Array.from(value);
|
|
2115
|
+
return codePoints.length > max ? `${codePoints.slice(0, max - 1).join("")}…` : value;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Build the chip for one `cron.fired` delivery.
|
|
2119
|
+
*
|
|
2120
|
+
* `previewText` runs first: the prompt is model/tool-authored text about to be
|
|
2121
|
+
* written into a terminal row, so control sequences are stripped and newlines
|
|
2122
|
+
* collapse before the length cap is applied.
|
|
2123
|
+
*
|
|
2124
|
+
* Precedence, most notable first:
|
|
2125
|
+
* stale (7-day cutoff) → coalesced (missed theoretical fires) → one-shot →
|
|
2126
|
+
* plain recurring fire.
|
|
2127
|
+
*/
|
|
2128
|
+
function buildCronReminderChip(event) {
|
|
2129
|
+
const origin = event.origin;
|
|
2130
|
+
const preview = shorten(previewText(event.prompt), CRON_PREVIEW_CHARS) || origin.cron;
|
|
2131
|
+
if (origin.stale) return {
|
|
2132
|
+
text: t("status.cron_stale", { preview }),
|
|
2133
|
+
tone: "missed"
|
|
2134
|
+
};
|
|
2135
|
+
if (origin.coalescedCount > 1) return {
|
|
2136
|
+
text: t("status.cron_fired_coalesced", {
|
|
2137
|
+
count: String(origin.coalescedCount),
|
|
2138
|
+
preview
|
|
2139
|
+
}),
|
|
2140
|
+
tone: "fired"
|
|
2141
|
+
};
|
|
2142
|
+
if (!origin.recurring) return {
|
|
2143
|
+
text: t("status.cron_once", { preview }),
|
|
2144
|
+
tone: "fired"
|
|
2145
|
+
};
|
|
2146
|
+
return {
|
|
2147
|
+
text: t("status.cron_fired", { preview }),
|
|
2148
|
+
tone: "fired"
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Chat-side notice for a fired task that finished without any reply text — an
|
|
2153
|
+
* error swallowed by the agent, or a turn the model ended silently. The chat
|
|
2154
|
+
* should hear *something*; silence is the one outcome a reminder must not have.
|
|
2155
|
+
*/
|
|
2156
|
+
function buildCronFailureNotice(event) {
|
|
2157
|
+
const origin = event.origin;
|
|
2158
|
+
return t("status.cron_empty", { preview: shorten(previewText(event.prompt), CRON_PREVIEW_CHARS) || origin.cron });
|
|
2159
|
+
}
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region src/line/turn-collector.ts
|
|
1862
2162
|
/**
|
|
1863
2163
|
* Collects the final content output of one turn from the session event
|
|
1864
2164
|
* stream (main agent only).
|
|
@@ -1912,8 +2212,124 @@ var LineTurnCollector = class {
|
|
|
1912
2212
|
}
|
|
1913
2213
|
}
|
|
1914
2214
|
};
|
|
2215
|
+
//#endregion
|
|
2216
|
+
//#region src/line/cron-relay.ts
|
|
2217
|
+
/**
|
|
2218
|
+
* Scheduled-task relay.
|
|
2219
|
+
*
|
|
2220
|
+
* A fired cron task runs *inside* the session, not through `submit()`, so its
|
|
2221
|
+
* turn carries no inbound context for the platform to reply to. This class
|
|
2222
|
+
* turns that turn into a delivery: announce the fire, collect the final text,
|
|
2223
|
+
* hand it to the caller's `send`.
|
|
2224
|
+
*
|
|
2225
|
+
* Two rules keep it from double-posting:
|
|
2226
|
+
* - a fire that lands while a user turn is running is dropped — `steer()` has
|
|
2227
|
+
* already merged it into that turn, whose reply reaches the chat anyway;
|
|
2228
|
+
* - one forward at a time per session; a second fire during collection is
|
|
2229
|
+
* skipped rather than interleaved.
|
|
2230
|
+
*/
|
|
2231
|
+
/** A fired turn that never reports its end must not block later fires. */
|
|
2232
|
+
const DEFAULT_COLLECT_TIMEOUT_MS = 15 * 6e4;
|
|
2233
|
+
var CronRelay = class {
|
|
2234
|
+
options;
|
|
2235
|
+
collector;
|
|
2236
|
+
guard;
|
|
2237
|
+
constructor(options) {
|
|
2238
|
+
this.options = options;
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* A scheduled task fired; `notice` is the one-liner shown before the result.
|
|
2242
|
+
*
|
|
2243
|
+
* `pendingStart` is the `turn.started` event the core emits *before* the
|
|
2244
|
+
* `cron.fired` event (`steer()` launches the turn synchronously). Without
|
|
2245
|
+
* replaying it the collector would ignore every delta of that turn and never
|
|
2246
|
+
* settle, so no result would ever be delivered.
|
|
2247
|
+
*/
|
|
2248
|
+
onFire(notice, pendingStart, failureNotice) {
|
|
2249
|
+
if (this.options.isBusy()) return;
|
|
2250
|
+
if (this.collector !== void 0) {
|
|
2251
|
+
log.warn("line: cron fire ignored, previous forward still collecting");
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
const collector = new LineTurnCollector(() => {
|
|
2255
|
+
this.clearGuard();
|
|
2256
|
+
this.collector = void 0;
|
|
2257
|
+
this.deliver(collector, failureNotice);
|
|
2258
|
+
});
|
|
2259
|
+
this.collector = collector;
|
|
2260
|
+
this.guard = setTimeout(() => {
|
|
2261
|
+
if (this.collector !== collector) return;
|
|
2262
|
+
this.collector = void 0;
|
|
2263
|
+
log.warn("line: scheduled task turn produced no end event", { timeoutMs: this.options.collectTimeoutMs ?? DEFAULT_COLLECT_TIMEOUT_MS });
|
|
2264
|
+
}, this.options.collectTimeoutMs ?? DEFAULT_COLLECT_TIMEOUT_MS);
|
|
2265
|
+
this.guard.unref?.();
|
|
2266
|
+
if (pendingStart !== void 0) collector.handleEvent(pendingStart);
|
|
2267
|
+
if (notice !== null) this.options.send(notice).then((result) => {
|
|
2268
|
+
if (result !== "sent") log.warn("line: cron notice not delivered", { result });
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
/** True while a fired task's turn is being collected. */
|
|
2272
|
+
isCollecting() {
|
|
2273
|
+
return this.collector !== void 0;
|
|
2274
|
+
}
|
|
2275
|
+
/** Drops any in-flight collection (session reset / shutdown). */
|
|
2276
|
+
dispose() {
|
|
2277
|
+
this.clearGuard();
|
|
2278
|
+
this.collector = void 0;
|
|
2279
|
+
}
|
|
2280
|
+
clearGuard() {
|
|
2281
|
+
if (this.guard === void 0) return;
|
|
2282
|
+
clearTimeout(this.guard);
|
|
2283
|
+
this.guard = void 0;
|
|
2284
|
+
}
|
|
2285
|
+
/** Every other event of the session; ignored unless a fire is being collected. */
|
|
2286
|
+
onEvent(event) {
|
|
2287
|
+
if (this.collector === void 0) return;
|
|
2288
|
+
if (this.options.isBusy()) {
|
|
2289
|
+
this.clearGuard();
|
|
2290
|
+
this.collector = void 0;
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
this.collector.handleEvent(event);
|
|
2294
|
+
}
|
|
2295
|
+
async deliver(collector, failureNotice) {
|
|
2296
|
+
if (this.options.isBusy()) return;
|
|
2297
|
+
const text = collector.text.trim();
|
|
2298
|
+
if (text === "") {
|
|
2299
|
+
log.warn("line: scheduled task produced no reply text", { ended: collector.ended });
|
|
2300
|
+
if (failureNotice !== void 0) {
|
|
2301
|
+
const result = await this.options.send(failureNotice);
|
|
2302
|
+
if (result !== "sent") log.warn("line: cron failure notice not delivered", { result });
|
|
2303
|
+
}
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
const result = await this.options.send(text);
|
|
2307
|
+
if (result !== "sent") log.warn("line: cron result not delivered", { result });
|
|
2308
|
+
}
|
|
2309
|
+
};
|
|
2310
|
+
//#endregion
|
|
2311
|
+
//#region src/line/session-driver.ts
|
|
2312
|
+
/**
|
|
2313
|
+
* Line session driver — maps Feishu chats to scream agent sessions.
|
|
2314
|
+
*
|
|
2315
|
+
* Uses the same headless layer as `scream stream-json` (`ScreamHarness`),
|
|
2316
|
+
* including the create/resume/repair session lifecycle (ported from
|
|
2317
|
+
* `run-stream-json.ts` L659-726) and the approval-handler mapping. Turns are
|
|
2318
|
+
* serialized per session (one FIFO chain each) and run in parallel across
|
|
2319
|
+
* sessions; a bounded queue rejects new work with a friendly busy reply
|
|
2320
|
+
* instead of growing unbounded.
|
|
2321
|
+
*/
|
|
2322
|
+
const MAX_QUEUE_PER_SESSION = 3;
|
|
2323
|
+
const ORPHAN_RESET_NOTICE = "会话已自动重置,请重新发送你的消息。";
|
|
1915
2324
|
/** A turn that never settles is cancelled after this long (product default). */
|
|
1916
2325
|
const TURN_TIMEOUT_MS = 900 * 1e3;
|
|
2326
|
+
/**
|
|
2327
|
+
* `/stop` interrupts in-process. A tool that ignores the abort would otherwise
|
|
2328
|
+
* keep the chat busy until the 15-minute cap, so after this window the live
|
|
2329
|
+
* session is closed: history stays on disk and the next message resumes the same
|
|
2330
|
+
* session id — the same end result as tearing down the agent process.
|
|
2331
|
+
*/
|
|
2332
|
+
const STOP_ESCALATION_MS = 1e4;
|
|
1917
2333
|
const TURN_TIMEOUT_TEXT = "本轮执行超时(15 分钟),已取消。请重试或拆分任务。";
|
|
1918
2334
|
/** `exec`-type custom commands are bounded like any other IM command. */
|
|
1919
2335
|
const CUSTOM_COMMAND_TIMEOUT_MS = 3e4;
|
|
@@ -1939,12 +2355,31 @@ var LineSessionDriver = class {
|
|
|
1939
2355
|
promptedSessions = /* @__PURE__ */ new WeakSet();
|
|
1940
2356
|
/** sessionKey → platform sender for outbound media (registered by the daemon). */
|
|
1941
2357
|
mediaSenders = /* @__PURE__ */ new Map();
|
|
2358
|
+
/** Fallback sender resolved from the platform's own target cache, so a
|
|
2359
|
+
* scheduled-task turn can still send media after a reset or restart. */
|
|
2360
|
+
mediaSenderResolver;
|
|
1942
2361
|
/** Session id → sessionKey, so a host tool call can find its channel. */
|
|
1943
2362
|
sessionKeyBySessionId = /* @__PURE__ */ new Map();
|
|
1944
2363
|
/** Chats currently executing `/new`; a turn must not start meanwhile. */
|
|
1945
2364
|
resetting = /* @__PURE__ */ new Set();
|
|
1946
|
-
/**
|
|
1947
|
-
|
|
2365
|
+
/** The turn executing right now per chat, so `/stop` has something to abort.
|
|
2366
|
+
* Chats run in parallel, so this is keyed by session — a single slot would
|
|
2367
|
+
* let one chat's turn hide another's from `/stop`. */
|
|
2368
|
+
activeTurns = /* @__PURE__ */ new Map();
|
|
2369
|
+
/** Bumped by `/stop`: turns queued before the bump are dropped instead of
|
|
2370
|
+
* running. "Stop" has to mean the messages I just sent, not only the one
|
|
2371
|
+
* that happens to be executing. */
|
|
2372
|
+
stopEpoch = /* @__PURE__ */ new Map();
|
|
2373
|
+
/** Escalation timers for a `/stop` whose turn never settles, per chat. */
|
|
2374
|
+
stopEscalations = /* @__PURE__ */ new Map();
|
|
2375
|
+
/** Injected by the daemon: sends to a chat with no inbound message to reply
|
|
2376
|
+
* to. Undefined when no platform supports proactive delivery. */
|
|
2377
|
+
proactiveSender;
|
|
2378
|
+
/** One relay + event subscription per live session, so scheduled tasks can
|
|
2379
|
+
* reach the chat. Replaced on session reset, dropped on close. */
|
|
2380
|
+
cronForwarders = /* @__PURE__ */ new Map();
|
|
2381
|
+
/** Cached ffmpeg capability answer (see `probeVoice`). */
|
|
2382
|
+
voiceSupport = null;
|
|
1948
2383
|
/** In-flight creations, so a command and a turn cannot create two sessions. */
|
|
1949
2384
|
sessionCreations = /* @__PURE__ */ new Map();
|
|
1950
2385
|
/** Per-chat lifecycle mutex: session creation must never interleave a reset. */
|
|
@@ -1966,6 +2401,100 @@ var LineSessionDriver = class {
|
|
|
1966
2401
|
this.harness = harness;
|
|
1967
2402
|
}
|
|
1968
2403
|
/**
|
|
2404
|
+
* Probes the host for an AMR-NB encoder once. Called by the daemon at startup
|
|
2405
|
+
* so `/status` can warn before the first voice reply falls back to a file.
|
|
2406
|
+
* Never throws.
|
|
2407
|
+
*/
|
|
2408
|
+
async probeVoice() {
|
|
2409
|
+
try {
|
|
2410
|
+
this.voiceSupport = await probeVoiceSupport();
|
|
2411
|
+
if (describeVoiceSupport(this.voiceSupport) !== null) log.info("line: voice capability", { support: this.voiceSupport });
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
this.voiceSupport = "unknown";
|
|
2414
|
+
log.warn("line: voice probe failed", { error: String(error) });
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* `/stop`: aborts the turn that is running now. The cancel itself happens
|
|
2419
|
+
* inside `runTurn` so the queue unwinds in one place; this only requests it.
|
|
2420
|
+
*/
|
|
2421
|
+
stopActiveTurn(sessionKey) {
|
|
2422
|
+
const running = this.activeTurns.get(sessionKey);
|
|
2423
|
+
const pending = this.queues.get(sessionKey)?.pending ?? 0;
|
|
2424
|
+
const forwarder = this.cronForwarders.get(sessionKey);
|
|
2425
|
+
const forwarding = forwarder?.relay.isCollecting() ?? false;
|
|
2426
|
+
const cronTurn = forwarder?.isCronTurnRunning() ?? false;
|
|
2427
|
+
if (running === void 0 && pending === 0 && !forwarding && !cronTurn) return false;
|
|
2428
|
+
this.stopEpoch.set(sessionKey, (this.stopEpoch.get(sessionKey) ?? 0) + 1);
|
|
2429
|
+
if (running !== void 0) {
|
|
2430
|
+
running.stop();
|
|
2431
|
+
this.armStopEscalation(sessionKey);
|
|
2432
|
+
}
|
|
2433
|
+
if (forwarding || cronTurn) {
|
|
2434
|
+
forwarder?.relay.dispose();
|
|
2435
|
+
this.sessions.get(sessionKey)?.cancel().catch(() => {});
|
|
2436
|
+
log.info("line: dropped a scheduled-task turn", { sessionKey });
|
|
2437
|
+
}
|
|
2438
|
+
log.info("line: stop requested", {
|
|
2439
|
+
sessionKey,
|
|
2440
|
+
running: running !== void 0,
|
|
2441
|
+
pending
|
|
2442
|
+
});
|
|
2443
|
+
return true;
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* `/cancel`: stop whatever runs and start a clean session. Falls back to
|
|
2447
|
+
* closing the live session when a hung tool refuses to settle, then resets.
|
|
2448
|
+
*/
|
|
2449
|
+
async cancelSession(sessionKey) {
|
|
2450
|
+
const stopped = this.stopActiveTurn(sessionKey);
|
|
2451
|
+
const timeout = new Promise((resolve) => {
|
|
2452
|
+
setTimeout(() => {
|
|
2453
|
+
resolve("timeout");
|
|
2454
|
+
}, STOP_ESCALATION_MS).unref?.();
|
|
2455
|
+
});
|
|
2456
|
+
const reset = this.resetSession(sessionKey).then(() => "done", () => "done");
|
|
2457
|
+
if (await Promise.race([reset, timeout]) === "timeout") {
|
|
2458
|
+
log.warn("line: cancel could not reset in time, closing live session", { sessionKey });
|
|
2459
|
+
await this.forceCloseSession(sessionKey);
|
|
2460
|
+
await this.resetSession(sessionKey).catch((error) => {
|
|
2461
|
+
log.warn("line: cancel reset failed after close", {
|
|
2462
|
+
sessionKey,
|
|
2463
|
+
error: String(error)
|
|
2464
|
+
});
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
return stopped;
|
|
2468
|
+
}
|
|
2469
|
+
armStopEscalation(sessionKey) {
|
|
2470
|
+
this.clearStopEscalation(sessionKey);
|
|
2471
|
+
const timer = setTimeout(() => {
|
|
2472
|
+
this.stopEscalations.delete(sessionKey);
|
|
2473
|
+
if (!this.activeTurns.has(sessionKey)) return;
|
|
2474
|
+
log.warn("line: /stop did not settle, closing the live session", { sessionKey });
|
|
2475
|
+
this.forceCloseSession(sessionKey);
|
|
2476
|
+
}, STOP_ESCALATION_MS);
|
|
2477
|
+
timer.unref?.();
|
|
2478
|
+
this.stopEscalations.set(sessionKey, timer);
|
|
2479
|
+
}
|
|
2480
|
+
clearStopEscalation(sessionKey) {
|
|
2481
|
+
const timer = this.stopEscalations.get(sessionKey);
|
|
2482
|
+
if (timer === void 0) return;
|
|
2483
|
+
clearTimeout(timer);
|
|
2484
|
+
this.stopEscalations.delete(sessionKey);
|
|
2485
|
+
}
|
|
2486
|
+
/** Closes the live session without deleting it: the next message resumes. */
|
|
2487
|
+
async forceCloseSession(sessionKey) {
|
|
2488
|
+
this.stopCronForwarder(sessionKey);
|
|
2489
|
+
const session = this.sessions.get(sessionKey);
|
|
2490
|
+
this.sessions.delete(sessionKey);
|
|
2491
|
+
if (session !== void 0) {
|
|
2492
|
+
this.sessionKeyBySessionId.delete(session.id);
|
|
2493
|
+
await session.close().catch(() => {});
|
|
2494
|
+
}
|
|
2495
|
+
this.mediaSenders.delete(sessionKey);
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
1969
2498
|
* Submit a turn on the session for `sessionKey`. Serialized per session;
|
|
1970
2499
|
* resolves with the turn outcome (final text or a friendly error).
|
|
1971
2500
|
*/
|
|
@@ -2003,10 +2532,28 @@ var LineSessionDriver = class {
|
|
|
2003
2532
|
text: "上一条消息还在处理中,队列已满,请稍后再发。"
|
|
2004
2533
|
};
|
|
2005
2534
|
}
|
|
2535
|
+
const epoch = this.stopEpoch.get(effectiveJob.sessionKey) ?? 0;
|
|
2536
|
+
const waitingBehind = queue.pending;
|
|
2006
2537
|
queue.pending += 1;
|
|
2007
2538
|
this.queues.set(effectiveJob.sessionKey, queue);
|
|
2539
|
+
if (waitingBehind > 0) try {
|
|
2540
|
+
effectiveJob.onQueued?.(waitingBehind);
|
|
2541
|
+
} catch (error) {
|
|
2542
|
+
log.warn("line: queued notice failed", {
|
|
2543
|
+
sessionKey: effectiveJob.sessionKey,
|
|
2544
|
+
error: String(error)
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2008
2547
|
let outcome;
|
|
2009
2548
|
const next = queue.chain.then(async () => {
|
|
2549
|
+
if ((this.stopEpoch.get(effectiveJob.sessionKey) ?? 0) !== epoch) {
|
|
2550
|
+
outcome = {
|
|
2551
|
+
ok: false,
|
|
2552
|
+
text: "",
|
|
2553
|
+
silent: true
|
|
2554
|
+
};
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2010
2557
|
try {
|
|
2011
2558
|
outcome = await this.runTurn(effectiveJob);
|
|
2012
2559
|
} catch (error) {
|
|
@@ -2030,22 +2577,35 @@ var LineSessionDriver = class {
|
|
|
2030
2577
|
/**
|
|
2031
2578
|
* Resolves after `TURN_TIMEOUT_MS`. Raced against the turn promise so a
|
|
2032
2579
|
* provider that never emits a settlement event cannot wedge the chat queue.
|
|
2580
|
+
*
|
|
2581
|
+
* The timer belongs to this turn, not to the driver: chats run in parallel,
|
|
2582
|
+
* so a shared field would let the first chat to finish cancel the second
|
|
2583
|
+
* chat's deadline and leave that turn with no way out.
|
|
2033
2584
|
*/
|
|
2034
2585
|
turnDeadline() {
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2586
|
+
let timer;
|
|
2587
|
+
return {
|
|
2588
|
+
promise: new Promise((resolve) => {
|
|
2589
|
+
timer = setTimeout(resolve, TURN_TIMEOUT_MS);
|
|
2590
|
+
timer.unref?.();
|
|
2591
|
+
}),
|
|
2592
|
+
clear: () => {
|
|
2593
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2594
|
+
timer = void 0;
|
|
2595
|
+
}
|
|
2596
|
+
};
|
|
2045
2597
|
}
|
|
2046
2598
|
/** Close all sessions and the harness (daemon shutdown). */
|
|
2047
2599
|
async close() {
|
|
2048
2600
|
this.closed = true;
|
|
2601
|
+
for (const timer of this.stopEscalations.values()) clearTimeout(timer);
|
|
2602
|
+
this.stopEscalations.clear();
|
|
2603
|
+
const forwarders = [...this.cronForwarders.values()];
|
|
2604
|
+
this.cronForwarders.clear();
|
|
2605
|
+
for (const forwarder of forwarders) {
|
|
2606
|
+
forwarder.unsubscribe();
|
|
2607
|
+
forwarder.relay.dispose();
|
|
2608
|
+
}
|
|
2049
2609
|
const sessions = [...this.sessions.values()];
|
|
2050
2610
|
this.sessions.clear();
|
|
2051
2611
|
for (const session of sessions) await session.close().catch(() => {});
|
|
@@ -2066,16 +2626,42 @@ var LineSessionDriver = class {
|
|
|
2066
2626
|
const turnPromise = new Promise((resolve) => {
|
|
2067
2627
|
resolveTurn = resolve;
|
|
2068
2628
|
});
|
|
2629
|
+
let stopRequested = false;
|
|
2630
|
+
let requestStop;
|
|
2631
|
+
const stopPromise = new Promise((resolve) => {
|
|
2632
|
+
requestStop = () => {
|
|
2633
|
+
stopRequested = true;
|
|
2634
|
+
resolve();
|
|
2635
|
+
};
|
|
2636
|
+
});
|
|
2069
2637
|
const collector = new LineTurnCollector(resolveTurn);
|
|
2070
2638
|
const unsubscribe = session.onEvent((event) => {
|
|
2071
2639
|
collector.handleEvent(event);
|
|
2072
2640
|
});
|
|
2641
|
+
const registration = {
|
|
2642
|
+
sessionKey: job.sessionKey,
|
|
2643
|
+
stop: requestStop
|
|
2644
|
+
};
|
|
2645
|
+
this.activeTurns.set(job.sessionKey, registration);
|
|
2646
|
+
const deadline = this.turnDeadline();
|
|
2073
2647
|
try {
|
|
2074
2648
|
session.prompt(buildPromptParts(job)).catch((error) => {
|
|
2075
2649
|
promptError = error;
|
|
2076
2650
|
resolveTurn();
|
|
2077
2651
|
});
|
|
2078
|
-
await Promise.race([
|
|
2652
|
+
await Promise.race([
|
|
2653
|
+
turnPromise,
|
|
2654
|
+
deadline.promise,
|
|
2655
|
+
stopPromise
|
|
2656
|
+
]);
|
|
2657
|
+
if (stopRequested) {
|
|
2658
|
+
await session.cancel().catch(() => {});
|
|
2659
|
+
return {
|
|
2660
|
+
ok: false,
|
|
2661
|
+
text: "",
|
|
2662
|
+
silent: true
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2079
2665
|
if (!collector.ended) {
|
|
2080
2666
|
await session.cancel().catch(() => {});
|
|
2081
2667
|
return {
|
|
@@ -2084,8 +2670,10 @@ var LineSessionDriver = class {
|
|
|
2084
2670
|
};
|
|
2085
2671
|
}
|
|
2086
2672
|
} finally {
|
|
2673
|
+
if (this.activeTurns.get(job.sessionKey) === registration) this.activeTurns.delete(job.sessionKey);
|
|
2674
|
+
this.clearStopEscalation(job.sessionKey);
|
|
2087
2675
|
unsubscribe();
|
|
2088
|
-
|
|
2676
|
+
deadline.clear();
|
|
2089
2677
|
}
|
|
2090
2678
|
if (promptError !== void 0 && isOrphanedToolCallError(promptError)) {
|
|
2091
2679
|
log.warn("line: resetting session after tool call mismatch", {
|
|
@@ -2133,6 +2721,13 @@ var LineSessionDriver = class {
|
|
|
2133
2721
|
* Waits for in-flight turns to finish (bounded), so a shutdown does not cut a
|
|
2134
2722
|
* reply mid-flight. Only the per-chat queues are awaited.
|
|
2135
2723
|
*/
|
|
2724
|
+
/** How many turns are queued or running. Read by the shutdown path so a
|
|
2725
|
+
* timed-out drain is reported instead of looking like a clean exit. */
|
|
2726
|
+
pendingTurnCount() {
|
|
2727
|
+
let total = 0;
|
|
2728
|
+
for (const queue of this.queues.values()) total += queue.pending;
|
|
2729
|
+
return total;
|
|
2730
|
+
}
|
|
2136
2731
|
async waitIdle(timeoutMs) {
|
|
2137
2732
|
this.closed = true;
|
|
2138
2733
|
const chains = [...[...this.queues.values()].map((queue) => queue.chain.catch(() => {})), ...[...this.sessionCreations.values()].map((creation) => creation.then(() => {}).catch(() => {}))];
|
|
@@ -2203,6 +2798,63 @@ var LineSessionDriver = class {
|
|
|
2203
2798
|
}
|
|
2204
2799
|
}
|
|
2205
2800
|
/** Registers the channel's outbound-media sender for one chat. */
|
|
2801
|
+
/** Wired once by the daemon after the platforms are up. */
|
|
2802
|
+
setProactiveSender(sender) {
|
|
2803
|
+
this.proactiveSender = sender;
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Watches one session for `cron.fired`.
|
|
2807
|
+
*
|
|
2808
|
+
* A scheduled task runs inside the session, not through `submit()`, so this
|
|
2809
|
+
* subscription is the only path that carries its output back to the chat.
|
|
2810
|
+
*/
|
|
2811
|
+
attachCronForwarder(sessionKey, session) {
|
|
2812
|
+
this.stopCronForwarder(sessionKey);
|
|
2813
|
+
const relay = new CronRelay({
|
|
2814
|
+
send: (text) => this.proactiveSender?.(sessionKey, text) ?? Promise.resolve("no-target"),
|
|
2815
|
+
isBusy: () => this.activeTurns.has(sessionKey)
|
|
2816
|
+
});
|
|
2817
|
+
let pendingStart;
|
|
2818
|
+
let cronTurnId;
|
|
2819
|
+
const unsubscribe = session.onEvent((event) => {
|
|
2820
|
+
if (event.type === "turn.started") {
|
|
2821
|
+
pendingStart = event;
|
|
2822
|
+
if (event.agentId === "main") cronTurnId = event.origin.kind === "cron_job" ? event.turnId : void 0;
|
|
2823
|
+
relay.onEvent(event);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (event.type === "turn.ended") {
|
|
2827
|
+
pendingStart = void 0;
|
|
2828
|
+
if (event.turnId === cronTurnId) cronTurnId = void 0;
|
|
2829
|
+
relay.onEvent(event);
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
if (event.type === "cron.fired") {
|
|
2833
|
+
if (event.agentId !== "main") return;
|
|
2834
|
+
if (event.origin.mute === true) return;
|
|
2835
|
+
relay.onFire(event.origin.silent === true ? null : buildCronReminderChip(event).text, pendingStart, event.origin.silent === true ? void 0 : buildCronFailureNotice(event));
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
relay.onEvent(event);
|
|
2839
|
+
});
|
|
2840
|
+
this.cronForwarders.set(sessionKey, {
|
|
2841
|
+
unsubscribe,
|
|
2842
|
+
relay,
|
|
2843
|
+
isCronTurnRunning: () => cronTurnId !== void 0
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
stopCronForwarder(sessionKey) {
|
|
2847
|
+
const entry = this.cronForwarders.get(sessionKey);
|
|
2848
|
+
if (entry === void 0) return;
|
|
2849
|
+
entry.unsubscribe();
|
|
2850
|
+
entry.relay.dispose();
|
|
2851
|
+
this.cronForwarders.delete(sessionKey);
|
|
2852
|
+
}
|
|
2853
|
+
/** Wired once by the daemon; covers sessions whose per-message sender is gone
|
|
2854
|
+
* (scheduled tasks, after `/stop`, after a restart). */
|
|
2855
|
+
setMediaSenderResolver(resolver) {
|
|
2856
|
+
this.mediaSenderResolver = resolver;
|
|
2857
|
+
}
|
|
2206
2858
|
setMediaSender(sessionKey, sender) {
|
|
2207
2859
|
this.mediaSenders.set(sessionKey, sender);
|
|
2208
2860
|
}
|
|
@@ -2214,7 +2866,10 @@ var LineSessionDriver = class {
|
|
|
2214
2866
|
};
|
|
2215
2867
|
return runLineCommand$1(text, {
|
|
2216
2868
|
defaultModel: this.defaultModel,
|
|
2217
|
-
isBusy: () => (this.queues.get(sessionKey)?.pending ?? 0) > 0,
|
|
2869
|
+
isBusy: () => (this.queues.get(sessionKey)?.pending ?? 0) > 0 || (this.cronForwarders.get(sessionKey)?.relay.isCollecting() ?? false),
|
|
2870
|
+
stopTurn: () => this.stopActiveTurn(sessionKey),
|
|
2871
|
+
cancelSession: () => this.cancelSession(sessionKey),
|
|
2872
|
+
voiceStatus: describeVoiceSupport(this.voiceSupport),
|
|
2218
2873
|
getSession: () => this.resolveSession(sessionKey),
|
|
2219
2874
|
resetSession: () => this.resetSession(sessionKey),
|
|
2220
2875
|
commands: this.options.commands ?? [],
|
|
@@ -2272,12 +2927,14 @@ var LineSessionDriver = class {
|
|
|
2272
2927
|
const session = await this.getOrCreateSession(harness, sessionKey, workDir, permission, planMode);
|
|
2273
2928
|
this.sessions.set(sessionKey, session);
|
|
2274
2929
|
this.sessionKeyBySessionId.set(session.id, sessionKey);
|
|
2930
|
+
this.attachCronForwarder(sessionKey, session);
|
|
2275
2931
|
await this.applyChannelPrompt(session);
|
|
2276
2932
|
return session;
|
|
2277
2933
|
}
|
|
2278
2934
|
/** Drops the chat's session so the next message starts a fresh one. */
|
|
2279
2935
|
async resetSession(sessionKey) {
|
|
2280
2936
|
this.resetting.add(sessionKey);
|
|
2937
|
+
this.stopCronForwarder(sessionKey);
|
|
2281
2938
|
try {
|
|
2282
2939
|
const queue = this.queues.get(sessionKey);
|
|
2283
2940
|
if (queue !== void 0) {
|
|
@@ -2340,7 +2997,7 @@ var LineSessionDriver = class {
|
|
|
2340
2997
|
isError: true
|
|
2341
2998
|
};
|
|
2342
2999
|
const sessionKey = request.sessionId === void 0 ? void 0 : this.sessionKeyBySessionId.get(request.sessionId);
|
|
2343
|
-
const sender = sessionKey === void 0 ? void 0 : this.mediaSenders.get(sessionKey);
|
|
3000
|
+
const sender = sessionKey === void 0 ? void 0 : this.mediaSenders.get(sessionKey) ?? this.mediaSenderResolver?.(sessionKey);
|
|
2344
3001
|
if (sender === void 0) return {
|
|
2345
3002
|
output: "当前会话没有可用的发送通道(该工具仅在 IM 会话中可用)。",
|
|
2346
3003
|
isError: true
|
|
@@ -3077,6 +3734,41 @@ var WeixinPlatform = class {
|
|
|
3077
3734
|
}
|
|
3078
3735
|
/** Reply text to a peer; chunked, fence-aware, throttle fail-fast. */
|
|
3079
3736
|
/**
|
|
3737
|
+
* Sends text to the peer that owns this session with no inbound message to
|
|
3738
|
+
* reply to — the scheduled-task path. WeChat requires a `context_token`,
|
|
3739
|
+
* cached from the last inbound message; without one there is nothing we can
|
|
3740
|
+
* legally address, so this reports `no-target` instead of failing a send.
|
|
3741
|
+
*/
|
|
3742
|
+
async sendProactive(sessionKey, text) {
|
|
3743
|
+
const body = text.trim();
|
|
3744
|
+
if (body === "") return "no-target";
|
|
3745
|
+
const target = this.latestTargets.get(sessionKey);
|
|
3746
|
+
const peer = target?.peerUserId;
|
|
3747
|
+
if (peer === void 0) {
|
|
3748
|
+
log.warn("line-weixin: no peer recorded for proactive send", { sessionKey });
|
|
3749
|
+
return "no-target";
|
|
3750
|
+
}
|
|
3751
|
+
const token = (target?.contextToken ?? "").trim() || (this.contextTokens.get(peer) ?? "");
|
|
3752
|
+
if (token === "") {
|
|
3753
|
+
log.warn("line-weixin: no context_token for proactive send (user must message first)", { sessionKey });
|
|
3754
|
+
return "no-target";
|
|
3755
|
+
}
|
|
3756
|
+
try {
|
|
3757
|
+
const chunks = splitMessageCodeFenceAware(body);
|
|
3758
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
3759
|
+
if (i > 0) await sleep(100);
|
|
3760
|
+
await this.client.sendText(peer, chunks[i] ?? "", token, this.client.newClientId());
|
|
3761
|
+
}
|
|
3762
|
+
return "sent";
|
|
3763
|
+
} catch (error) {
|
|
3764
|
+
log.warn("line-weixin: proactive send failed", {
|
|
3765
|
+
sessionKey,
|
|
3766
|
+
error: String(error)
|
|
3767
|
+
});
|
|
3768
|
+
return "failed";
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3080
3772
|
* Sends a local image or file to the chat that owns this session: upload to
|
|
3081
3773
|
* the CDN (AES-128-ECB), then `sendmessage` with the media item. Ported from
|
|
3082
3774
|
* the reference `SendImage`/`SendFile`.
|
|
@@ -3136,6 +3828,11 @@ var WeixinPlatform = class {
|
|
|
3136
3828
|
}
|
|
3137
3829
|
};
|
|
3138
3830
|
await this.client.sendMediaItem(target.peerUserId, target.contextToken, this.client.newClientId(), item);
|
|
3831
|
+
if (effectiveKind === "voice") log.warn("line: voice submitted without delivery receipt", {
|
|
3832
|
+
sessionKey,
|
|
3833
|
+
bytes: data.length,
|
|
3834
|
+
note: "gateway ret=0 is not a delivery confirmation"
|
|
3835
|
+
});
|
|
3139
3836
|
return note;
|
|
3140
3837
|
}
|
|
3141
3838
|
/** Indirection so tests can exercise the ffmpeg-missing fallback. */
|
|
@@ -3255,6 +3952,24 @@ function sleep(ms) {
|
|
|
3255
3952
|
});
|
|
3256
3953
|
}
|
|
3257
3954
|
//#endregion
|
|
3955
|
+
//#region src/line/notices.ts
|
|
3956
|
+
/**
|
|
3957
|
+
* Short receipts the IM channels send outside a turn's own reply.
|
|
3958
|
+
*
|
|
3959
|
+
* Kept apart from the routers so the wording has one home: the same sentence is
|
|
3960
|
+
* sent from the Feishu and WeChat paths, and both need to answer the two
|
|
3961
|
+
* questions a silent queue leaves open — "did you get my message" and "can I get
|
|
3962
|
+
* out of this".
|
|
3963
|
+
*/
|
|
3964
|
+
/**
|
|
3965
|
+
* Sent when a message had to wait behind a running turn.
|
|
3966
|
+
*
|
|
3967
|
+
* @param position how many turns are ahead of this message (1 = one running).
|
|
3968
|
+
*/
|
|
3969
|
+
function queuedNotice(position) {
|
|
3970
|
+
return `收到,前面还有 ${position} 条在处理中。回复 /stop 可中止当前任务。`;
|
|
3971
|
+
}
|
|
3972
|
+
//#endregion
|
|
3258
3973
|
//#region src/line/daemon.ts
|
|
3259
3974
|
/**
|
|
3260
3975
|
* Line daemon entry point (`scream line`).
|
|
@@ -3270,12 +3985,21 @@ function sleep(ms) {
|
|
|
3270
3985
|
* indicator) — all platform-specific behavior lives in the platform modules.
|
|
3271
3986
|
*/
|
|
3272
3987
|
const GROUP_FILTER_RETRY_INTERVAL_MS = 300 * 1e3;
|
|
3273
|
-
|
|
3988
|
+
/**
|
|
3989
|
+
* How long a shutdown waits for the in-flight turn to finish before forcing an
|
|
3990
|
+
* exit. Ten seconds used to be enough in theory but cut real turns mid-tool, so
|
|
3991
|
+
* the window matches a slow tool call; the wait is announced on stderr.
|
|
3992
|
+
*/
|
|
3993
|
+
const SHUTDOWN_GRACE_MS = 6e4;
|
|
3994
|
+
/** `daemon.log` is read long after the fact, so every line carries a stamp. */
|
|
3995
|
+
function stamp() {
|
|
3996
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
3997
|
+
}
|
|
3274
3998
|
function out$1(message) {
|
|
3275
|
-
process.stdout.write(`${message}\n`);
|
|
3999
|
+
process.stdout.write(`${stamp()} ${message}\n`);
|
|
3276
4000
|
}
|
|
3277
4001
|
function err(message) {
|
|
3278
|
-
process.stderr.write(`${message}\n`);
|
|
4002
|
+
process.stderr.write(`${stamp()} ${message}\n`);
|
|
3279
4003
|
}
|
|
3280
4004
|
/** Run the daemon in the foreground; resolves on graceful shutdown. */
|
|
3281
4005
|
async function runLineDaemon() {
|
|
@@ -3302,6 +4026,9 @@ async function runLineDaemon() {
|
|
|
3302
4026
|
process.exitCode = 1;
|
|
3303
4027
|
return;
|
|
3304
4028
|
}
|
|
4029
|
+
const resolvedWorkDir = lineConfig.workDir ?? process.cwd();
|
|
4030
|
+
if (lineConfig.workDir === void 0) err(`警告(line):配置未设置 workDir,本次以启动目录作为工作目录:${resolvedWorkDir}(建议在 /line 向导里固定它)`);
|
|
4031
|
+
else out$1(`工作目录:${resolvedWorkDir}`);
|
|
3305
4032
|
const driver = new LineSessionDriver({
|
|
3306
4033
|
permissionMode: lineConfig.permissionMode,
|
|
3307
4034
|
workDir: lineConfig.workDir,
|
|
@@ -3315,6 +4042,7 @@ async function runLineDaemon() {
|
|
|
3315
4042
|
if (shuttingDown) return;
|
|
3316
4043
|
shuttingDown = true;
|
|
3317
4044
|
log.info("line: shutting down", { signal });
|
|
4045
|
+
err(`正在等待当前回合结束(最多 ${SHUTDOWN_GRACE_MS / 1e3}s),收到再次信号将立即退出`);
|
|
3318
4046
|
const forceTimer = setTimeout(() => {
|
|
3319
4047
|
err(`line: shutdown exceeded ${SHUTDOWN_GRACE_MS / 1e3}s, forcing exit`);
|
|
3320
4048
|
process.exit(1);
|
|
@@ -3350,6 +4078,7 @@ async function runLineDaemon() {
|
|
|
3350
4078
|
await gracefulShutdown(handles, driver);
|
|
3351
4079
|
process.exit(1);
|
|
3352
4080
|
}
|
|
4081
|
+
await driver.probeVoice();
|
|
3353
4082
|
if (lineConfig.feishu) try {
|
|
3354
4083
|
handles.push(await startFeishu(lineConfig.feishu, driver));
|
|
3355
4084
|
} catch (error) {
|
|
@@ -3360,6 +4089,19 @@ async function runLineDaemon() {
|
|
|
3360
4089
|
} catch (error) {
|
|
3361
4090
|
failures.push(`微信:${error instanceof Error ? error.message : String(error)}`);
|
|
3362
4091
|
}
|
|
4092
|
+
const proactivePlatforms = new Map(handles.map((handle) => [handle.name, handle]));
|
|
4093
|
+
driver.setProactiveSender(async (sessionKey, text) => {
|
|
4094
|
+
const name = sessionKey.startsWith("line-feishu-") ? "feishu" : sessionKey.startsWith("line-weixin-") ? "weixin" : void 0;
|
|
4095
|
+
const platform = name === void 0 ? void 0 : proactivePlatforms.get(name);
|
|
4096
|
+
if (platform === void 0) return "no-target";
|
|
4097
|
+
return platform.sendProactive(sessionKey, text);
|
|
4098
|
+
});
|
|
4099
|
+
driver.setMediaSenderResolver((sessionKey) => {
|
|
4100
|
+
const name = sessionKey.startsWith("line-feishu-") ? "feishu" : sessionKey.startsWith("line-weixin-") ? "weixin" : void 0;
|
|
4101
|
+
const platform = name === void 0 ? void 0 : proactivePlatforms.get(name);
|
|
4102
|
+
if (platform === void 0) return void 0;
|
|
4103
|
+
return (kind, path) => platform.sendMedia(sessionKey, kind, path);
|
|
4104
|
+
});
|
|
3363
4105
|
for (const failure of failures) err(`平台启动失败:${failure}`);
|
|
3364
4106
|
if (handles.length === 0) {
|
|
3365
4107
|
err("所有平台启动失败,line 守护进程退出。");
|
|
@@ -3387,20 +4129,29 @@ async function startFeishu(cfg, driver) {
|
|
|
3387
4129
|
const platform = new FeishuPlatform(client, options, async (message) => {
|
|
3388
4130
|
try {
|
|
3389
4131
|
driver.setMediaSender(message.sessionKey, (kind, path) => platform.sendMedia(message.sessionKey, kind, path));
|
|
3390
|
-
const
|
|
4132
|
+
const outcome = await driver.submit({
|
|
3391
4133
|
sessionKey: message.sessionKey,
|
|
3392
4134
|
text: message.text,
|
|
3393
4135
|
images: message.images,
|
|
3394
|
-
files: message.files
|
|
3395
|
-
|
|
4136
|
+
files: message.files,
|
|
4137
|
+
onQueued: (position) => {
|
|
4138
|
+
const notice = buildReplyContent(queuedNotice(position));
|
|
4139
|
+
client.reply(message.context.messageId, notice.msgType, notice.body).catch((error) => {
|
|
4140
|
+
log.warn("line: feishu queued notice failed", { error: String(error) });
|
|
4141
|
+
});
|
|
4142
|
+
}
|
|
4143
|
+
});
|
|
4144
|
+
if (outcome.silent === true) return;
|
|
4145
|
+
const outbound = buildReplyContent(truncateForLine(outcome.text));
|
|
3396
4146
|
await client.reply(message.context.messageId, outbound.msgType, outbound.body);
|
|
3397
4147
|
} catch (error) {
|
|
3398
4148
|
log.error("line: feishu reply failed", {
|
|
3399
4149
|
messageId: message.context.messageId,
|
|
3400
4150
|
error: String(error)
|
|
3401
4151
|
});
|
|
4152
|
+
} finally {
|
|
4153
|
+
await platform.finishMessage(message.context.messageId).catch(() => {});
|
|
3402
4154
|
}
|
|
3403
|
-
await platform.finishMessage(message.context.messageId).catch(() => {});
|
|
3404
4155
|
});
|
|
3405
4156
|
await client.connect(async (event) => {
|
|
3406
4157
|
try {
|
|
@@ -3422,6 +4173,8 @@ async function startFeishu(cfg, driver) {
|
|
|
3422
4173
|
});
|
|
3423
4174
|
return {
|
|
3424
4175
|
name: "feishu",
|
|
4176
|
+
sendProactive: (sessionKey, text) => platform.sendProactive(sessionKey, text),
|
|
4177
|
+
sendMedia: (sessionKey, kind, path) => platform.sendMedia(sessionKey, kind, path),
|
|
3425
4178
|
close: async () => {
|
|
3426
4179
|
platform.stop();
|
|
3427
4180
|
await client.close();
|
|
@@ -3441,8 +4194,14 @@ async function startWeixin(cfg, driver) {
|
|
|
3441
4194
|
sessionKey: message.sessionKey,
|
|
3442
4195
|
text: message.text,
|
|
3443
4196
|
images: message.images,
|
|
3444
|
-
files: message.files
|
|
4197
|
+
files: message.files,
|
|
4198
|
+
onQueued: (position) => {
|
|
4199
|
+
platform.replyText(message.context, queuedNotice(position)).catch((error) => {
|
|
4200
|
+
log.warn("line: weixin queued notice failed", { error: String(error) });
|
|
4201
|
+
});
|
|
4202
|
+
}
|
|
3445
4203
|
});
|
|
4204
|
+
if (outcome.silent === true) return;
|
|
3446
4205
|
await platform.replyText(message.context, outcome.text);
|
|
3447
4206
|
} catch (error) {
|
|
3448
4207
|
log.error("line: weixin reply failed", {
|
|
@@ -3459,6 +4218,8 @@ async function startWeixin(cfg, driver) {
|
|
|
3459
4218
|
log.info("line: weixin platform started", { baseUrl: options.baseUrl });
|
|
3460
4219
|
return {
|
|
3461
4220
|
name: "weixin",
|
|
4221
|
+
sendProactive: (sessionKey, text) => platform.sendProactive(sessionKey, text),
|
|
4222
|
+
sendMedia: (sessionKey, kind, path) => platform.sendMedia(sessionKey, kind, path),
|
|
3462
4223
|
close: async () => {
|
|
3463
4224
|
await platform.stop();
|
|
3464
4225
|
}
|
|
@@ -3502,6 +4263,11 @@ function scheduleGroupFilterSupervisor(client) {
|
|
|
3502
4263
|
}
|
|
3503
4264
|
async function gracefulShutdown(handles, driver) {
|
|
3504
4265
|
await driver.waitIdle(SHUTDOWN_GRACE_MS).catch(() => {});
|
|
4266
|
+
const abandoned = driver.pendingTurnCount();
|
|
4267
|
+
if (abandoned > 0) {
|
|
4268
|
+
log.warn("line: shutdown timed out with pending turns", { pending: abandoned });
|
|
4269
|
+
err(`警告:仍有 ${abandoned} 个回合未完成,退出后这些消息不会有回复`);
|
|
4270
|
+
}
|
|
3505
4271
|
for (const handle of handles) try {
|
|
3506
4272
|
await handle.close();
|
|
3507
4273
|
} catch {}
|
|
@@ -5413,121 +6179,6 @@ var EditorKeyboardController = class {
|
|
|
5413
6179
|
}
|
|
5414
6180
|
};
|
|
5415
6181
|
//#endregion
|
|
5416
|
-
//#region src/tui/utils/child-request-notice.ts
|
|
5417
|
-
const CHILD_REQUEST_KINDS = [
|
|
5418
|
-
"info",
|
|
5419
|
-
"handoff",
|
|
5420
|
-
"escalate"
|
|
5421
|
-
];
|
|
5422
|
-
/**
|
|
5423
|
-
* Longest previewed value kept on one row. The kernel allows 8192 chars
|
|
5424
|
-
* (`contact-parent.ts`); the ticker is one terminal line, so anything longer is
|
|
5425
|
-
* cut here and the full text remains reachable via the agent card.
|
|
5426
|
-
*/
|
|
5427
|
-
const TEXT_PREVIEW = 200;
|
|
5428
|
-
/**
|
|
5429
|
-
* Collapse the request onto previewable single-paragraph form.
|
|
5430
|
-
*
|
|
5431
|
-
* The text comes from a child agent's model output, i.e. untrusted input that
|
|
5432
|
-
* is about to be written into a terminal row: control sequences (CSI/OSC/DCS —
|
|
5433
|
-
* screen clears, cursor moves, OSC 52 clipboard writes) are stripped first, the
|
|
5434
|
-
* same way shell output is sanitized elsewhere. The cap counts code points, not
|
|
5435
|
-
* UTF-16 units, so a surrogate pair is never split into a replacement glyph.
|
|
5436
|
-
*/
|
|
5437
|
-
function previewText(value) {
|
|
5438
|
-
const flattened = sanitizeShellOutput(value).replaceAll(/\s*\n\s*/g, " ").trim();
|
|
5439
|
-
const codePoints = Array.from(flattened);
|
|
5440
|
-
return codePoints.length > TEXT_PREVIEW ? `${codePoints.slice(0, TEXT_PREVIEW).join("")}…` : flattened;
|
|
5441
|
-
}
|
|
5442
|
-
function kindOf(raw) {
|
|
5443
|
-
return typeof raw === "string" && CHILD_REQUEST_KINDS.includes(raw) ? raw : "info";
|
|
5444
|
-
}
|
|
5445
|
-
function textOf(raw) {
|
|
5446
|
-
if (typeof raw !== "string") return void 0;
|
|
5447
|
-
const trimmed = raw.trim();
|
|
5448
|
-
return trimmed.length > 0 ? trimmed : void 0;
|
|
5449
|
-
}
|
|
5450
|
-
/**
|
|
5451
|
-
* Live producer: the delivery event the kernel emits once a request landed in
|
|
5452
|
-
* the parent's turn. Events cross the wire as untyped JSON, so every field is
|
|
5453
|
-
* re-checked here rather than trusted from the declared type.
|
|
5454
|
-
*/
|
|
5455
|
-
function childRequestFieldsFromEvent(event) {
|
|
5456
|
-
const record = event;
|
|
5457
|
-
const message = textOf(record["message"]);
|
|
5458
|
-
if (message === void 0) return null;
|
|
5459
|
-
const listOf = (key) => {
|
|
5460
|
-
const raw = record[key];
|
|
5461
|
-
if (!Array.isArray(raw)) return void 0;
|
|
5462
|
-
const items = raw.map((item) => textOf(item)).filter((item) => item !== void 0);
|
|
5463
|
-
return items.length > 0 ? items : void 0;
|
|
5464
|
-
};
|
|
5465
|
-
return {
|
|
5466
|
-
requestType: kindOf(record["requestType"]),
|
|
5467
|
-
message,
|
|
5468
|
-
needs: textOf(record["needs"]),
|
|
5469
|
-
expecting: textOf(record["expecting"]),
|
|
5470
|
-
artifacts: listOf("artifacts"),
|
|
5471
|
-
evidence: listOf("evidence"),
|
|
5472
|
-
missing: listOf("missing")
|
|
5473
|
-
};
|
|
5474
|
-
}
|
|
5475
|
-
//#endregion
|
|
5476
|
-
//#region src/tui/utils/cron-chip.ts
|
|
5477
|
-
/**
|
|
5478
|
-
* Scheduled-task reminder → one-line status chip.
|
|
5479
|
-
*
|
|
5480
|
-
* A fired cron task already owns a transcript block (`CronMessageComponent`) and
|
|
5481
|
-
* a turn. The status bar adds the *live* signal: the reminder is time-sensitive,
|
|
5482
|
-
* so it must be visible above the editor for a few seconds even when the user is
|
|
5483
|
-
* looking at the input box, not the transcript.
|
|
5484
|
-
*
|
|
5485
|
-
* Copy follows the shared chip grammar `<symbol> <label>:<value>` — the symbol
|
|
5486
|
-
* lives in the copy, the colour comes from the caller (`accent` for a normal
|
|
5487
|
-
* fire, `warning` for a stale/overdue one).
|
|
5488
|
-
*/
|
|
5489
|
-
/** Longest task prompt shown on the row. The full text stays in the transcript
|
|
5490
|
-
* block, so the chip only needs enough words to recognise the task. */
|
|
5491
|
-
const CRON_PREVIEW_CHARS = 40;
|
|
5492
|
-
function shorten(value, max) {
|
|
5493
|
-
const codePoints = Array.from(value);
|
|
5494
|
-
return codePoints.length > max ? `${codePoints.slice(0, max - 1).join("")}…` : value;
|
|
5495
|
-
}
|
|
5496
|
-
/**
|
|
5497
|
-
* Build the chip for one `cron.fired` delivery.
|
|
5498
|
-
*
|
|
5499
|
-
* `previewText` runs first: the prompt is model/tool-authored text about to be
|
|
5500
|
-
* written into a terminal row, so control sequences are stripped and newlines
|
|
5501
|
-
* collapse before the length cap is applied.
|
|
5502
|
-
*
|
|
5503
|
-
* Precedence, most notable first:
|
|
5504
|
-
* stale (7-day cutoff) → coalesced (missed theoretical fires) → one-shot →
|
|
5505
|
-
* plain recurring fire.
|
|
5506
|
-
*/
|
|
5507
|
-
function buildCronReminderChip(event) {
|
|
5508
|
-
const origin = event.origin;
|
|
5509
|
-
const preview = shorten(previewText(event.prompt), CRON_PREVIEW_CHARS) || origin.cron;
|
|
5510
|
-
if (origin.stale) return {
|
|
5511
|
-
text: t("status.cron_stale", { preview }),
|
|
5512
|
-
tone: "missed"
|
|
5513
|
-
};
|
|
5514
|
-
if (origin.coalescedCount > 1) return {
|
|
5515
|
-
text: t("status.cron_fired_coalesced", {
|
|
5516
|
-
count: String(origin.coalescedCount),
|
|
5517
|
-
preview
|
|
5518
|
-
}),
|
|
5519
|
-
tone: "fired"
|
|
5520
|
-
};
|
|
5521
|
-
if (!origin.recurring) return {
|
|
5522
|
-
text: t("status.cron_once", { preview }),
|
|
5523
|
-
tone: "fired"
|
|
5524
|
-
};
|
|
5525
|
-
return {
|
|
5526
|
-
text: t("status.cron_fired", { preview }),
|
|
5527
|
-
tone: "fired"
|
|
5528
|
-
};
|
|
5529
|
-
}
|
|
5530
|
-
//#endregion
|
|
5531
6182
|
//#region src/tui/utils/background-agent-status.ts
|
|
5532
6183
|
const MAX_BACKGROUND_FIELD_LENGTH = 240;
|
|
5533
6184
|
function normalizeBackgroundField(value) {
|
|
@@ -19536,7 +20187,7 @@ var ScreamTUI = class {
|
|
|
19536
20187
|
const card = new OnboardingCardComponent({
|
|
19537
20188
|
colors: this.state.theme.colors,
|
|
19538
20189
|
onConfigure: () => {
|
|
19539
|
-
import("./dispatch-
|
|
20190
|
+
import("./dispatch-Cqw-jMk5.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
|
|
19540
20191
|
this.restoreEditor();
|
|
19541
20192
|
onDone?.();
|
|
19542
20193
|
});
|