scream-code 0.19.3 → 0.19.6
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-CE9aji1S.mjs} +827 -161
- package/dist/{dispatch-CB9ZdDBD.mjs → dispatch-DNYdiEo4.mjs} +1 -1
- package/dist/{dispatch-BziMqJ_r.mjs → dispatch-XPKo0cQR.mjs} +365 -99
- package/dist/main.mjs +1 -1
- package/dist/{text-input-dialog-yvsx17gg.mjs → text-input-dialog-BqMy3ifH.mjs} +13 -3
- package/dist/{text-input-dialog-CD4xCwLd.mjs → text-input-dialog-CbYhsAbz.mjs} +1 -1
- package/docs/line.md +10 -1
- package/docs/plugins.md +183 -0
- 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 flushDiagnosticLogs, At as createThemeStyles, B as getSharedSpeedTracker, Bn as isSendThrottled, Bt as OSC11_RESPONSE, Cn as BUILTIN_SLASH_COMMANDS, Cr as fetchCatalog, D as renderDiffLines, Dn as embeddingProgressLabel, Dt as isEmptySessionHintDismissed, E as ToolCallComponent, En as setExperimentalFlags, Er as normalizeWorkDir, Et as createMarkdownTheme, F as SkillActivationComponent, Fn as startLineDaemonDetached, Fr as SCREAM_ERROR_INFO, 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 resolveGlobalLogPath, Mt as getColorPalette, N as NoticeMessageComponent, Nn as PULSE_WAVE_FRAMES, Nr as isScreamError, Nt as detectTerminalTheme, O as renderDiffLinesClustered, On as embeddingFailureDetail, Or as isRealUserPrompt, Ot as isTurnElapsedEnabled, P as StatusMessageComponent, Pn as readLineDaemonStatus, Pr as isOrphanedToolCallError, 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 DEFAULT_CATALOG_URL, T as UserMessageComponent, Tn as isExperimentalFlagEnabled, Tr as ScreamHarness, 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 detectInstallSource, a as writeTextClipboard, an as appendStreamingArgsPreview, ar as getWeixinContextTokensFile, at as getModelCycleLevel, b as readUpdateCache, bn as getLlmNotSetMessage, br as CLI_USER_AGENT_PRODUCT, bt as supportsBalance, cn as isTodoItemShape, cr as loadLineConfig, d as isExpandable, dn as stringValue, dr as resolveTurnTimeoutMs, en as isBusy, er as LineConfigError, f as isPlanExpandable, fn as truncateErrorMessage, fr as resolveWeixinOptions, ft as handlePlanCommand, gn as SESSION_TIPS, gr as getLogDir, hn as MAIN_AGENT_ID, hr as getInputHistoryFile, i as openUrl, in as appendSessionHeaderHint, ir as getLinePidFile, it as changeThinkingLevel, j as CachedContainer, jn as startManualEmbeddingDownload, jr as log, jt as contrastTextHex, k as highlightLines, kn as getKnowledgeStore, kr as resolveScreamHome, kt as toggleEmptySessionHint, l as disposeChildren, ln as parseStreamingArgs, lr as resolveCustomCommands, m as formatMemoryMemoForInjection, mn as EXIT_CONFIRM_WINDOW_MS, mr as getDataDir, n as clearEvalPanelState, nn as printableChar, nr as assertSinglePlatformConfig, o as syncDiagramCapabilityPrompt, on as argsRecord, or as getWeixinCursorFile, p as MoonLoader, pr as detectShellEnvironment, q as BREATHE_CYCLE_MS, qn as sanitizeShellOutput, qt as TERMINAL_THEME_LIGHT, rn as STATUS_BULLET, rr as getLineConfigPath, rt as toTerminalHyperlink, sn as formatErrorMessage, sr as getWeixinStateDir, t as dispatchInput, tn as isStreaming, tr as MINUTE_MS, u as hasDispose, un as serializeToolResultOutput, ur as resolveFeishuOptions, ut as handleFusionPlanCommand, v as selectUpdateTarget, vn as getCtrlCHint, vr as CLI_COMMAND_NAME, wn as sortSlashCommands, wr as saveCatalogCache, wt as resolveThemeSync, x as appendJsonlLine, xn as getNoActiveSessionMessage, xr as PRODUCT_NAME, xt as clearInfoPanelState, y as refreshUpdateCache, yn as getCtrlDHint, yr as CLI_UI_MODE, yt as refreshProviderBalance, z as estimateTokens, zn as WeixinClient, zt as OSC11_QUERY } from "./dispatch-XPKo0cQR.mjs";
|
|
9
|
+
import { i as t, n as getLocale, r as setLocale } from "./text-input-dialog-BqMy3ifH.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.6"),
|
|
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,9 @@ 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.turnTimeoutMinutes === void 0 ? [] : [`单轮上限:${deps.turnTimeoutMinutes} 分钟`],
|
|
1962
|
+
...deps.voiceStatus === void 0 || deps.voiceStatus === null ? [] : [deps.voiceStatus]
|
|
1817
1963
|
].join("\n");
|
|
1818
1964
|
} catch (error) {
|
|
1819
1965
|
return `读取状态失败:${describeError(error)}`;
|
|
@@ -1846,19 +1992,174 @@ function expandPrompt(template, arg) {
|
|
|
1846
1992
|
return arg.length > 0 ? `${template}\n\n${arg}` : template;
|
|
1847
1993
|
}
|
|
1848
1994
|
//#endregion
|
|
1849
|
-
//#region src/line/
|
|
1995
|
+
//#region src/line/voice-support.ts
|
|
1850
1996
|
/**
|
|
1851
|
-
*
|
|
1997
|
+
* Startup capability probe for outbound voice.
|
|
1852
1998
|
*
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
1857
|
-
*
|
|
1858
|
-
*
|
|
1999
|
+
* Voice sending is the one path that depends on the host's ffmpeg being built
|
|
2000
|
+
* with an AMR-NB *encoder* — plenty of package-manager builds ship the decoder
|
|
2001
|
+
* only. Discovering that at send time turns every voice reply into a fallback
|
|
2002
|
+
* file plus an apology, so the daemon probes once at startup and reports the
|
|
2003
|
+
* answer in `/status`.
|
|
2004
|
+
*
|
|
2005
|
+
* The probe never throws: a failure to answer is `unknown`, which is treated as
|
|
2006
|
+
* "try anyway" rather than "unavailable".
|
|
1859
2007
|
*/
|
|
1860
|
-
const
|
|
1861
|
-
const
|
|
2008
|
+
const execFileAsync$2 = promisify(execFile);
|
|
2009
|
+
const PROBE_TIMEOUT_MS = 5e3;
|
|
2010
|
+
/** Parses `ffmpeg -encoders` output for an AMR-NB encoder. */
|
|
2011
|
+
function parseAmrEncoderListing(stdout) {
|
|
2012
|
+
return stdout.split(/\r?\n/).some((line) => /\bamr_nb\b|\blibopencore_amrnb\b/i.test(line) && /^\s*A/.test(line));
|
|
2013
|
+
}
|
|
2014
|
+
/** One-shot probe; callers cache the result (ffmpeg does not appear mid-run). */
|
|
2015
|
+
async function probeVoiceSupport() {
|
|
2016
|
+
try {
|
|
2017
|
+
const { stdout } = await execFileAsync$2("ffmpeg", ["-hide_banner", "-encoders"], {
|
|
2018
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
2019
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
2020
|
+
windowsHide: true
|
|
2021
|
+
});
|
|
2022
|
+
return parseAmrEncoderListing(stdout) ? "ready" : "no-encoder";
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
return error.code === "ENOENT" ? "no-ffmpeg" : "unknown";
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
/** One-line description for `/status`; `null` when no probe has run yet. */
|
|
2028
|
+
function describeVoiceSupport(support) {
|
|
2029
|
+
switch (support) {
|
|
2030
|
+
case "ready": return "语音:可用(AMR-NB)";
|
|
2031
|
+
case "no-ffmpeg": return "语音:不可用(未找到 ffmpeg,将改发文件)";
|
|
2032
|
+
case "no-encoder": return "语音:不可用(ffmpeg 缺少 AMR-NB 编码器,将改发文件)";
|
|
2033
|
+
case "unknown": return "语音:未确认(ffmpeg 探测失败,发送时再判断)";
|
|
2034
|
+
case null: return null;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
//#endregion
|
|
2038
|
+
//#region src/tui/utils/child-request-notice.ts
|
|
2039
|
+
const CHILD_REQUEST_KINDS = [
|
|
2040
|
+
"info",
|
|
2041
|
+
"handoff",
|
|
2042
|
+
"escalate"
|
|
2043
|
+
];
|
|
2044
|
+
/**
|
|
2045
|
+
* Longest previewed value kept on one row. The kernel allows 8192 chars
|
|
2046
|
+
* (`contact-parent.ts`); the ticker is one terminal line, so anything longer is
|
|
2047
|
+
* cut here and the full text remains reachable via the agent card.
|
|
2048
|
+
*/
|
|
2049
|
+
const TEXT_PREVIEW = 200;
|
|
2050
|
+
/**
|
|
2051
|
+
* Collapse the request onto previewable single-paragraph form.
|
|
2052
|
+
*
|
|
2053
|
+
* The text comes from a child agent's model output, i.e. untrusted input that
|
|
2054
|
+
* is about to be written into a terminal row: control sequences (CSI/OSC/DCS —
|
|
2055
|
+
* screen clears, cursor moves, OSC 52 clipboard writes) are stripped first, the
|
|
2056
|
+
* same way shell output is sanitized elsewhere. The cap counts code points, not
|
|
2057
|
+
* UTF-16 units, so a surrogate pair is never split into a replacement glyph.
|
|
2058
|
+
*/
|
|
2059
|
+
function previewText(value) {
|
|
2060
|
+
const flattened = sanitizeShellOutput(value).replaceAll(/\s*\n\s*/g, " ").trim();
|
|
2061
|
+
const codePoints = Array.from(flattened);
|
|
2062
|
+
return codePoints.length > TEXT_PREVIEW ? `${codePoints.slice(0, TEXT_PREVIEW).join("")}…` : flattened;
|
|
2063
|
+
}
|
|
2064
|
+
function kindOf(raw) {
|
|
2065
|
+
return typeof raw === "string" && CHILD_REQUEST_KINDS.includes(raw) ? raw : "info";
|
|
2066
|
+
}
|
|
2067
|
+
function textOf(raw) {
|
|
2068
|
+
if (typeof raw !== "string") return void 0;
|
|
2069
|
+
const trimmed = raw.trim();
|
|
2070
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Live producer: the delivery event the kernel emits once a request landed in
|
|
2074
|
+
* the parent's turn. Events cross the wire as untyped JSON, so every field is
|
|
2075
|
+
* re-checked here rather than trusted from the declared type.
|
|
2076
|
+
*/
|
|
2077
|
+
function childRequestFieldsFromEvent(event) {
|
|
2078
|
+
const record = event;
|
|
2079
|
+
const message = textOf(record["message"]);
|
|
2080
|
+
if (message === void 0) return null;
|
|
2081
|
+
const listOf = (key) => {
|
|
2082
|
+
const raw = record[key];
|
|
2083
|
+
if (!Array.isArray(raw)) return void 0;
|
|
2084
|
+
const items = raw.map((item) => textOf(item)).filter((item) => item !== void 0);
|
|
2085
|
+
return items.length > 0 ? items : void 0;
|
|
2086
|
+
};
|
|
2087
|
+
return {
|
|
2088
|
+
requestType: kindOf(record["requestType"]),
|
|
2089
|
+
message,
|
|
2090
|
+
needs: textOf(record["needs"]),
|
|
2091
|
+
expecting: textOf(record["expecting"]),
|
|
2092
|
+
artifacts: listOf("artifacts"),
|
|
2093
|
+
evidence: listOf("evidence"),
|
|
2094
|
+
missing: listOf("missing")
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
//#endregion
|
|
2098
|
+
//#region src/tui/utils/cron-chip.ts
|
|
2099
|
+
/**
|
|
2100
|
+
* Scheduled-task reminder → one-line status chip.
|
|
2101
|
+
*
|
|
2102
|
+
* A fired cron task already owns a transcript block (`CronMessageComponent`) and
|
|
2103
|
+
* a turn. The status bar adds the *live* signal: the reminder is time-sensitive,
|
|
2104
|
+
* so it must be visible above the editor for a few seconds even when the user is
|
|
2105
|
+
* looking at the input box, not the transcript.
|
|
2106
|
+
*
|
|
2107
|
+
* Copy follows the shared chip grammar `<symbol> <label>:<value>` — the symbol
|
|
2108
|
+
* lives in the copy, the colour comes from the caller (`accent` for a normal
|
|
2109
|
+
* fire, `warning` for a stale/overdue one).
|
|
2110
|
+
*/
|
|
2111
|
+
/** Longest task prompt shown on the row. The full text stays in the transcript
|
|
2112
|
+
* block, so the chip only needs enough words to recognise the task. */
|
|
2113
|
+
const CRON_PREVIEW_CHARS = 40;
|
|
2114
|
+
function shorten(value, max) {
|
|
2115
|
+
const codePoints = Array.from(value);
|
|
2116
|
+
return codePoints.length > max ? `${codePoints.slice(0, max - 1).join("")}…` : value;
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Build the chip for one `cron.fired` delivery.
|
|
2120
|
+
*
|
|
2121
|
+
* `previewText` runs first: the prompt is model/tool-authored text about to be
|
|
2122
|
+
* written into a terminal row, so control sequences are stripped and newlines
|
|
2123
|
+
* collapse before the length cap is applied.
|
|
2124
|
+
*
|
|
2125
|
+
* Precedence, most notable first:
|
|
2126
|
+
* stale (7-day cutoff) → coalesced (missed theoretical fires) → one-shot →
|
|
2127
|
+
* plain recurring fire.
|
|
2128
|
+
*/
|
|
2129
|
+
function buildCronReminderChip(event) {
|
|
2130
|
+
const origin = event.origin;
|
|
2131
|
+
const preview = shorten(previewText(event.prompt), CRON_PREVIEW_CHARS) || origin.cron;
|
|
2132
|
+
if (origin.stale) return {
|
|
2133
|
+
text: t("status.cron_stale", { preview }),
|
|
2134
|
+
tone: "missed"
|
|
2135
|
+
};
|
|
2136
|
+
if (origin.coalescedCount > 1) return {
|
|
2137
|
+
text: t("status.cron_fired_coalesced", {
|
|
2138
|
+
count: String(origin.coalescedCount),
|
|
2139
|
+
preview
|
|
2140
|
+
}),
|
|
2141
|
+
tone: "fired"
|
|
2142
|
+
};
|
|
2143
|
+
if (!origin.recurring) return {
|
|
2144
|
+
text: t("status.cron_once", { preview }),
|
|
2145
|
+
tone: "fired"
|
|
2146
|
+
};
|
|
2147
|
+
return {
|
|
2148
|
+
text: t("status.cron_fired", { preview }),
|
|
2149
|
+
tone: "fired"
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Chat-side notice for a fired task that finished without any reply text — an
|
|
2154
|
+
* error swallowed by the agent, or a turn the model ended silently. The chat
|
|
2155
|
+
* should hear *something*; silence is the one outcome a reminder must not have.
|
|
2156
|
+
*/
|
|
2157
|
+
function buildCronFailureNotice(event) {
|
|
2158
|
+
const origin = event.origin;
|
|
2159
|
+
return t("status.cron_empty", { preview: shorten(previewText(event.prompt), CRON_PREVIEW_CHARS) || origin.cron });
|
|
2160
|
+
}
|
|
2161
|
+
//#endregion
|
|
2162
|
+
//#region src/line/turn-collector.ts
|
|
1862
2163
|
/**
|
|
1863
2164
|
* Collects the final content output of one turn from the session event
|
|
1864
2165
|
* stream (main agent only).
|
|
@@ -1912,9 +2213,125 @@ var LineTurnCollector = class {
|
|
|
1912
2213
|
}
|
|
1913
2214
|
}
|
|
1914
2215
|
};
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2216
|
+
//#endregion
|
|
2217
|
+
//#region src/line/cron-relay.ts
|
|
2218
|
+
/**
|
|
2219
|
+
* Scheduled-task relay.
|
|
2220
|
+
*
|
|
2221
|
+
* A fired cron task runs *inside* the session, not through `submit()`, so its
|
|
2222
|
+
* turn carries no inbound context for the platform to reply to. This class
|
|
2223
|
+
* turns that turn into a delivery: announce the fire, collect the final text,
|
|
2224
|
+
* hand it to the caller's `send`.
|
|
2225
|
+
*
|
|
2226
|
+
* Two rules keep it from double-posting:
|
|
2227
|
+
* - a fire that lands while a user turn is running is dropped — `steer()` has
|
|
2228
|
+
* already merged it into that turn, whose reply reaches the chat anyway;
|
|
2229
|
+
* - one forward at a time per session; a second fire during collection is
|
|
2230
|
+
* skipped rather than interleaved.
|
|
2231
|
+
*/
|
|
2232
|
+
/**
|
|
2233
|
+
* A fired turn that never reports its end must not block later fires. Follows
|
|
2234
|
+
* the channel's turn cap, so a long scheduled task is not dropped early.
|
|
2235
|
+
*/
|
|
2236
|
+
const DEFAULT_COLLECT_TIMEOUT_MS = 60 * MINUTE_MS;
|
|
2237
|
+
var CronRelay = class {
|
|
2238
|
+
options;
|
|
2239
|
+
collector;
|
|
2240
|
+
guard;
|
|
2241
|
+
constructor(options) {
|
|
2242
|
+
this.options = options;
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* A scheduled task fired; `notice` is the one-liner shown before the result.
|
|
2246
|
+
*
|
|
2247
|
+
* `pendingStart` is the `turn.started` event the core emits *before* the
|
|
2248
|
+
* `cron.fired` event (`steer()` launches the turn synchronously). Without
|
|
2249
|
+
* replaying it the collector would ignore every delta of that turn and never
|
|
2250
|
+
* settle, so no result would ever be delivered.
|
|
2251
|
+
*/
|
|
2252
|
+
onFire(notice, pendingStart, failureNotice) {
|
|
2253
|
+
if (this.options.isBusy()) return;
|
|
2254
|
+
if (this.collector !== void 0) {
|
|
2255
|
+
log.warn("line: cron fire ignored, previous forward still collecting");
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
const collector = new LineTurnCollector(() => {
|
|
2259
|
+
this.clearGuard();
|
|
2260
|
+
this.collector = void 0;
|
|
2261
|
+
this.deliver(collector, failureNotice);
|
|
2262
|
+
});
|
|
2263
|
+
this.collector = collector;
|
|
2264
|
+
this.guard = setTimeout(() => {
|
|
2265
|
+
if (this.collector !== collector) return;
|
|
2266
|
+
this.collector = void 0;
|
|
2267
|
+
log.warn("line: scheduled task turn produced no end event", { timeoutMs: this.options.collectTimeoutMs ?? DEFAULT_COLLECT_TIMEOUT_MS });
|
|
2268
|
+
}, this.options.collectTimeoutMs ?? DEFAULT_COLLECT_TIMEOUT_MS);
|
|
2269
|
+
this.guard.unref?.();
|
|
2270
|
+
if (pendingStart !== void 0) collector.handleEvent(pendingStart);
|
|
2271
|
+
if (notice !== null) this.options.send(notice).then((result) => {
|
|
2272
|
+
if (result !== "sent") log.warn("line: cron notice not delivered", { result });
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
/** True while a fired task's turn is being collected. */
|
|
2276
|
+
isCollecting() {
|
|
2277
|
+
return this.collector !== void 0;
|
|
2278
|
+
}
|
|
2279
|
+
/** Drops any in-flight collection (session reset / shutdown). */
|
|
2280
|
+
dispose() {
|
|
2281
|
+
this.clearGuard();
|
|
2282
|
+
this.collector = void 0;
|
|
2283
|
+
}
|
|
2284
|
+
clearGuard() {
|
|
2285
|
+
if (this.guard === void 0) return;
|
|
2286
|
+
clearTimeout(this.guard);
|
|
2287
|
+
this.guard = void 0;
|
|
2288
|
+
}
|
|
2289
|
+
/** Every other event of the session; ignored unless a fire is being collected. */
|
|
2290
|
+
onEvent(event) {
|
|
2291
|
+
if (this.collector === void 0) return;
|
|
2292
|
+
if (this.options.isBusy()) {
|
|
2293
|
+
this.clearGuard();
|
|
2294
|
+
this.collector = void 0;
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
this.collector.handleEvent(event);
|
|
2298
|
+
}
|
|
2299
|
+
async deliver(collector, failureNotice) {
|
|
2300
|
+
if (this.options.isBusy()) return;
|
|
2301
|
+
const text = collector.text.trim();
|
|
2302
|
+
if (text === "") {
|
|
2303
|
+
log.warn("line: scheduled task produced no reply text", { ended: collector.ended });
|
|
2304
|
+
if (failureNotice !== void 0) {
|
|
2305
|
+
const result = await this.options.send(failureNotice);
|
|
2306
|
+
if (result !== "sent") log.warn("line: cron failure notice not delivered", { result });
|
|
2307
|
+
}
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
const result = await this.options.send(text);
|
|
2311
|
+
if (result !== "sent") log.warn("line: cron result not delivered", { result });
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2314
|
+
//#endregion
|
|
2315
|
+
//#region src/line/session-driver.ts
|
|
2316
|
+
/**
|
|
2317
|
+
* Line session driver — maps Feishu chats to scream agent sessions.
|
|
2318
|
+
*
|
|
2319
|
+
* Uses the same headless layer as `scream stream-json` (`ScreamHarness`),
|
|
2320
|
+
* including the create/resume/repair session lifecycle (ported from
|
|
2321
|
+
* `run-stream-json.ts` L659-726) and the approval-handler mapping. Turns are
|
|
2322
|
+
* serialized per session (one FIFO chain each) and run in parallel across
|
|
2323
|
+
* sessions; a bounded queue rejects new work with a friendly busy reply
|
|
2324
|
+
* instead of growing unbounded.
|
|
2325
|
+
*/
|
|
2326
|
+
const MAX_QUEUE_PER_SESSION = 3;
|
|
2327
|
+
const ORPHAN_RESET_NOTICE = "会话已自动重置,请重新发送你的消息。";
|
|
2328
|
+
/**
|
|
2329
|
+
* `/stop` interrupts in-process. A tool that ignores the abort would otherwise
|
|
2330
|
+
* keep the chat busy until the turn cap, so after this window the live session is
|
|
2331
|
+
* closed: history stays on disk and the next message resumes the same session
|
|
2332
|
+
* id — the same end result as tearing down the agent process.
|
|
2333
|
+
*/
|
|
2334
|
+
const STOP_ESCALATION_MS = 1e4;
|
|
1918
2335
|
/** `exec`-type custom commands are bounded like any other IM command. */
|
|
1919
2336
|
const CUSTOM_COMMAND_TIMEOUT_MS = 3e4;
|
|
1920
2337
|
const CUSTOM_COMMAND_MAX_BUFFER = 1024 * 1024;
|
|
@@ -1939,19 +2356,41 @@ var LineSessionDriver = class {
|
|
|
1939
2356
|
promptedSessions = /* @__PURE__ */ new WeakSet();
|
|
1940
2357
|
/** sessionKey → platform sender for outbound media (registered by the daemon). */
|
|
1941
2358
|
mediaSenders = /* @__PURE__ */ new Map();
|
|
2359
|
+
/** Fallback sender resolved from the platform's own target cache, so a
|
|
2360
|
+
* scheduled-task turn can still send media after a reset or restart. */
|
|
2361
|
+
mediaSenderResolver;
|
|
1942
2362
|
/** Session id → sessionKey, so a host tool call can find its channel. */
|
|
1943
2363
|
sessionKeyBySessionId = /* @__PURE__ */ new Map();
|
|
1944
2364
|
/** Chats currently executing `/new`; a turn must not start meanwhile. */
|
|
1945
2365
|
resetting = /* @__PURE__ */ new Set();
|
|
1946
|
-
/**
|
|
1947
|
-
|
|
2366
|
+
/** The turn executing right now per chat, so `/stop` has something to abort.
|
|
2367
|
+
* Chats run in parallel, so this is keyed by session — a single slot would
|
|
2368
|
+
* let one chat's turn hide another's from `/stop`. */
|
|
2369
|
+
activeTurns = /* @__PURE__ */ new Map();
|
|
2370
|
+
/** Bumped by `/stop`: turns queued before the bump are dropped instead of
|
|
2371
|
+
* running. "Stop" has to mean the messages I just sent, not only the one
|
|
2372
|
+
* that happens to be executing. */
|
|
2373
|
+
stopEpoch = /* @__PURE__ */ new Map();
|
|
2374
|
+
/** Escalation timers for a `/stop` whose turn never settles, per chat. */
|
|
2375
|
+
stopEscalations = /* @__PURE__ */ new Map();
|
|
2376
|
+
/** Injected by the daemon: sends to a chat with no inbound message to reply
|
|
2377
|
+
* to. Undefined when no platform supports proactive delivery. */
|
|
2378
|
+
proactiveSender;
|
|
2379
|
+
/** One relay + event subscription per live session, so scheduled tasks can
|
|
2380
|
+
* reach the chat. Replaced on session reset, dropped on close. */
|
|
2381
|
+
cronForwarders = /* @__PURE__ */ new Map();
|
|
2382
|
+
/** Cached ffmpeg capability answer (see `probeVoice`). */
|
|
2383
|
+
voiceSupport = null;
|
|
1948
2384
|
/** In-flight creations, so a command and a turn cannot create two sessions. */
|
|
1949
2385
|
sessionCreations = /* @__PURE__ */ new Map();
|
|
1950
2386
|
/** Per-chat lifecycle mutex: session creation must never interleave a reset. */
|
|
1951
2387
|
lifecycleLocks = /* @__PURE__ */ new Map();
|
|
2388
|
+
/** Wall-clock cap for one turn; see `turnDeadline`. */
|
|
2389
|
+
turnTimeoutMs;
|
|
1952
2390
|
closed = false;
|
|
1953
2391
|
constructor(options) {
|
|
1954
2392
|
this.options = options;
|
|
2393
|
+
this.turnTimeoutMs = options.turnTimeoutMs ?? 36e5;
|
|
1955
2394
|
}
|
|
1956
2395
|
async init() {
|
|
1957
2396
|
const harness = new ScreamHarness({
|
|
@@ -1966,6 +2405,100 @@ var LineSessionDriver = class {
|
|
|
1966
2405
|
this.harness = harness;
|
|
1967
2406
|
}
|
|
1968
2407
|
/**
|
|
2408
|
+
* Probes the host for an AMR-NB encoder once. Called by the daemon at startup
|
|
2409
|
+
* so `/status` can warn before the first voice reply falls back to a file.
|
|
2410
|
+
* Never throws.
|
|
2411
|
+
*/
|
|
2412
|
+
async probeVoice() {
|
|
2413
|
+
try {
|
|
2414
|
+
this.voiceSupport = await probeVoiceSupport();
|
|
2415
|
+
if (describeVoiceSupport(this.voiceSupport) !== null) log.info("line: voice capability", { support: this.voiceSupport });
|
|
2416
|
+
} catch (error) {
|
|
2417
|
+
this.voiceSupport = "unknown";
|
|
2418
|
+
log.warn("line: voice probe failed", { error: String(error) });
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* `/stop`: aborts the turn that is running now. The cancel itself happens
|
|
2423
|
+
* inside `runTurn` so the queue unwinds in one place; this only requests it.
|
|
2424
|
+
*/
|
|
2425
|
+
stopActiveTurn(sessionKey) {
|
|
2426
|
+
const running = this.activeTurns.get(sessionKey);
|
|
2427
|
+
const pending = this.queues.get(sessionKey)?.pending ?? 0;
|
|
2428
|
+
const forwarder = this.cronForwarders.get(sessionKey);
|
|
2429
|
+
const forwarding = forwarder?.relay.isCollecting() ?? false;
|
|
2430
|
+
const cronTurn = forwarder?.isCronTurnRunning() ?? false;
|
|
2431
|
+
if (running === void 0 && pending === 0 && !forwarding && !cronTurn) return false;
|
|
2432
|
+
this.stopEpoch.set(sessionKey, (this.stopEpoch.get(sessionKey) ?? 0) + 1);
|
|
2433
|
+
if (running !== void 0) {
|
|
2434
|
+
running.stop();
|
|
2435
|
+
this.armStopEscalation(sessionKey);
|
|
2436
|
+
}
|
|
2437
|
+
if (forwarding || cronTurn) {
|
|
2438
|
+
forwarder?.relay.dispose();
|
|
2439
|
+
this.sessions.get(sessionKey)?.cancel().catch(() => {});
|
|
2440
|
+
log.info("line: dropped a scheduled-task turn", { sessionKey });
|
|
2441
|
+
}
|
|
2442
|
+
log.info("line: stop requested", {
|
|
2443
|
+
sessionKey,
|
|
2444
|
+
running: running !== void 0,
|
|
2445
|
+
pending
|
|
2446
|
+
});
|
|
2447
|
+
return true;
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* `/cancel`: stop whatever runs and start a clean session. Falls back to
|
|
2451
|
+
* closing the live session when a hung tool refuses to settle, then resets.
|
|
2452
|
+
*/
|
|
2453
|
+
async cancelSession(sessionKey) {
|
|
2454
|
+
const stopped = this.stopActiveTurn(sessionKey);
|
|
2455
|
+
const timeout = new Promise((resolve) => {
|
|
2456
|
+
setTimeout(() => {
|
|
2457
|
+
resolve("timeout");
|
|
2458
|
+
}, STOP_ESCALATION_MS).unref?.();
|
|
2459
|
+
});
|
|
2460
|
+
const reset = this.resetSession(sessionKey).then(() => "done", () => "done");
|
|
2461
|
+
if (await Promise.race([reset, timeout]) === "timeout") {
|
|
2462
|
+
log.warn("line: cancel could not reset in time, closing live session", { sessionKey });
|
|
2463
|
+
await this.forceCloseSession(sessionKey);
|
|
2464
|
+
await this.resetSession(sessionKey).catch((error) => {
|
|
2465
|
+
log.warn("line: cancel reset failed after close", {
|
|
2466
|
+
sessionKey,
|
|
2467
|
+
error: String(error)
|
|
2468
|
+
});
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
return stopped;
|
|
2472
|
+
}
|
|
2473
|
+
armStopEscalation(sessionKey) {
|
|
2474
|
+
this.clearStopEscalation(sessionKey);
|
|
2475
|
+
const timer = setTimeout(() => {
|
|
2476
|
+
this.stopEscalations.delete(sessionKey);
|
|
2477
|
+
if (!this.activeTurns.has(sessionKey)) return;
|
|
2478
|
+
log.warn("line: /stop did not settle, closing the live session", { sessionKey });
|
|
2479
|
+
this.forceCloseSession(sessionKey);
|
|
2480
|
+
}, STOP_ESCALATION_MS);
|
|
2481
|
+
timer.unref?.();
|
|
2482
|
+
this.stopEscalations.set(sessionKey, timer);
|
|
2483
|
+
}
|
|
2484
|
+
clearStopEscalation(sessionKey) {
|
|
2485
|
+
const timer = this.stopEscalations.get(sessionKey);
|
|
2486
|
+
if (timer === void 0) return;
|
|
2487
|
+
clearTimeout(timer);
|
|
2488
|
+
this.stopEscalations.delete(sessionKey);
|
|
2489
|
+
}
|
|
2490
|
+
/** Closes the live session without deleting it: the next message resumes. */
|
|
2491
|
+
async forceCloseSession(sessionKey) {
|
|
2492
|
+
this.stopCronForwarder(sessionKey);
|
|
2493
|
+
const session = this.sessions.get(sessionKey);
|
|
2494
|
+
this.sessions.delete(sessionKey);
|
|
2495
|
+
if (session !== void 0) {
|
|
2496
|
+
this.sessionKeyBySessionId.delete(session.id);
|
|
2497
|
+
await session.close().catch(() => {});
|
|
2498
|
+
}
|
|
2499
|
+
this.mediaSenders.delete(sessionKey);
|
|
2500
|
+
}
|
|
2501
|
+
/**
|
|
1969
2502
|
* Submit a turn on the session for `sessionKey`. Serialized per session;
|
|
1970
2503
|
* resolves with the turn outcome (final text or a friendly error).
|
|
1971
2504
|
*/
|
|
@@ -2003,10 +2536,28 @@ var LineSessionDriver = class {
|
|
|
2003
2536
|
text: "上一条消息还在处理中,队列已满,请稍后再发。"
|
|
2004
2537
|
};
|
|
2005
2538
|
}
|
|
2539
|
+
const epoch = this.stopEpoch.get(effectiveJob.sessionKey) ?? 0;
|
|
2540
|
+
const waitingBehind = queue.pending;
|
|
2006
2541
|
queue.pending += 1;
|
|
2007
2542
|
this.queues.set(effectiveJob.sessionKey, queue);
|
|
2543
|
+
if (waitingBehind > 0) try {
|
|
2544
|
+
effectiveJob.onQueued?.(waitingBehind);
|
|
2545
|
+
} catch (error) {
|
|
2546
|
+
log.warn("line: queued notice failed", {
|
|
2547
|
+
sessionKey: effectiveJob.sessionKey,
|
|
2548
|
+
error: String(error)
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2008
2551
|
let outcome;
|
|
2009
2552
|
const next = queue.chain.then(async () => {
|
|
2553
|
+
if ((this.stopEpoch.get(effectiveJob.sessionKey) ?? 0) !== epoch) {
|
|
2554
|
+
outcome = {
|
|
2555
|
+
ok: false,
|
|
2556
|
+
text: "",
|
|
2557
|
+
silent: true
|
|
2558
|
+
};
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2010
2561
|
try {
|
|
2011
2562
|
outcome = await this.runTurn(effectiveJob);
|
|
2012
2563
|
} catch (error) {
|
|
@@ -2028,24 +2579,44 @@ var LineSessionDriver = class {
|
|
|
2028
2579
|
return outcome;
|
|
2029
2580
|
}
|
|
2030
2581
|
/**
|
|
2031
|
-
* Resolves after
|
|
2582
|
+
* Resolves after the configured turn cap. Raced against the turn promise so a
|
|
2032
2583
|
* provider that never emits a settlement event cannot wedge the chat queue.
|
|
2584
|
+
*
|
|
2585
|
+
* The timer belongs to this turn, not to the driver: chats run in parallel,
|
|
2586
|
+
* so a shared field would let the first chat to finish cancel the second
|
|
2587
|
+
* chat's deadline and leave that turn with no way out.
|
|
2033
2588
|
*/
|
|
2034
2589
|
turnDeadline() {
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2590
|
+
let timer;
|
|
2591
|
+
return {
|
|
2592
|
+
promise: new Promise((resolve) => {
|
|
2593
|
+
timer = setTimeout(resolve, this.turnTimeoutMs);
|
|
2594
|
+
timer.unref?.();
|
|
2595
|
+
}),
|
|
2596
|
+
clear: () => {
|
|
2597
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2598
|
+
timer = void 0;
|
|
2599
|
+
}
|
|
2600
|
+
};
|
|
2039
2601
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2602
|
+
/** The cap in whole minutes, for `/status` and the timeout reply. */
|
|
2603
|
+
get turnTimeoutMinutes() {
|
|
2604
|
+
return Math.max(1, Math.round(this.turnTimeoutMs / 6e4));
|
|
2605
|
+
}
|
|
2606
|
+
turnTimeoutText() {
|
|
2607
|
+
return `本轮执行超时(${this.turnTimeoutMinutes} 分钟),已取消。请重试或拆分任务。`;
|
|
2045
2608
|
}
|
|
2046
2609
|
/** Close all sessions and the harness (daemon shutdown). */
|
|
2047
2610
|
async close() {
|
|
2048
2611
|
this.closed = true;
|
|
2612
|
+
for (const timer of this.stopEscalations.values()) clearTimeout(timer);
|
|
2613
|
+
this.stopEscalations.clear();
|
|
2614
|
+
const forwarders = [...this.cronForwarders.values()];
|
|
2615
|
+
this.cronForwarders.clear();
|
|
2616
|
+
for (const forwarder of forwarders) {
|
|
2617
|
+
forwarder.unsubscribe();
|
|
2618
|
+
forwarder.relay.dispose();
|
|
2619
|
+
}
|
|
2049
2620
|
const sessions = [...this.sessions.values()];
|
|
2050
2621
|
this.sessions.clear();
|
|
2051
2622
|
for (const session of sessions) await session.close().catch(() => {});
|
|
@@ -2066,26 +2637,54 @@ var LineSessionDriver = class {
|
|
|
2066
2637
|
const turnPromise = new Promise((resolve) => {
|
|
2067
2638
|
resolveTurn = resolve;
|
|
2068
2639
|
});
|
|
2640
|
+
let stopRequested = false;
|
|
2641
|
+
let requestStop;
|
|
2642
|
+
const stopPromise = new Promise((resolve) => {
|
|
2643
|
+
requestStop = () => {
|
|
2644
|
+
stopRequested = true;
|
|
2645
|
+
resolve();
|
|
2646
|
+
};
|
|
2647
|
+
});
|
|
2069
2648
|
const collector = new LineTurnCollector(resolveTurn);
|
|
2070
2649
|
const unsubscribe = session.onEvent((event) => {
|
|
2071
2650
|
collector.handleEvent(event);
|
|
2072
2651
|
});
|
|
2652
|
+
const registration = {
|
|
2653
|
+
sessionKey: job.sessionKey,
|
|
2654
|
+
stop: requestStop
|
|
2655
|
+
};
|
|
2656
|
+
this.activeTurns.set(job.sessionKey, registration);
|
|
2657
|
+
const deadline = this.turnDeadline();
|
|
2073
2658
|
try {
|
|
2074
2659
|
session.prompt(buildPromptParts(job)).catch((error) => {
|
|
2075
2660
|
promptError = error;
|
|
2076
2661
|
resolveTurn();
|
|
2077
2662
|
});
|
|
2078
|
-
await Promise.race([
|
|
2663
|
+
await Promise.race([
|
|
2664
|
+
turnPromise,
|
|
2665
|
+
deadline.promise,
|
|
2666
|
+
stopPromise
|
|
2667
|
+
]);
|
|
2668
|
+
if (stopRequested) {
|
|
2669
|
+
await session.cancel().catch(() => {});
|
|
2670
|
+
return {
|
|
2671
|
+
ok: false,
|
|
2672
|
+
text: "",
|
|
2673
|
+
silent: true
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2079
2676
|
if (!collector.ended) {
|
|
2080
2677
|
await session.cancel().catch(() => {});
|
|
2081
2678
|
return {
|
|
2082
2679
|
ok: false,
|
|
2083
|
-
text:
|
|
2680
|
+
text: this.turnTimeoutText()
|
|
2084
2681
|
};
|
|
2085
2682
|
}
|
|
2086
2683
|
} finally {
|
|
2684
|
+
if (this.activeTurns.get(job.sessionKey) === registration) this.activeTurns.delete(job.sessionKey);
|
|
2685
|
+
this.clearStopEscalation(job.sessionKey);
|
|
2087
2686
|
unsubscribe();
|
|
2088
|
-
|
|
2687
|
+
deadline.clear();
|
|
2089
2688
|
}
|
|
2090
2689
|
if (promptError !== void 0 && isOrphanedToolCallError(promptError)) {
|
|
2091
2690
|
log.warn("line: resetting session after tool call mismatch", {
|
|
@@ -2133,6 +2732,13 @@ var LineSessionDriver = class {
|
|
|
2133
2732
|
* Waits for in-flight turns to finish (bounded), so a shutdown does not cut a
|
|
2134
2733
|
* reply mid-flight. Only the per-chat queues are awaited.
|
|
2135
2734
|
*/
|
|
2735
|
+
/** How many turns are queued or running. Read by the shutdown path so a
|
|
2736
|
+
* timed-out drain is reported instead of looking like a clean exit. */
|
|
2737
|
+
pendingTurnCount() {
|
|
2738
|
+
let total = 0;
|
|
2739
|
+
for (const queue of this.queues.values()) total += queue.pending;
|
|
2740
|
+
return total;
|
|
2741
|
+
}
|
|
2136
2742
|
async waitIdle(timeoutMs) {
|
|
2137
2743
|
this.closed = true;
|
|
2138
2744
|
const chains = [...[...this.queues.values()].map((queue) => queue.chain.catch(() => {})), ...[...this.sessionCreations.values()].map((creation) => creation.then(() => {}).catch(() => {}))];
|
|
@@ -2203,6 +2809,64 @@ var LineSessionDriver = class {
|
|
|
2203
2809
|
}
|
|
2204
2810
|
}
|
|
2205
2811
|
/** Registers the channel's outbound-media sender for one chat. */
|
|
2812
|
+
/** Wired once by the daemon after the platforms are up. */
|
|
2813
|
+
setProactiveSender(sender) {
|
|
2814
|
+
this.proactiveSender = sender;
|
|
2815
|
+
}
|
|
2816
|
+
/**
|
|
2817
|
+
* Watches one session for `cron.fired`.
|
|
2818
|
+
*
|
|
2819
|
+
* A scheduled task runs inside the session, not through `submit()`, so this
|
|
2820
|
+
* subscription is the only path that carries its output back to the chat.
|
|
2821
|
+
*/
|
|
2822
|
+
attachCronForwarder(sessionKey, session) {
|
|
2823
|
+
this.stopCronForwarder(sessionKey);
|
|
2824
|
+
const relay = new CronRelay({
|
|
2825
|
+
send: (text) => this.proactiveSender?.(sessionKey, text) ?? Promise.resolve("no-target"),
|
|
2826
|
+
isBusy: () => this.activeTurns.has(sessionKey),
|
|
2827
|
+
collectTimeoutMs: this.turnTimeoutMs
|
|
2828
|
+
});
|
|
2829
|
+
let pendingStart;
|
|
2830
|
+
let cronTurnId;
|
|
2831
|
+
const unsubscribe = session.onEvent((event) => {
|
|
2832
|
+
if (event.type === "turn.started") {
|
|
2833
|
+
pendingStart = event;
|
|
2834
|
+
if (event.agentId === "main") cronTurnId = event.origin.kind === "cron_job" ? event.turnId : void 0;
|
|
2835
|
+
relay.onEvent(event);
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
if (event.type === "turn.ended") {
|
|
2839
|
+
pendingStart = void 0;
|
|
2840
|
+
if (event.turnId === cronTurnId) cronTurnId = void 0;
|
|
2841
|
+
relay.onEvent(event);
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
if (event.type === "cron.fired") {
|
|
2845
|
+
if (event.agentId !== "main") return;
|
|
2846
|
+
if (event.origin.mute === true) return;
|
|
2847
|
+
relay.onFire(event.origin.silent === true ? null : buildCronReminderChip(event).text, pendingStart, event.origin.silent === true ? void 0 : buildCronFailureNotice(event));
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
relay.onEvent(event);
|
|
2851
|
+
});
|
|
2852
|
+
this.cronForwarders.set(sessionKey, {
|
|
2853
|
+
unsubscribe,
|
|
2854
|
+
relay,
|
|
2855
|
+
isCronTurnRunning: () => cronTurnId !== void 0
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
stopCronForwarder(sessionKey) {
|
|
2859
|
+
const entry = this.cronForwarders.get(sessionKey);
|
|
2860
|
+
if (entry === void 0) return;
|
|
2861
|
+
entry.unsubscribe();
|
|
2862
|
+
entry.relay.dispose();
|
|
2863
|
+
this.cronForwarders.delete(sessionKey);
|
|
2864
|
+
}
|
|
2865
|
+
/** Wired once by the daemon; covers sessions whose per-message sender is gone
|
|
2866
|
+
* (scheduled tasks, after `/stop`, after a restart). */
|
|
2867
|
+
setMediaSenderResolver(resolver) {
|
|
2868
|
+
this.mediaSenderResolver = resolver;
|
|
2869
|
+
}
|
|
2206
2870
|
setMediaSender(sessionKey, sender) {
|
|
2207
2871
|
this.mediaSenders.set(sessionKey, sender);
|
|
2208
2872
|
}
|
|
@@ -2214,7 +2878,11 @@ var LineSessionDriver = class {
|
|
|
2214
2878
|
};
|
|
2215
2879
|
return runLineCommand$1(text, {
|
|
2216
2880
|
defaultModel: this.defaultModel,
|
|
2217
|
-
isBusy: () => (this.queues.get(sessionKey)?.pending ?? 0) > 0,
|
|
2881
|
+
isBusy: () => (this.queues.get(sessionKey)?.pending ?? 0) > 0 || (this.cronForwarders.get(sessionKey)?.relay.isCollecting() ?? false),
|
|
2882
|
+
stopTurn: () => this.stopActiveTurn(sessionKey),
|
|
2883
|
+
cancelSession: () => this.cancelSession(sessionKey),
|
|
2884
|
+
voiceStatus: describeVoiceSupport(this.voiceSupport),
|
|
2885
|
+
turnTimeoutMinutes: this.turnTimeoutMinutes,
|
|
2218
2886
|
getSession: () => this.resolveSession(sessionKey),
|
|
2219
2887
|
resetSession: () => this.resetSession(sessionKey),
|
|
2220
2888
|
commands: this.options.commands ?? [],
|
|
@@ -2272,12 +2940,14 @@ var LineSessionDriver = class {
|
|
|
2272
2940
|
const session = await this.getOrCreateSession(harness, sessionKey, workDir, permission, planMode);
|
|
2273
2941
|
this.sessions.set(sessionKey, session);
|
|
2274
2942
|
this.sessionKeyBySessionId.set(session.id, sessionKey);
|
|
2943
|
+
this.attachCronForwarder(sessionKey, session);
|
|
2275
2944
|
await this.applyChannelPrompt(session);
|
|
2276
2945
|
return session;
|
|
2277
2946
|
}
|
|
2278
2947
|
/** Drops the chat's session so the next message starts a fresh one. */
|
|
2279
2948
|
async resetSession(sessionKey) {
|
|
2280
2949
|
this.resetting.add(sessionKey);
|
|
2950
|
+
this.stopCronForwarder(sessionKey);
|
|
2281
2951
|
try {
|
|
2282
2952
|
const queue = this.queues.get(sessionKey);
|
|
2283
2953
|
if (queue !== void 0) {
|
|
@@ -2340,7 +3010,7 @@ var LineSessionDriver = class {
|
|
|
2340
3010
|
isError: true
|
|
2341
3011
|
};
|
|
2342
3012
|
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);
|
|
3013
|
+
const sender = sessionKey === void 0 ? void 0 : this.mediaSenders.get(sessionKey) ?? this.mediaSenderResolver?.(sessionKey);
|
|
2344
3014
|
if (sender === void 0) return {
|
|
2345
3015
|
output: "当前会话没有可用的发送通道(该工具仅在 IM 会话中可用)。",
|
|
2346
3016
|
isError: true
|
|
@@ -3077,6 +3747,41 @@ var WeixinPlatform = class {
|
|
|
3077
3747
|
}
|
|
3078
3748
|
/** Reply text to a peer; chunked, fence-aware, throttle fail-fast. */
|
|
3079
3749
|
/**
|
|
3750
|
+
* Sends text to the peer that owns this session with no inbound message to
|
|
3751
|
+
* reply to — the scheduled-task path. WeChat requires a `context_token`,
|
|
3752
|
+
* cached from the last inbound message; without one there is nothing we can
|
|
3753
|
+
* legally address, so this reports `no-target` instead of failing a send.
|
|
3754
|
+
*/
|
|
3755
|
+
async sendProactive(sessionKey, text) {
|
|
3756
|
+
const body = text.trim();
|
|
3757
|
+
if (body === "") return "no-target";
|
|
3758
|
+
const target = this.latestTargets.get(sessionKey);
|
|
3759
|
+
const peer = target?.peerUserId;
|
|
3760
|
+
if (peer === void 0) {
|
|
3761
|
+
log.warn("line-weixin: no peer recorded for proactive send", { sessionKey });
|
|
3762
|
+
return "no-target";
|
|
3763
|
+
}
|
|
3764
|
+
const token = (target?.contextToken ?? "").trim() || (this.contextTokens.get(peer) ?? "");
|
|
3765
|
+
if (token === "") {
|
|
3766
|
+
log.warn("line-weixin: no context_token for proactive send (user must message first)", { sessionKey });
|
|
3767
|
+
return "no-target";
|
|
3768
|
+
}
|
|
3769
|
+
try {
|
|
3770
|
+
const chunks = splitMessageCodeFenceAware(body);
|
|
3771
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
3772
|
+
if (i > 0) await sleep(100);
|
|
3773
|
+
await this.client.sendText(peer, chunks[i] ?? "", token, this.client.newClientId());
|
|
3774
|
+
}
|
|
3775
|
+
return "sent";
|
|
3776
|
+
} catch (error) {
|
|
3777
|
+
log.warn("line-weixin: proactive send failed", {
|
|
3778
|
+
sessionKey,
|
|
3779
|
+
error: String(error)
|
|
3780
|
+
});
|
|
3781
|
+
return "failed";
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
/**
|
|
3080
3785
|
* Sends a local image or file to the chat that owns this session: upload to
|
|
3081
3786
|
* the CDN (AES-128-ECB), then `sendmessage` with the media item. Ported from
|
|
3082
3787
|
* the reference `SendImage`/`SendFile`.
|
|
@@ -3136,6 +3841,11 @@ var WeixinPlatform = class {
|
|
|
3136
3841
|
}
|
|
3137
3842
|
};
|
|
3138
3843
|
await this.client.sendMediaItem(target.peerUserId, target.contextToken, this.client.newClientId(), item);
|
|
3844
|
+
if (effectiveKind === "voice") log.warn("line: voice submitted without delivery receipt", {
|
|
3845
|
+
sessionKey,
|
|
3846
|
+
bytes: data.length,
|
|
3847
|
+
note: "gateway ret=0 is not a delivery confirmation"
|
|
3848
|
+
});
|
|
3139
3849
|
return note;
|
|
3140
3850
|
}
|
|
3141
3851
|
/** Indirection so tests can exercise the ffmpeg-missing fallback. */
|
|
@@ -3255,6 +3965,24 @@ function sleep(ms) {
|
|
|
3255
3965
|
});
|
|
3256
3966
|
}
|
|
3257
3967
|
//#endregion
|
|
3968
|
+
//#region src/line/notices.ts
|
|
3969
|
+
/**
|
|
3970
|
+
* Short receipts the IM channels send outside a turn's own reply.
|
|
3971
|
+
*
|
|
3972
|
+
* Kept apart from the routers so the wording has one home: the same sentence is
|
|
3973
|
+
* sent from the Feishu and WeChat paths, and both need to answer the two
|
|
3974
|
+
* questions a silent queue leaves open — "did you get my message" and "can I get
|
|
3975
|
+
* out of this".
|
|
3976
|
+
*/
|
|
3977
|
+
/**
|
|
3978
|
+
* Sent when a message had to wait behind a running turn.
|
|
3979
|
+
*
|
|
3980
|
+
* @param position how many turns are ahead of this message (1 = one running).
|
|
3981
|
+
*/
|
|
3982
|
+
function queuedNotice(position) {
|
|
3983
|
+
return `收到,前面还有 ${position} 条在处理中。回复 /stop 可中止当前任务。`;
|
|
3984
|
+
}
|
|
3985
|
+
//#endregion
|
|
3258
3986
|
//#region src/line/daemon.ts
|
|
3259
3987
|
/**
|
|
3260
3988
|
* Line daemon entry point (`scream line`).
|
|
@@ -3270,12 +3998,21 @@ function sleep(ms) {
|
|
|
3270
3998
|
* indicator) — all platform-specific behavior lives in the platform modules.
|
|
3271
3999
|
*/
|
|
3272
4000
|
const GROUP_FILTER_RETRY_INTERVAL_MS = 300 * 1e3;
|
|
3273
|
-
|
|
4001
|
+
/**
|
|
4002
|
+
* How long a shutdown waits for the in-flight turn to finish before forcing an
|
|
4003
|
+
* exit. Ten seconds used to be enough in theory but cut real turns mid-tool, so
|
|
4004
|
+
* the window matches a slow tool call; the wait is announced on stderr.
|
|
4005
|
+
*/
|
|
4006
|
+
const SHUTDOWN_GRACE_MS = 6e4;
|
|
4007
|
+
/** `daemon.log` is read long after the fact, so every line carries a stamp. */
|
|
4008
|
+
function stamp() {
|
|
4009
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
4010
|
+
}
|
|
3274
4011
|
function out$1(message) {
|
|
3275
|
-
process.stdout.write(`${message}\n`);
|
|
4012
|
+
process.stdout.write(`${stamp()} ${message}\n`);
|
|
3276
4013
|
}
|
|
3277
4014
|
function err(message) {
|
|
3278
|
-
process.stderr.write(`${message}\n`);
|
|
4015
|
+
process.stderr.write(`${stamp()} ${message}\n`);
|
|
3279
4016
|
}
|
|
3280
4017
|
/** Run the daemon in the foreground; resolves on graceful shutdown. */
|
|
3281
4018
|
async function runLineDaemon() {
|
|
@@ -3302,12 +4039,17 @@ async function runLineDaemon() {
|
|
|
3302
4039
|
process.exitCode = 1;
|
|
3303
4040
|
return;
|
|
3304
4041
|
}
|
|
4042
|
+
const resolvedWorkDir = lineConfig.workDir ?? process.cwd();
|
|
4043
|
+
if (lineConfig.workDir === void 0) err(`警告(line):配置未设置 workDir,本次以启动目录作为工作目录:${resolvedWorkDir}(建议在 /line 向导里固定它)`);
|
|
4044
|
+
else out$1(`工作目录:${resolvedWorkDir}`);
|
|
3305
4045
|
const driver = new LineSessionDriver({
|
|
3306
4046
|
permissionMode: lineConfig.permissionMode,
|
|
3307
4047
|
workDir: lineConfig.workDir,
|
|
3308
4048
|
platform: lineConfig.feishu !== void 0 ? "feishu" : "weixin",
|
|
3309
|
-
commands: resolveCustomCommands(lineConfig.commands)
|
|
4049
|
+
commands: resolveCustomCommands(lineConfig.commands),
|
|
4050
|
+
turnTimeoutMs: resolveTurnTimeoutMs(lineConfig)
|
|
3310
4051
|
});
|
|
4052
|
+
out$1(`单轮上限:${Math.round(resolveTurnTimeoutMs(lineConfig) / 6e4)} 分钟(config.json 的 turnTimeoutMinutes)`);
|
|
3311
4053
|
const handles = [];
|
|
3312
4054
|
const failures = [];
|
|
3313
4055
|
let shuttingDown = false;
|
|
@@ -3315,6 +4057,7 @@ async function runLineDaemon() {
|
|
|
3315
4057
|
if (shuttingDown) return;
|
|
3316
4058
|
shuttingDown = true;
|
|
3317
4059
|
log.info("line: shutting down", { signal });
|
|
4060
|
+
err(`正在等待当前回合结束(最多 ${SHUTDOWN_GRACE_MS / 1e3}s),收到再次信号将立即退出`);
|
|
3318
4061
|
const forceTimer = setTimeout(() => {
|
|
3319
4062
|
err(`line: shutdown exceeded ${SHUTDOWN_GRACE_MS / 1e3}s, forcing exit`);
|
|
3320
4063
|
process.exit(1);
|
|
@@ -3350,6 +4093,7 @@ async function runLineDaemon() {
|
|
|
3350
4093
|
await gracefulShutdown(handles, driver);
|
|
3351
4094
|
process.exit(1);
|
|
3352
4095
|
}
|
|
4096
|
+
await driver.probeVoice();
|
|
3353
4097
|
if (lineConfig.feishu) try {
|
|
3354
4098
|
handles.push(await startFeishu(lineConfig.feishu, driver));
|
|
3355
4099
|
} catch (error) {
|
|
@@ -3360,6 +4104,19 @@ async function runLineDaemon() {
|
|
|
3360
4104
|
} catch (error) {
|
|
3361
4105
|
failures.push(`微信:${error instanceof Error ? error.message : String(error)}`);
|
|
3362
4106
|
}
|
|
4107
|
+
const proactivePlatforms = new Map(handles.map((handle) => [handle.name, handle]));
|
|
4108
|
+
driver.setProactiveSender(async (sessionKey, text) => {
|
|
4109
|
+
const name = sessionKey.startsWith("line-feishu-") ? "feishu" : sessionKey.startsWith("line-weixin-") ? "weixin" : void 0;
|
|
4110
|
+
const platform = name === void 0 ? void 0 : proactivePlatforms.get(name);
|
|
4111
|
+
if (platform === void 0) return "no-target";
|
|
4112
|
+
return platform.sendProactive(sessionKey, text);
|
|
4113
|
+
});
|
|
4114
|
+
driver.setMediaSenderResolver((sessionKey) => {
|
|
4115
|
+
const name = sessionKey.startsWith("line-feishu-") ? "feishu" : sessionKey.startsWith("line-weixin-") ? "weixin" : void 0;
|
|
4116
|
+
const platform = name === void 0 ? void 0 : proactivePlatforms.get(name);
|
|
4117
|
+
if (platform === void 0) return void 0;
|
|
4118
|
+
return (kind, path) => platform.sendMedia(sessionKey, kind, path);
|
|
4119
|
+
});
|
|
3363
4120
|
for (const failure of failures) err(`平台启动失败:${failure}`);
|
|
3364
4121
|
if (handles.length === 0) {
|
|
3365
4122
|
err("所有平台启动失败,line 守护进程退出。");
|
|
@@ -3387,20 +4144,29 @@ async function startFeishu(cfg, driver) {
|
|
|
3387
4144
|
const platform = new FeishuPlatform(client, options, async (message) => {
|
|
3388
4145
|
try {
|
|
3389
4146
|
driver.setMediaSender(message.sessionKey, (kind, path) => platform.sendMedia(message.sessionKey, kind, path));
|
|
3390
|
-
const
|
|
4147
|
+
const outcome = await driver.submit({
|
|
3391
4148
|
sessionKey: message.sessionKey,
|
|
3392
4149
|
text: message.text,
|
|
3393
4150
|
images: message.images,
|
|
3394
|
-
files: message.files
|
|
3395
|
-
|
|
4151
|
+
files: message.files,
|
|
4152
|
+
onQueued: (position) => {
|
|
4153
|
+
const notice = buildReplyContent(queuedNotice(position));
|
|
4154
|
+
client.reply(message.context.messageId, notice.msgType, notice.body).catch((error) => {
|
|
4155
|
+
log.warn("line: feishu queued notice failed", { error: String(error) });
|
|
4156
|
+
});
|
|
4157
|
+
}
|
|
4158
|
+
});
|
|
4159
|
+
if (outcome.silent === true) return;
|
|
4160
|
+
const outbound = buildReplyContent(truncateForLine(outcome.text));
|
|
3396
4161
|
await client.reply(message.context.messageId, outbound.msgType, outbound.body);
|
|
3397
4162
|
} catch (error) {
|
|
3398
4163
|
log.error("line: feishu reply failed", {
|
|
3399
4164
|
messageId: message.context.messageId,
|
|
3400
4165
|
error: String(error)
|
|
3401
4166
|
});
|
|
4167
|
+
} finally {
|
|
4168
|
+
await platform.finishMessage(message.context.messageId).catch(() => {});
|
|
3402
4169
|
}
|
|
3403
|
-
await platform.finishMessage(message.context.messageId).catch(() => {});
|
|
3404
4170
|
});
|
|
3405
4171
|
await client.connect(async (event) => {
|
|
3406
4172
|
try {
|
|
@@ -3422,6 +4188,8 @@ async function startFeishu(cfg, driver) {
|
|
|
3422
4188
|
});
|
|
3423
4189
|
return {
|
|
3424
4190
|
name: "feishu",
|
|
4191
|
+
sendProactive: (sessionKey, text) => platform.sendProactive(sessionKey, text),
|
|
4192
|
+
sendMedia: (sessionKey, kind, path) => platform.sendMedia(sessionKey, kind, path),
|
|
3425
4193
|
close: async () => {
|
|
3426
4194
|
platform.stop();
|
|
3427
4195
|
await client.close();
|
|
@@ -3441,8 +4209,14 @@ async function startWeixin(cfg, driver) {
|
|
|
3441
4209
|
sessionKey: message.sessionKey,
|
|
3442
4210
|
text: message.text,
|
|
3443
4211
|
images: message.images,
|
|
3444
|
-
files: message.files
|
|
4212
|
+
files: message.files,
|
|
4213
|
+
onQueued: (position) => {
|
|
4214
|
+
platform.replyText(message.context, queuedNotice(position)).catch((error) => {
|
|
4215
|
+
log.warn("line: weixin queued notice failed", { error: String(error) });
|
|
4216
|
+
});
|
|
4217
|
+
}
|
|
3445
4218
|
});
|
|
4219
|
+
if (outcome.silent === true) return;
|
|
3446
4220
|
await platform.replyText(message.context, outcome.text);
|
|
3447
4221
|
} catch (error) {
|
|
3448
4222
|
log.error("line: weixin reply failed", {
|
|
@@ -3459,6 +4233,8 @@ async function startWeixin(cfg, driver) {
|
|
|
3459
4233
|
log.info("line: weixin platform started", { baseUrl: options.baseUrl });
|
|
3460
4234
|
return {
|
|
3461
4235
|
name: "weixin",
|
|
4236
|
+
sendProactive: (sessionKey, text) => platform.sendProactive(sessionKey, text),
|
|
4237
|
+
sendMedia: (sessionKey, kind, path) => platform.sendMedia(sessionKey, kind, path),
|
|
3462
4238
|
close: async () => {
|
|
3463
4239
|
await platform.stop();
|
|
3464
4240
|
}
|
|
@@ -3502,6 +4278,11 @@ function scheduleGroupFilterSupervisor(client) {
|
|
|
3502
4278
|
}
|
|
3503
4279
|
async function gracefulShutdown(handles, driver) {
|
|
3504
4280
|
await driver.waitIdle(SHUTDOWN_GRACE_MS).catch(() => {});
|
|
4281
|
+
const abandoned = driver.pendingTurnCount();
|
|
4282
|
+
if (abandoned > 0) {
|
|
4283
|
+
log.warn("line: shutdown timed out with pending turns", { pending: abandoned });
|
|
4284
|
+
err(`警告:仍有 ${abandoned} 个回合未完成,退出后这些消息不会有回复`);
|
|
4285
|
+
}
|
|
3505
4286
|
for (const handle of handles) try {
|
|
3506
4287
|
await handle.close();
|
|
3507
4288
|
} catch {}
|
|
@@ -5413,121 +6194,6 @@ var EditorKeyboardController = class {
|
|
|
5413
6194
|
}
|
|
5414
6195
|
};
|
|
5415
6196
|
//#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
6197
|
//#region src/tui/utils/background-agent-status.ts
|
|
5532
6198
|
const MAX_BACKGROUND_FIELD_LENGTH = 240;
|
|
5533
6199
|
function normalizeBackgroundField(value) {
|
|
@@ -19536,7 +20202,7 @@ var ScreamTUI = class {
|
|
|
19536
20202
|
const card = new OnboardingCardComponent({
|
|
19537
20203
|
colors: this.state.theme.colors,
|
|
19538
20204
|
onConfigure: () => {
|
|
19539
|
-
import("./dispatch-
|
|
20205
|
+
import("./dispatch-DNYdiEo4.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
|
|
19540
20206
|
this.restoreEditor();
|
|
19541
20207
|
onDone?.();
|
|
19542
20208
|
});
|