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.
@@ -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 { $ as clearGoalState, $n as validateFeishuCredentials, A as langFromPath, An as embeddingFailureDetail, Ar as resolveGlobalLogPath, At as isTurnElapsedEnabled, B as charsForTokenBudget, Bn as WeixinApiError, Bt as ENABLE_TERMINAL_THEME_REPORTING, Cn as getNoActiveSessionMessage, Cr as ScreamHarness, Ct as clearInfoPanelState, D as renderDiffLines, Dn as isExperimentalFlagEnabled, Dr as resolveScreamHome, Dt as createEditorTheme, E as ToolCallComponent, En as sortSlashCommands, Er as isRealUserPrompt, Et as resolveThemeSync, F as replaceTabs, Fn as PULSE_WAVE_FRAMES, Ft as detectTerminalTheme, G as lerpHex, Gn as buildCdnDownloadUrl, Gt as QUERY_TERMINAL_THEME, H as getSharedSpeedTracker, Hn as isSendThrottled, Ht as OSC11_RESPONSE, I as sanitizeShellOutput, In as readLineDaemonStatus, It as parseOsc11BackgroundTheme, J as WelcomeComponent, Jn as TuiConfigParseError, Jt as TERMINAL_THEME_DARK, K as BackgroundAgentStatusComponent, Kn as decryptAesEcb, Kt as TERMINAL_FOCUS_IN, L as SkillActivationComponent, Ln as startLineDaemonDetached, Lt as DISABLE_TERMINAL_FOCUS_REPORTING, M as ThinkingComponent, Mn as hasEmbeddingModelCache, Mr as isOrphanedToolCallError, Mt as createThemeStyles, N as NoticeMessageComponent, Nn as startManualEmbeddingDownload, Nr as SCREAM_ERROR_INFO, Nt as contrastTextHex, O as renderDiffLinesClustered, On as setExperimentalFlags, Or as flushDiagnosticLogs, Ot as createMarkdownTheme, P as StatusMessageComponent, Pn as PIXEL_PULSE_FRAMES, Pt as getColorPalette, Q as CompactionComponent, Qn as feishuFileType, R as AssistantMessageComponent, Rn as stopLineDaemon, Rt as DISABLE_TERMINAL_THEME_REPORTING, S as readJsonlFile, Sn as getLlmNotSetMessage, Sr as saveCatalogCache, St as supportsBalance, T as UserMessageComponent, Tn as BUILTIN_SLASH_COMMANDS, U as GRADIENT_CYCLE_MS, Un as validateWeixinToken, Ut as OSC11_RESPONSE_PREFIX, V as estimateTokens, Vn as WeixinClient, Vt as OSC11_QUERY, W as lerpGradient, Wt as OSC11_RESPONSE_PREFIX_NO_ESC, X as getBreathingFrame, Xn as FeishuClient, Xt as safeUsageRatio, Y as BREATHE_CYCLE_MS, Yn as loadTuiConfig, Yt as TERMINAL_THEME_LIGHT, Z as resetBreathingClock, Zn as FeishuCredentialError, Zt as SubagentSlots, _n as MAIN_AGENT_ID, _r as CLI_UI_MODE, a as writeTextClipboard, an as STATUS_BULLET, ar as getWeixinCursorFile, at as toTerminalHyperlink, b as readUpdateCache, bn as getCtrlCHint, br as DEFAULT_CATALOG_URL, cn as argsRecord, cr as resolveCustomCommands, d as isExpandable, dn as parseStreamingArgs, dr as detectShellEnvironment, er as LineConfigError, f as isPlanExpandable, fn as serializeToolResultOutput, fr as getDataDir, ft as handleFusionPlanCommand, gn as EXIT_CONFIRM_WINDOW_MS, gr as CLI_COMMAND_NAME, hr as detectInstallSource, i as openUrl, in as printableChar, ir as getWeixinContextTokensFile, j as CachedContainer, jn as getKnowledgeStore, jr as isScreamError, jt as toggleEmptySessionHint, k as highlightLines, kn as embeddingProgressLabel, kr as log, kt as isEmptySessionHintDismissed, l as disposeChildren, ln as formatErrorMessage, lr as resolveFeishuOptions, m as formatMemoryMemoForInjection, mn as truncateErrorMessage, mr as getLogDir, mt as handlePlanCommand, n as clearEvalPanelState, nn as isBusy, nr as getLineConfigPath, o as syncDiagramCapabilityPrompt, on as appendSessionHeaderHint, or as getWeixinStateDir, ot as changeThinkingLevel, p as MoonLoader, pn as stringValue, pr as getInputHistoryFile, q as AgentGroupComponent, qn as parseAesKey, qt as TERMINAL_FOCUS_OUT, rn as isStreaming, rr as getLinePidFile, sn as appendStreamingArgsPreview, sr as loadLineConfig, st as getModelCycleLevel, t as dispatchInput, tr as assertSinglePlatformConfig, u as hasDispose, un as isTodoItemShape, ur as resolveWeixinOptions, v as selectUpdateTarget, vn as SESSION_TIPS, vr as CLI_USER_AGENT_PRODUCT, wn as buildSkillSlashCommands, wr as normalizeWorkDir, x as appendJsonlLine, xn as getCtrlDHint, xr as fetchCatalog, xt as refreshProviderBalance, y as refreshUpdateCache, yn as TIP_ROTATION_INTERVAL_MS, yr as PRODUCT_NAME, z as ActivityGroupComponent, zn as UPLOAD_MEDIA_TYPE, zt as ENABLE_TERMINAL_FOCUS_REPORTING } from "./dispatch-BziMqJ_r.mjs";
9
- import { i as t, n as getLocale, r as setLocale } from "./text-input-dialog-yvsx17gg.mjs";
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.3"),
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/session-driver.ts
1995
+ //#region src/line/voice-support.ts
1850
1996
  /**
1851
- * Line session driver — maps Feishu chats to scream agent sessions.
1997
+ * Startup capability probe for outbound voice.
1852
1998
  *
1853
- * Uses the same headless layer as `scream stream-json` (`ScreamHarness`),
1854
- * including the create/resume/repair session lifecycle (ported from
1855
- * `run-stream-json.ts` L659-726) and the approval-handler mapping. Turns are
1856
- * serialized per session (one FIFO chain each) and run in parallel across
1857
- * sessions; a bounded queue rejects new work with a friendly busy reply
1858
- * instead of growing unbounded.
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 MAX_QUEUE_PER_SESSION = 3;
1861
- const ORPHAN_RESET_NOTICE = "会话已自动重置,请重新发送你的消息。";
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
- /** A turn that never settles is cancelled after this long (product default). */
1916
- const TURN_TIMEOUT_MS = 900 * 1e3;
1917
- const TURN_TIMEOUT_TEXT = "本轮执行超时(15 分钟),已取消。请重试或拆分任务。";
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
- /** Deadline timer for the running turn (see `turnDeadline`). */
1947
- turnTimer;
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 `TURN_TIMEOUT_MS`. Raced against the turn promise so a
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
- return new Promise((resolve) => {
2036
- this.turnTimer = setTimeout(resolve, TURN_TIMEOUT_MS);
2037
- this.turnTimer.unref?.();
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
- clearTurnDeadline() {
2041
- if (this.turnTimer !== void 0) {
2042
- clearTimeout(this.turnTimer);
2043
- this.turnTimer = void 0;
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([turnPromise, this.turnDeadline()]);
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: TURN_TIMEOUT_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
- this.clearTurnDeadline();
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
- const SHUTDOWN_GRACE_MS = 1e4;
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 outbound = buildReplyContent(truncateForLine((await driver.submit({
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
- })).text));
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-CB9ZdDBD.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
20205
+ import("./dispatch-DNYdiEo4.mjs").then(({ handleConnectCommand }) => handleConnectCommand(this, "")).finally(() => {
19540
20206
  this.restoreEditor();
19541
20207
  onDone?.();
19542
20208
  });