mixdog 0.9.1 → 0.9.3
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/package.json +9 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/anthropic-maxtokens-test.mjs +119 -0
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/build-tui.mjs +13 -1
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/explore-bench.mjs +124 -0
- package/scripts/hook-bus-test.mjs +191 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/path-suffix-test.mjs +57 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/recall-bench.mjs +207 -0
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +310 -67
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +20 -9
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +17 -9
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +1040 -2036
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +17 -7
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +8 -12
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +13 -2
- package/src/rules/shared/01-tool.md +23 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
- package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +437 -1439
- package/src/runtime/channels/lib/boot-profile.mjs +23 -0
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/crash-log.mjs +106 -0
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +280 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/lib/whisper-language.mjs +42 -0
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +465 -345
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
- package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
- package/src/runtime/memory/lib/http-wire.mjs +57 -0
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
- package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
- package/src/runtime/memory/lib/memory.mjs +121 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
- package/src/runtime/memory/lib/recall-format.mjs +183 -0
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +10 -7
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/background-tasks.mjs +2 -3
- package/src/runtime/shared/buffered-appender.mjs +149 -0
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/task-notification-envelope.mjs +98 -0
- package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
- package/src/runtime/shared/tool-execution-contract.mjs +2 -2
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +156 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/session-runtime/config-helpers.mjs +209 -0
- package/src/session-runtime/effort.mjs +128 -0
- package/src/session-runtime/fs-utils.mjs +10 -0
- package/src/session-runtime/model-capabilities.mjs +130 -0
- package/src/session-runtime/output-styles.mjs +124 -0
- package/src/session-runtime/plugin-mcp.mjs +114 -0
- package/src/session-runtime/session-text.mjs +100 -0
- package/src/session-runtime/statusline-route.mjs +35 -0
- package/src/session-runtime/tool-catalog.mjs +720 -0
- package/src/session-runtime/workflow.mjs +358 -0
- package/src/standalone/agent-tool.mjs +302 -117
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +10 -292
- package/src/standalone/explore-tool.mjs +12 -5
- package/src/standalone/hook-bus.mjs +165 -8
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/opencode-go-login.mjs +121 -0
- package/src/standalone/provider-admin.mjs +25 -3
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2883 -778
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +355 -22
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +183 -101
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +161 -23
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6731 -2333
- package/src/tui/engine/agent-envelope.mjs +296 -0
- package/src/tui/engine/boot-profile.mjs +21 -0
- package/src/tui/engine/labels.mjs +67 -0
- package/src/tui/engine/notice-text.mjs +112 -0
- package/src/tui/engine/queue-helpers.mjs +161 -0
- package/src/tui/engine/session-stats.mjs +46 -0
- package/src/tui/engine/tool-call-fields.mjs +23 -0
- package/src/tui/engine/tool-result-text.mjs +126 -0
- package/src/tui/engine.mjs +569 -948
- package/src/tui/index.jsx +117 -7
- package/src/tui/input-editing.mjs +58 -8
- package/src/tui/input-editing.selection.test.mjs +75 -0
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +469 -0
- package/src/tui/markdown/format-token.mjs +128 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +36 -9
- package/src/tui/paste-fix.test.mjs +119 -0
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +80 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +75 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +46 -12
- package/src/workflows/sequential/WORKFLOW.md +51 -0
- package/src/workflows/solo/WORKFLOW.md +12 -1
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- package/src/workflows/solo/workflow.json +0 -7
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { performance } from "perf_hooks";
|
|
2
|
+
|
|
3
|
+
// Boot-timing instrumentation + shared local timestamp helper.
|
|
4
|
+
// Extracted verbatim from channels/index.mjs (behavior-preserving).
|
|
5
|
+
const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ""));
|
|
6
|
+
const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
|
|
7
|
+
|
|
8
|
+
function bootProfile(event, fields = {}) {
|
|
9
|
+
if (!BOOT_PROFILE_ENABLED) return;
|
|
10
|
+
const elapsedMs = performance.now() - BOOT_PROFILE_START;
|
|
11
|
+
const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `channels:${event}`];
|
|
12
|
+
for (const [key, value] of Object.entries(fields || {})) {
|
|
13
|
+
if (value === undefined || value === null || value === "") continue;
|
|
14
|
+
parts.push(`${key}=${String(value).replace(/\s+/g, "_")}`);
|
|
15
|
+
}
|
|
16
|
+
try { process.stderr.write(`${parts.join(" ")}\n`); } catch {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function localTimestamp() {
|
|
20
|
+
return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { BOOT_PROFILE_ENABLED, BOOT_PROFILE_START, bootProfile, localTimestamp };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync, mkdirSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { DiscordBackend } from "../backends/discord.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { TelegramBackend } from "../backends/telegram.mjs";
|
|
5
|
+
import { readSection, updateSection, CONFIG_PATH as MIXDOG_CONFIG_PATH, getDiscordToken, getTelegramToken, diagnoseDiscordTokenValue } from "../../shared/config.mjs";
|
|
5
6
|
import { listSchedules } from "../../shared/schedules-store.mjs";
|
|
6
7
|
import { resolvePluginData } from "../../shared/plugin-paths.mjs";
|
|
7
8
|
import { isHolidaySync } from "./holidays.mjs";
|
|
@@ -15,6 +16,7 @@ const DEFAULT_ACCESS = {
|
|
|
15
16
|
const DEFAULT_CONFIG = {
|
|
16
17
|
backend: "discord",
|
|
17
18
|
discord: { token: "" },
|
|
19
|
+
telegram: { token: "" },
|
|
18
20
|
access: DEFAULT_ACCESS,
|
|
19
21
|
mainChannel: "main",
|
|
20
22
|
channelsConfig: {
|
|
@@ -47,6 +49,22 @@ function applyDefaults(config) {
|
|
|
47
49
|
out.webhook = { ...CONFIG_DEFAULTS.webhook, ...(out.webhook || {}) };
|
|
48
50
|
return out;
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
function channelIdForBackend(entry = {}, backend = "discord") {
|
|
54
|
+
if (backend === "telegram") {
|
|
55
|
+
return String(entry?.telegramChatId || (entry?.discordChannelId ? "" : entry?.channelId) || "");
|
|
56
|
+
}
|
|
57
|
+
return String(entry?.discordChannelId || (entry?.telegramChatId ? "" : entry?.channelId) || "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function channelsConfigForBackend(rawChannelsConfig = {}, backend = "discord") {
|
|
61
|
+
return Object.fromEntries(Object.entries(rawChannelsConfig || {}).map(([name, entry]) => {
|
|
62
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
63
|
+
const channelId = channelIdForBackend(value, backend);
|
|
64
|
+
return [name, { ...value, channelId }];
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
/**
|
|
51
69
|
* Shared DND / quiet-window helper used by scheduler + webhook.
|
|
52
70
|
*
|
|
@@ -185,11 +203,20 @@ function loadConfig() {
|
|
|
185
203
|
if (discordTokenProblem) {
|
|
186
204
|
process.stderr.write(`mixdog: discord token ignored: ${discordTokenProblem}\n`);
|
|
187
205
|
}
|
|
206
|
+
// Single-backend select: config.backend picks ONE of discord|telegram.
|
|
207
|
+
// Anything else falls back to the discord default.
|
|
208
|
+
const backend = raw.backend === "telegram" ? "telegram" : "discord";
|
|
209
|
+
const telegramToken = getTelegramToken();
|
|
210
|
+
const rawChannelsConfig = { ...DEFAULT_CONFIG.channelsConfig, ...(raw.channelsConfig || {}) };
|
|
188
211
|
return applyDefaults({
|
|
189
212
|
...DEFAULT_CONFIG,
|
|
190
213
|
...raw,
|
|
191
|
-
backend
|
|
214
|
+
backend,
|
|
215
|
+
channelsConfig: channelsConfigForBackend(rawChannelsConfig, backend),
|
|
192
216
|
discord: { ...DEFAULT_CONFIG.discord, ...(({ token: _, ...rest }) => rest)(raw.discord || {}), ...(discordToken && !discordTokenProblem ? { token: discordToken } : {}) },
|
|
217
|
+
// Merge the keychain-resolved telegram token (harmless when backend is
|
|
218
|
+
// discord; the secret never lands in the on-disk config either way).
|
|
219
|
+
telegram: { ...DEFAULT_CONFIG.telegram, ...(({ token: _t, ...rest }) => rest)(raw.telegram || {}), ...(telegramToken ? { token: telegramToken } : {}) },
|
|
193
220
|
access: {
|
|
194
221
|
...DEFAULT_ACCESS,
|
|
195
222
|
// Drop the retired pairing-era keys at the config layer too (the
|
|
@@ -218,6 +245,10 @@ function loadConfig() {
|
|
|
218
245
|
}
|
|
219
246
|
const HEADLESS_BACKEND = {
|
|
220
247
|
name: "headless",
|
|
248
|
+
MAX_MESSAGE_LENGTH: 2000,
|
|
249
|
+
formatOutgoing(t) {
|
|
250
|
+
return t;
|
|
251
|
+
},
|
|
221
252
|
async connect() {
|
|
222
253
|
},
|
|
223
254
|
async disconnect() {
|
|
@@ -244,6 +275,26 @@ const HEADLESS_BACKEND = {
|
|
|
244
275
|
}
|
|
245
276
|
};
|
|
246
277
|
function createBackend(config) {
|
|
278
|
+
// Single-backend select: exactly one backend is constructed based on
|
|
279
|
+
// config.backend (discord|telegram). The two are mutually exclusive.
|
|
280
|
+
if (config.backend === "telegram") {
|
|
281
|
+
const telegramToken = getTelegramToken();
|
|
282
|
+
if (!telegramToken) {
|
|
283
|
+
process.stderr.write("mixdog: telegram bot not configured; channel runtime running in headless mode\n");
|
|
284
|
+
return HEADLESS_BACKEND;
|
|
285
|
+
}
|
|
286
|
+
const tgStateDir = config.telegram?.stateDir ?? join(DATA_DIR, "telegram");
|
|
287
|
+
mkdirSync(tgStateDir, { recursive: true });
|
|
288
|
+
return new TelegramBackend({
|
|
289
|
+
...config.telegram,
|
|
290
|
+
configPath: CONFIG_FILE,
|
|
291
|
+
access: config.access,
|
|
292
|
+
// Single-source channel setup: the main chat is auto-allowed inside
|
|
293
|
+
// TelegramBackend.loadAccess() so a configured channelsConfig.main.channelId
|
|
294
|
+
// is enough for both inbound gating and outbound.
|
|
295
|
+
mainChannelId: config.channelsConfig?.main?.channelId
|
|
296
|
+
}, tgStateDir);
|
|
297
|
+
}
|
|
247
298
|
const discordToken = getDiscordToken();
|
|
248
299
|
const discordTokenProblem = diagnoseDiscordTokenValue(discordToken, config);
|
|
249
300
|
if (discordTokenProblem) {
|
|
@@ -278,6 +329,7 @@ export {
|
|
|
278
329
|
DEFAULT_HOLIDAY_COUNTRY,
|
|
279
330
|
createBackend,
|
|
280
331
|
getDiscordToken,
|
|
332
|
+
getTelegramToken,
|
|
281
333
|
isInQuietWindow,
|
|
282
334
|
loadConfig,
|
|
283
335
|
loadProfileConfig
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { DATA_DIR } from "./config.mjs";
|
|
4
|
+
import { localTimestamp } from "./boot-profile.mjs";
|
|
5
|
+
|
|
6
|
+
// Crash logging + degraded-state tracking for the channels worker.
|
|
7
|
+
// Extracted verbatim from channels/index.mjs (behavior-preserving).
|
|
8
|
+
//
|
|
9
|
+
// Degraded/stderr-broken state is module-scoped here; index.mjs reads it via
|
|
10
|
+
// isChannelsDegraded() and installs the process-level unhandledRejection/
|
|
11
|
+
// uncaughtException handlers (which need the worker's stop()).
|
|
12
|
+
let crashLogging = false;
|
|
13
|
+
let _channelsDegraded = false;
|
|
14
|
+
let _stderrBroken = false;
|
|
15
|
+
function isChannelsDegraded() { return _channelsDegraded; }
|
|
16
|
+
|
|
17
|
+
// stderr can break when the parent stdio pipe closes. Node then emits an
|
|
18
|
+
// async 'error' on process.stderr, which sync try/catch around write() does
|
|
19
|
+
// not catch — without a listener, that error becomes uncaughtException and
|
|
20
|
+
// re-enters logCrash, looping until the disk fills. Register a suppressor
|
|
21
|
+
// once at load time and stop writing to stderr after the first EPIPE so the
|
|
22
|
+
// loop cannot start.
|
|
23
|
+
try {
|
|
24
|
+
process.stderr.on('error', (e) => {
|
|
25
|
+
if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
|
|
26
|
+
_stderrBroken = true;
|
|
27
|
+
_channelsDegraded = true;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
} catch {}
|
|
31
|
+
|
|
32
|
+
// Crash log guards: dedup repeated identical errors (a single broken handler
|
|
33
|
+
// can fire thousands of times per minute) and rotate at a 10 MB cap so the
|
|
34
|
+
// file cannot grow unbounded. One .old generation is kept; older rolls drop.
|
|
35
|
+
const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
36
|
+
let _lastCrashSig = "";
|
|
37
|
+
let _crashRepeatCount = 0;
|
|
38
|
+
|
|
39
|
+
function _writeCrashLine(crashLog, line) {
|
|
40
|
+
try {
|
|
41
|
+
let size = 0;
|
|
42
|
+
try { size = fs.statSync(crashLog).size; } catch {}
|
|
43
|
+
if (size + line.length > CRASH_LOG_MAX_BYTES) {
|
|
44
|
+
try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
|
|
45
|
+
}
|
|
46
|
+
fs.appendFileSync(crashLog, line);
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function logCrash(label, err) {
|
|
51
|
+
if (crashLogging) return;
|
|
52
|
+
crashLogging = true;
|
|
53
|
+
const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
|
|
54
|
+
${err instanceof Error ? err.stack : ""}
|
|
55
|
+
`;
|
|
56
|
+
if (!_stderrBroken) {
|
|
57
|
+
try { process.stderr.write(msg); } catch (e) {
|
|
58
|
+
if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
|
|
59
|
+
_stderrBroken = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const sig = `${label}|${err && err.message ? err.message : String(err)}`;
|
|
64
|
+
const crashLog = path.join(DATA_DIR, "crash.log");
|
|
65
|
+
if (sig === _lastCrashSig) {
|
|
66
|
+
// Same error repeating — count it but skip the disk write. The next
|
|
67
|
+
// distinct error (or EPIPE branch below) flushes the suppressed total.
|
|
68
|
+
_crashRepeatCount += 1;
|
|
69
|
+
} else {
|
|
70
|
+
if (_crashRepeatCount > 0) {
|
|
71
|
+
_writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
|
|
72
|
+
_crashRepeatCount = 0;
|
|
73
|
+
}
|
|
74
|
+
_lastCrashSig = sig;
|
|
75
|
+
_writeCrashLine(crashLog, msg);
|
|
76
|
+
}
|
|
77
|
+
if (err instanceof Error && err.message.includes("EPIPE")) {
|
|
78
|
+
_channelsDegraded = true;
|
|
79
|
+
_stderrBroken = true;
|
|
80
|
+
}
|
|
81
|
+
crashLogging = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Benign whitelist: transient EPERM/EACCES/EBUSY on the active-instance
|
|
85
|
+
// rename path is expected under Windows file-lock contention and is
|
|
86
|
+
// already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
|
|
87
|
+
// occurrence must NOT be fatal, only a run of 3+ in a row without an
|
|
88
|
+
// intervening distinct/successful event.
|
|
89
|
+
const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
90
|
+
const BENIGN_CRASH_FATAL_THRESHOLD = 3;
|
|
91
|
+
// "In a row" needs a time dimension: benign errors minutes/hours apart are
|
|
92
|
+
// independent contention events, not a corrupted-state run. Only count a
|
|
93
|
+
// streak when hits land within this window of the previous one.
|
|
94
|
+
const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
|
|
95
|
+
function _isBenignCrash(err) {
|
|
96
|
+
const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
|
|
97
|
+
return BENIGN_CRASH_CODES.has(code);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export {
|
|
101
|
+
isChannelsDegraded,
|
|
102
|
+
logCrash,
|
|
103
|
+
_isBenignCrash,
|
|
104
|
+
BENIGN_CRASH_FATAL_THRESHOLD,
|
|
105
|
+
BENIGN_CRASH_STREAK_WINDOW_MS,
|
|
106
|
+
};
|
|
@@ -140,7 +140,8 @@ function safeCodeBlock(content, lang = "") {
|
|
|
140
140
|
const escaped = content.replace(/```/g, "```");
|
|
141
141
|
return "```" + lang + "\n" + escaped + "\n```";
|
|
142
142
|
}
|
|
143
|
-
|
|
143
|
+
const MAX_DISCORD_MESSAGE = 2000;
|
|
144
|
+
function chunk(text, limit = MAX_DISCORD_MESSAGE) {
|
|
144
145
|
if (text.length <= limit) return [text];
|
|
145
146
|
const out = [];
|
|
146
147
|
let rest = text;
|
|
@@ -184,5 +185,6 @@ function chunk(text, limit = 2e3) {
|
|
|
184
185
|
export {
|
|
185
186
|
chunk,
|
|
186
187
|
formatForDiscord,
|
|
187
|
-
safeCodeBlock
|
|
188
|
+
safeCodeBlock,
|
|
189
|
+
MAX_DISCORD_MESSAGE
|
|
188
190
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { DATA_DIR } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
// Drop-trace instrumentation for channels/index.mjs.
|
|
6
|
+
// Extracted verbatim (behavior-preserving). Distinct from lib/drop-trace.mjs
|
|
7
|
+
// (which the output-forwarder uses): this instance is owned by index.mjs and
|
|
8
|
+
// keeps its own buffered writer + `preview` helper so the two trace streams
|
|
9
|
+
// stay byte-identical to the pre-split behavior.
|
|
10
|
+
const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
|
|
11
|
+
const DROP_TRACE_ENABLED =
|
|
12
|
+
process.env.MIXDOG_DROP_TRACE === "1" ||
|
|
13
|
+
process.env.MIXDOG_DROP_TRACE === "true" ||
|
|
14
|
+
process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
|
|
15
|
+
process.env.MIXDOG_DEBUG_CHANNELS === "true";
|
|
16
|
+
// One-shot rotation for drop-trace.log at worker boot.
|
|
17
|
+
if (DROP_TRACE_ENABLED) {
|
|
18
|
+
try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Buffered drop-trace writer (channels/index) ──────────────────────────────
|
|
22
|
+
// Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
|
|
23
|
+
// Drains on process exit so no log lines are lost.
|
|
24
|
+
let _dtIdxBuf = "";
|
|
25
|
+
let _dtIdxBytes = 0;
|
|
26
|
+
let _dtIdxFlushTimer = null;
|
|
27
|
+
let _dtIdxStream = null;
|
|
28
|
+
function _dtIdxGetStream() {
|
|
29
|
+
if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
|
|
30
|
+
return _dtIdxStream;
|
|
31
|
+
}
|
|
32
|
+
async function _dtIdxFlush() {
|
|
33
|
+
if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
|
|
34
|
+
if (!_dtIdxBuf) return;
|
|
35
|
+
const stream = _dtIdxGetStream();
|
|
36
|
+
const buf = _dtIdxBuf;
|
|
37
|
+
_dtIdxBuf = "";
|
|
38
|
+
_dtIdxBytes = 0;
|
|
39
|
+
try {
|
|
40
|
+
const ok = stream.write(buf);
|
|
41
|
+
if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
function _dtIdxScheduleFlush() {
|
|
45
|
+
if (_dtIdxFlushTimer) return;
|
|
46
|
+
_dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
|
|
47
|
+
if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
|
|
48
|
+
}
|
|
49
|
+
function _dtIdxAppend(line) {
|
|
50
|
+
_dtIdxBuf += line;
|
|
51
|
+
_dtIdxBytes += Buffer.byteLength(line);
|
|
52
|
+
if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
|
|
53
|
+
_dtIdxScheduleFlush();
|
|
54
|
+
}
|
|
55
|
+
process.on("exit", () => { void _dtIdxFlush(); });
|
|
56
|
+
|
|
57
|
+
function preview(text) {
|
|
58
|
+
if (!text) return "";
|
|
59
|
+
const s = String(text).replace(/\n/g, "\\n");
|
|
60
|
+
return s.length > 120 ? s.slice(0, 120) + "…" : s;
|
|
61
|
+
}
|
|
62
|
+
function dropTrace(event, fields) {
|
|
63
|
+
if (!DROP_TRACE_ENABLED) return;
|
|
64
|
+
try {
|
|
65
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
66
|
+
const loc = `[${ts}][pid=${process.pid}] ${event}`;
|
|
67
|
+
const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
|
|
68
|
+
_dtIdxAppend(loc + kv + "\n");
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { DROP_TRACE_ENABLED, dropTrace, preview, _dtIdxFlush };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync, watch, openSync, readSync, closeSync } from "fs";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { safeCodeBlock } from "./format.mjs";
|
|
4
4
|
import { dropTrace, _dtPreview } from "./drop-trace.mjs";
|
|
5
5
|
import {
|
|
6
6
|
formatToolSurface,
|
|
@@ -154,6 +154,14 @@ class OutputForwarder {
|
|
|
154
154
|
try {
|
|
155
155
|
const stat = this._pendingStat ?? statSync(this.transcriptPath);
|
|
156
156
|
this._pendingStat = null;
|
|
157
|
+
// File shrank below our cursor: the transcript was rotated out from
|
|
158
|
+
// under us (buffered-appender rotation, or an external truncation).
|
|
159
|
+
// Treat it as a fresh file and reset the cursor instead of getting
|
|
160
|
+
// stuck forever (stat.size <= readFileSize would otherwise short
|
|
161
|
+
// circuit below and never read the new content).
|
|
162
|
+
if (stat.size < this.readFileSize) {
|
|
163
|
+
this.readFileSize = 0;
|
|
164
|
+
}
|
|
157
165
|
if (stat.size <= this.readFileSize) {
|
|
158
166
|
return { lines: [], nextFileSize: this.readFileSize };
|
|
159
167
|
}
|
|
@@ -282,7 +290,12 @@ class OutputForwarder {
|
|
|
282
290
|
this.commitReadProgress(item.nextFileSize);
|
|
283
291
|
return;
|
|
284
292
|
}
|
|
285
|
-
|
|
293
|
+
// Formatting is routed through the active backend via the send-callback so
|
|
294
|
+
// the forwarder stays backend-agnostic (Discord/Telegram/etc). Preformatted
|
|
295
|
+
// items (tool logs) are sent as-is. Fallback to raw text when no hook.
|
|
296
|
+
const formatted = item.preformatted
|
|
297
|
+
? item.text
|
|
298
|
+
: (this.cb.formatOutgoing ? this.cb.formatOutgoing(item.text) : item.text);
|
|
286
299
|
const hash = item.skipHashDedup
|
|
287
300
|
? ""
|
|
288
301
|
: item.dedupKey
|
|
@@ -292,48 +305,25 @@ class OutputForwarder {
|
|
|
292
305
|
this.commitReadProgress(item.nextFileSize);
|
|
293
306
|
return;
|
|
294
307
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const status = err?.status ?? err?.code ?? err?.httpStatus;
|
|
315
|
-
const retryAfter = err?.retryAfter ?? err?.retry_after
|
|
316
|
-
?? err?.headers?.["retry-after"] ?? err?.response?.headers?.["retry-after"];
|
|
317
|
-
dropTrace("discord.send.err", { channelId: this.channelId, chunkIndex: _ci, status, retryAfter: retryAfter ?? "(none)", err: String(err) });
|
|
318
|
-
item._sendRetries = (item._sendRetries || 0) + 1;
|
|
319
|
-
if (item._sendRetries >= 3) {
|
|
320
|
-
// Cap retries to avoid infinite duplicate loop — give up on this item
|
|
321
|
-
process.stderr.write(`[output-forwarder] chunk send exceeded 3 retries at chunk ${_ci}, dropping item\n`);
|
|
322
|
-
item._nextChunkIdx = item._chunks.length; // mark exhausted
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
if (status === 429) {
|
|
326
|
-
if (retryAfter != null) {
|
|
327
|
-
const ms = Number(retryAfter) > 1000 ? Number(retryAfter) : Number(retryAfter) * 1000;
|
|
328
|
-
if (Number.isFinite(ms) && ms > 0) {
|
|
329
|
-
await new Promise((r) => setTimeout(r, Math.min(ms, 60_000)));
|
|
330
|
-
}
|
|
331
|
-
} else {
|
|
332
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
throw err;
|
|
336
|
-
}
|
|
308
|
+
// Backend owns chunking + send retry: pass the whole (unchunked) text once.
|
|
309
|
+
// On failure the backend has already exhausted its per-chunk retries and
|
|
310
|
+
// throws; we re-throw so drainQueue/scheduleRetry requeues the whole item.
|
|
311
|
+
try {
|
|
312
|
+
// Hand back any opaque resume token stored from a prior partial-send
|
|
313
|
+
// failure so the backend resumes at the failed chunk instead of
|
|
314
|
+
// re-sending chunks that already landed. The token is opaque here —
|
|
315
|
+
// the forwarder only stores/passes/clears it, never interprets it.
|
|
316
|
+
await this.cb.send(targetChannelId, formatted, item._resumeToken ? { resumeToken: item._resumeToken } : undefined);
|
|
317
|
+
// Full success — drop any stale token so a later reuse of this item
|
|
318
|
+
// object can't carry a dead token.
|
|
319
|
+
item._resumeToken = undefined;
|
|
320
|
+
dropTrace("discord.send.ok", null);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// Persist the opaque resume token (if any) on the item so the requeued
|
|
323
|
+
// retry resumes from the failed chunk.
|
|
324
|
+
if (err?.resumeToken) item._resumeToken = err.resumeToken;
|
|
325
|
+
dropTrace("discord.send.err", { channelId: this.channelId, err: String(err) });
|
|
326
|
+
throw err;
|
|
337
327
|
}
|
|
338
328
|
if (!item.skipHashDedup) {
|
|
339
329
|
this.lastHash = hash;
|
|
@@ -344,7 +334,9 @@ class OutputForwarder {
|
|
|
344
334
|
|
|
345
335
|
${_bt.trim()}` : _bt.trim();
|
|
346
336
|
}
|
|
347
|
-
|
|
337
|
+
// sentCount now tracks delivered items (the forwarder no longer knows the
|
|
338
|
+
// backend's chunk count). Increment by 1 per delivered item.
|
|
339
|
+
this.sentCount += 1;
|
|
348
340
|
this.commitReadProgress(item.nextFileSize);
|
|
349
341
|
}
|
|
350
342
|
scheduleRetry() {
|
|
@@ -375,7 +367,13 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
375
367
|
// finalLane[0] is being drained when this.sending; coalesce into tail only
|
|
376
368
|
// when tail is not index 0 (i.e. length >= 2) or drain is not in flight.
|
|
377
369
|
const ftLen = this.finalLane.length;
|
|
378
|
-
|
|
370
|
+
// A partially-delivered item carries an opaque _resumeToken pinned (by
|
|
371
|
+
// hash) to its exact text. Mutating its text here would break that hash on
|
|
372
|
+
// the next retry, forcing the backend to full-resend from chunk 0 and
|
|
373
|
+
// duplicate the chunks already delivered. Treat any tokenized item as
|
|
374
|
+
// sealed: never coalesce/cap-merge into it; new text goes behind it as a
|
|
375
|
+
// separate item.
|
|
376
|
+
const ftTail = (ftLen > 0 && !(this.sending && ftLen === 1) && !this.finalLane[ftLen - 1]._resumeToken)
|
|
379
377
|
? this.finalLane[ftLen - 1] : null;
|
|
380
378
|
if (ftTail) {
|
|
381
379
|
ftTail.text = `${ftTail.text}\n\n${newText}`;
|
|
@@ -391,6 +389,8 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
391
389
|
const capEnd = this.sending ? 1 : 0;
|
|
392
390
|
for (let i = this.finalLane.length - 1; i >= capEnd; i--) {
|
|
393
391
|
const cap = this.finalLane[i];
|
|
392
|
+
// Never fold into a sealed (partially-delivered) item — see above.
|
|
393
|
+
if (cap._resumeToken) continue;
|
|
394
394
|
cap.text = `${cap.text}\n\n${newText}`;
|
|
395
395
|
cap.bufferText = `${cap.bufferText}\n\n${newText}`;
|
|
396
396
|
cap.nextFileSize = nextFileSize;
|
|
@@ -446,8 +446,20 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
446
446
|
}
|
|
447
447
|
lane.shift();
|
|
448
448
|
} catch (err) {
|
|
449
|
-
|
|
450
|
-
|
|
449
|
+
// Permanent (non-retryable) send failure — e.g. Telegram 400 "chat
|
|
450
|
+
// not found", Discord 403/404. Retrying would loop forever and wedge
|
|
451
|
+
// the queue, so DROP the item: advance the read cursor past its bytes
|
|
452
|
+
// (so it never reprocesses), clear any stale resume token, and keep
|
|
453
|
+
// draining the next item.
|
|
454
|
+
if (err?.permanent) {
|
|
455
|
+
process.stderr.write(`[output-forwarder] permanent send failure, dropping item: ${err?.message || err}\n`);
|
|
456
|
+
dropTrace("drain.send.permanent", { lane: fromFinal ? "final" : "stream", itemType: item?.type, err: String(err) });
|
|
457
|
+
item._resumeToken = undefined;
|
|
458
|
+
this.commitReadProgress(item.nextFileSize);
|
|
459
|
+
lane.shift();
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
process.stderr.write(`[output-forwarder] send failed: ${err}\n`);
|
|
451
463
|
dropTrace("drain.send.err", { finalLen: this.finalLane.length, streamLen: this.streamLane.length, lane: fromFinal ? "final" : "stream", itemType: item?.type, err: String(err) });
|
|
452
464
|
this.scheduleRetry();
|
|
453
465
|
break;
|
|
@@ -467,8 +479,7 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
467
479
|
}
|
|
468
480
|
await this.cb.react(this.channelId, this.userMessageId, newEmoji);
|
|
469
481
|
this.emoji = newEmoji;
|
|
470
|
-
} catch {
|
|
471
|
-
}
|
|
482
|
+
} catch {} // best-effort: reaction is non-critical
|
|
472
483
|
}
|
|
473
484
|
await this.deliverQueueItem(item);
|
|
474
485
|
}
|
|
@@ -487,7 +498,7 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
487
498
|
try {
|
|
488
499
|
this.pendingFinalFlush = true;
|
|
489
500
|
this.updateState((state) => { state.pendingFinalFlush = true; });
|
|
490
|
-
} catch {}
|
|
501
|
+
} catch {} // best-effort: durable flush marker is non-critical
|
|
491
502
|
if (retries < 5) {
|
|
492
503
|
setTimeout(() => void this.forwardFinalText(retries + 1, channelId), 300);
|
|
493
504
|
} else {
|
|
@@ -511,8 +522,7 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
511
522
|
if (this.userMessageId && this.emoji) {
|
|
512
523
|
try {
|
|
513
524
|
await this.cb.removeReaction(channelId, this.userMessageId, this.emoji);
|
|
514
|
-
} catch {
|
|
515
|
-
}
|
|
525
|
+
} catch {} // best-effort: remove reaction is non-critical
|
|
516
526
|
}
|
|
517
527
|
const { text: newText, nextFileSize } = this.extractNewText();
|
|
518
528
|
if (newText) {
|
|
@@ -520,14 +530,23 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
520
530
|
try {
|
|
521
531
|
await this.deliverQueueItem(finalItem);
|
|
522
532
|
} catch (err) {
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
533
|
+
// Permanent (non-retryable) failure — drop instead of requeue so we
|
|
534
|
+
// don't loop forever on a dead channel. extractNewText already
|
|
535
|
+
// advanced the read cursor, so commit it and let the frame go.
|
|
536
|
+
if (err?.permanent) {
|
|
537
|
+
process.stderr.write(`[output-forwarder] permanent final-send failure, dropping: ${err?.message || err}\n`);
|
|
538
|
+
this.commitReadProgress(nextFileSize);
|
|
539
|
+
} else {
|
|
540
|
+
// Transient send failure: extractNewText already advanced the read
|
|
541
|
+
// cursor past these bytes, so dropping the item here would lose the
|
|
542
|
+
// final text. Requeue the WHOLE item and let drainQueue retry
|
|
543
|
+
// instead of silently discarding. The backend owns chunking+retry,
|
|
544
|
+
// so a requeued send restarts from chunk 0. pendingFinalFlush stays
|
|
545
|
+
// set so a process restart can also resume the flush.
|
|
546
|
+
this.finalLane.push(finalItem);
|
|
547
|
+
this.scheduleRetry();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
531
550
|
}
|
|
532
551
|
} else {
|
|
533
552
|
this.commitReadProgress(nextFileSize);
|
|
@@ -546,7 +565,7 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
546
565
|
try {
|
|
547
566
|
this.pendingFinalFlush = false;
|
|
548
567
|
this.updateState((state) => { state.pendingFinalFlush = false; });
|
|
549
|
-
} catch {}
|
|
568
|
+
} catch {} // best-effort: clear flush marker is non-critical
|
|
550
569
|
this.updateState((state) => {
|
|
551
570
|
state.sessionIdle = true;
|
|
552
571
|
});
|
|
@@ -569,7 +588,7 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
569
588
|
// isHidden, because that helper would re-consult the module-local
|
|
570
589
|
// HIDDEN_TOOLS Set and ignore the OutputForwarder static.
|
|
571
590
|
if (OutputForwarder.HIDDEN_TOOLS.has(name)) return true;
|
|
572
|
-
if (
|
|
591
|
+
if (name === "reply" || name === "react" || name === "edit_message" || name === "fetch" || name === "download_attachment") return true;
|
|
573
592
|
return false;
|
|
574
593
|
};
|
|
575
594
|
/** Concatenate text blocks from a transcript entry (user or assistant). */
|
|
@@ -684,13 +703,12 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
684
703
|
this.watchDebounce = null;
|
|
685
704
|
let _wfStat = null;
|
|
686
705
|
if (this.transcriptPath) {
|
|
687
|
-
try { _wfStat = statSync(this.transcriptPath); } catch {}
|
|
706
|
+
try { _wfStat = statSync(this.transcriptPath); } catch {} // best-effort: stat during flush
|
|
688
707
|
}
|
|
689
708
|
if (this.transcriptPath && !_wfStat) {
|
|
690
709
|
const relocated = detectCurrentSessionTranscript()?.transcriptPath ?? findLatestTranscriptByMtime();
|
|
691
710
|
if (relocated && relocated !== this.transcriptPath) {
|
|
692
|
-
process.stderr.write(`
|
|
693
|
-
`);
|
|
711
|
+
process.stderr.write(`[output-forwarder] watched transcript gone during flush, relocated to ${relocated}\n`);
|
|
694
712
|
dropTrace("watch.flush.relocate", { from: this.transcriptPath, to: relocated });
|
|
695
713
|
this.closeWatcher();
|
|
696
714
|
this.transcriptPath = relocated;
|
|
@@ -754,5 +772,8 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
754
772
|
}
|
|
755
773
|
}
|
|
756
774
|
export {
|
|
757
|
-
OutputForwarder
|
|
775
|
+
OutputForwarder,
|
|
776
|
+
discoverSessionBoundTranscript,
|
|
777
|
+
findLatestTranscriptByMtime,
|
|
778
|
+
sameResolvedPath
|
|
758
779
|
};
|
|
@@ -68,6 +68,7 @@ function isPidAlive(pid) {
|
|
|
68
68
|
return e?.code === "EPERM";
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
const UI_HEARTBEAT_STALE_MS = 5 * 60 * 1e3;
|
|
71
72
|
function activeInstanceStaleReason(state) {
|
|
72
73
|
const ownerPid = getActiveOwnerPid(state);
|
|
73
74
|
if (!isPidAlive(ownerPid)) return `owner PID ${ownerPid ?? "unknown"} is dead`;
|
|
@@ -77,8 +78,35 @@ function activeInstanceStaleReason(state) {
|
|
|
77
78
|
if (workerPid && !isPidAlive(workerPid)) return `worker PID ${workerPid} is dead`;
|
|
78
79
|
const serverPid = parsePositivePid(state?.server_pid);
|
|
79
80
|
if (serverPid && !isPidAlive(serverPid)) return `server PID ${serverPid} is dead`;
|
|
81
|
+
// Zombie-Lead repro (2026-07-02): a Lead's owner/channels/worker/server
|
|
82
|
+
// PIDs can all still be alive (process not killed) while the TUI's render
|
|
83
|
+
// loop is dead in the water — no signal ever fires, so pid-only staleness
|
|
84
|
+
// never trips. If the TUI is heartbeating (field present), treat a stale
|
|
85
|
+
// heartbeat as stale ownership too. Backward-compat: state written by an
|
|
86
|
+
// older/non-TUI process (or before the first heartbeat tick) simply omits
|
|
87
|
+
// ui_heartbeat_at, so this branch is a no-op and pid-only judgment stands.
|
|
88
|
+
const uiHeartbeatAt = Number(state?.ui_heartbeat_at);
|
|
89
|
+
if (Number.isFinite(uiHeartbeatAt) && uiHeartbeatAt > 0) {
|
|
90
|
+
const age = Date.now() - uiHeartbeatAt;
|
|
91
|
+
if (age > UI_HEARTBEAT_STALE_MS) {
|
|
92
|
+
return `ui heartbeat stale (${Math.round(age / 1000)}s since last tick)`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
80
95
|
return null;
|
|
81
96
|
}
|
|
97
|
+
// Called from src/tui on a 30s timer while the render loop is alive. Only
|
|
98
|
+
// touches ui_heartbeat_at (and updatedAt) so it never races/clobbers the
|
|
99
|
+
// channels worker's own refreshActiveInstance() writes.
|
|
100
|
+
function touchUiHeartbeat(instanceId) {
|
|
101
|
+
ensureRuntimeDirs();
|
|
102
|
+
try {
|
|
103
|
+
updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
|
|
104
|
+
if (!curRaw) return undefined;
|
|
105
|
+
if (instanceId && curRaw.instanceId !== instanceId) return undefined;
|
|
106
|
+
return { ...curRaw, ui_heartbeat_at: Date.now(), updatedAt: Date.now() };
|
|
107
|
+
}, { compact: true, fsync: false, fsyncDir: false });
|
|
108
|
+
} catch { /* best-effort; a missed tick just relies on the next one */ }
|
|
109
|
+
}
|
|
82
110
|
function buildRuntimeIdentity() {
|
|
83
111
|
const terminalLeadPid = getTerminalLeadPid();
|
|
84
112
|
const serverPid = getServerPid();
|
|
@@ -487,5 +515,6 @@ export {
|
|
|
487
515
|
readActiveInstance,
|
|
488
516
|
refreshActiveInstance,
|
|
489
517
|
releaseOwnedChannelLocks,
|
|
518
|
+
touchUiHeartbeat,
|
|
490
519
|
writeServerPid
|
|
491
520
|
};
|
|
@@ -8,7 +8,7 @@ import { runScript as execScript, ensureNopluginDir } from "./executor.mjs";
|
|
|
8
8
|
import { withFileLockSync } from "../../shared/atomic-file.mjs";
|
|
9
9
|
import { makeAgentDispatch } from '../../agent/orchestrator/agent-runtime/agent-dispatch.mjs';
|
|
10
10
|
|
|
11
|
-
const schedulerLlm = makeAgentDispatch({ taskType: 'scheduler-task',
|
|
11
|
+
const schedulerLlm = makeAgentDispatch({ taskType: 'scheduler-task', agent: 'scheduler-task', sourceType: 'scheduler' });
|
|
12
12
|
const SCHEDULE_LOG = join(DATA_DIR, "schedule.log");
|
|
13
13
|
// Buffered async logger — coalesces per-line appends into batched writes.
|
|
14
14
|
let _schedLogBuf = [];
|