botmux 2.9.1 → 2.9.2
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/README.en.md +140 -76
- package/README.md +134 -75
- package/dist/adapters/backend/pty-backend.d.ts +6 -0
- package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
- package/dist/adapters/backend/pty-backend.js +10 -0
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
- package/dist/adapters/backend/session-backend-selector.js +26 -0
- package/dist/adapters/backend/session-backend-selector.js.map +1 -0
- package/dist/adapters/backend/tmux-backend.d.ts +80 -3
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +301 -49
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +100 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
- package/dist/adapters/backend/tmux-pipe-backend.js +473 -0
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
- package/dist/adapters/cli/aiden.d.ts.map +1 -1
- package/dist/adapters/cli/aiden.js +5 -0
- package/dist/adapters/cli/aiden.js.map +1 -1
- package/dist/adapters/cli/claude-code.d.ts +40 -1
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +470 -49
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/coco.d.ts.map +1 -1
- package/dist/adapters/cli/coco.js +191 -9
- package/dist/adapters/cli/coco.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +94 -17
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/shared-hints.d.ts +2 -2
- package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
- package/dist/adapters/cli/shared-hints.js +7 -5
- package/dist/adapters/cli/shared-hints.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +38 -1
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/autostart.d.ts +14 -0
- package/dist/autostart.d.ts.map +1 -0
- package/dist/autostart.js +357 -0
- package/dist/autostart.js.map +1 -0
- package/dist/bot-registry.d.ts +29 -3
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +91 -12
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/arg-utils.d.ts +11 -0
- package/dist/cli/arg-utils.d.ts.map +1 -0
- package/dist/cli/arg-utils.js +25 -0
- package/dist/cli/arg-utils.js.map +1 -0
- package/dist/cli/create-group-resolver.d.ts +32 -0
- package/dist/cli/create-group-resolver.d.ts.map +1 -0
- package/dist/cli/create-group-resolver.js +70 -0
- package/dist/cli/create-group-resolver.js.map +1 -0
- package/dist/cli/quoted-render.d.ts +30 -0
- package/dist/cli/quoted-render.d.ts.map +1 -0
- package/dist/cli/quoted-render.js +29 -0
- package/dist/cli/quoted-render.js.map +1 -0
- package/dist/cli.js +916 -272
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -8
- package/dist/config.js.map +1 -1
- package/dist/core/command-handler.d.ts +43 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +167 -64
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-events.d.ts +57 -0
- package/dist/core/dashboard-events.d.ts.map +1 -0
- package/dist/core/dashboard-events.js +23 -0
- package/dist/core/dashboard-events.js.map +1 -0
- package/dist/core/dashboard-ipc-server.d.ts +43 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -0
- package/dist/core/dashboard-ipc-server.js +481 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -0
- package/dist/core/dashboard-locate.d.ts +20 -0
- package/dist/core/dashboard-locate.d.ts.map +1 -0
- package/dist/core/dashboard-locate.js +26 -0
- package/dist/core/dashboard-locate.js.map +1 -0
- package/dist/core/dashboard-rows.d.ts +31 -0
- package/dist/core/dashboard-rows.d.ts.map +1 -0
- package/dist/core/dashboard-rows.js +65 -0
- package/dist/core/dashboard-rows.js.map +1 -0
- package/dist/core/inherit-peer.d.ts +14 -0
- package/dist/core/inherit-peer.d.ts.map +1 -0
- package/dist/core/inherit-peer.js +32 -0
- package/dist/core/inherit-peer.js.map +1 -0
- package/dist/core/scheduler.d.ts +24 -0
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +93 -2
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/session-activity.d.ts +3 -0
- package/dist/core/session-activity.d.ts.map +1 -0
- package/dist/core/session-activity.js +20 -0
- package/dist/core/session-activity.js.map +1 -0
- package/dist/core/session-discovery.d.ts +39 -0
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +114 -21
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts +72 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +396 -106
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/types.d.ts +27 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +14 -3
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +72 -3
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +459 -38
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +601 -309
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +41 -0
- package/dist/dashboard/aggregator.d.ts.map +1 -0
- package/dist/dashboard/aggregator.js +125 -0
- package/dist/dashboard/aggregator.js.map +1 -0
- package/dist/dashboard/auth.d.ts +23 -0
- package/dist/dashboard/auth.d.ts.map +1 -0
- package/dist/dashboard/auth.js +66 -0
- package/dist/dashboard/auth.js.map +1 -0
- package/dist/dashboard/operator-selector.d.ts +20 -0
- package/dist/dashboard/operator-selector.d.ts.map +1 -0
- package/dist/dashboard/operator-selector.js +39 -0
- package/dist/dashboard/operator-selector.js.map +1 -0
- package/dist/dashboard/registry.d.ts +35 -0
- package/dist/dashboard/registry.d.ts.map +1 -0
- package/dist/dashboard/registry.js +74 -0
- package/dist/dashboard/registry.js.map +1 -0
- package/dist/dashboard/web/app.d.ts +2 -0
- package/dist/dashboard/web/app.d.ts.map +1 -0
- package/dist/dashboard/web/app.js +45 -0
- package/dist/dashboard/web/app.js.map +1 -0
- package/dist/dashboard/web/bot-defaults.d.ts +2 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
- package/dist/dashboard/web/bot-defaults.js +201 -0
- package/dist/dashboard/web/bot-defaults.js.map +1 -0
- package/dist/dashboard/web/groups.d.ts +16 -0
- package/dist/dashboard/web/groups.d.ts.map +1 -0
- package/dist/dashboard/web/groups.js +584 -0
- package/dist/dashboard/web/groups.js.map +1 -0
- package/dist/dashboard/web/schedules.d.ts +2 -0
- package/dist/dashboard/web/schedules.d.ts.map +1 -0
- package/dist/dashboard/web/schedules.js +105 -0
- package/dist/dashboard/web/schedules.js.map +1 -0
- package/dist/dashboard/web/sessions.d.ts +2 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -0
- package/dist/dashboard/web/sessions.js +374 -0
- package/dist/dashboard/web/sessions.js.map +1 -0
- package/dist/dashboard/web/store.d.ts +23 -0
- package/dist/dashboard/web/store.d.ts.map +1 -0
- package/dist/dashboard/web/store.js +82 -0
- package/dist/dashboard/web/store.js.map +1 -0
- package/dist/dashboard-web/app.js +263 -0
- package/dist/dashboard-web/index.html +23 -0
- package/dist/dashboard-web/style.css +93 -0
- package/dist/dashboard.d.ts +2 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +639 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/im/lark/card-builder.d.ts +18 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +70 -9
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +123 -109
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +35 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +114 -11
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +88 -6
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +398 -62
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/forwarded-renderer.d.ts +23 -0
- package/dist/im/lark/forwarded-renderer.d.ts.map +1 -0
- package/dist/im/lark/forwarded-renderer.js +105 -0
- package/dist/im/lark/forwarded-renderer.js.map +1 -0
- package/dist/im/lark/md-card.d.ts +73 -0
- package/dist/im/lark/md-card.d.ts.map +1 -0
- package/dist/im/lark/md-card.js +332 -0
- package/dist/im/lark/md-card.js.map +1 -0
- package/dist/im/lark/merge-forward.d.ts +32 -0
- package/dist/im/lark/merge-forward.d.ts.map +1 -0
- package/dist/im/lark/merge-forward.js +110 -0
- package/dist/im/lark/merge-forward.js.map +1 -0
- package/dist/im/lark/message-parser.d.ts +9 -3
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +48 -13
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/quote-hint.d.ts +18 -0
- package/dist/im/lark/quote-hint.d.ts.map +1 -0
- package/dist/im/lark/quote-hint.js +23 -0
- package/dist/im/lark/quote-hint.js.map +1 -0
- package/dist/services/bridge-fallback-gate.d.ts +42 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
- package/dist/services/bridge-fallback-gate.js +12 -0
- package/dist/services/bridge-fallback-gate.js.map +1 -0
- package/dist/services/bridge-rotation-policy.d.ts +139 -0
- package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
- package/dist/services/bridge-rotation-policy.js +125 -0
- package/dist/services/bridge-rotation-policy.js.map +1 -0
- package/dist/services/bridge-turn-queue.d.ts +154 -0
- package/dist/services/bridge-turn-queue.d.ts.map +1 -0
- package/dist/services/bridge-turn-queue.js +316 -0
- package/dist/services/bridge-turn-queue.js.map +1 -0
- package/dist/services/chat-first-seen-store.d.ts +27 -0
- package/dist/services/chat-first-seen-store.d.ts.map +1 -0
- package/dist/services/chat-first-seen-store.js +114 -0
- package/dist/services/chat-first-seen-store.js.map +1 -0
- package/dist/services/claude-transcript.d.ts +268 -0
- package/dist/services/claude-transcript.d.ts.map +1 -0
- package/dist/services/claude-transcript.js +798 -0
- package/dist/services/claude-transcript.js.map +1 -0
- package/dist/services/coco-transcript.d.ts +35 -0
- package/dist/services/coco-transcript.d.ts.map +1 -0
- package/dist/services/coco-transcript.js +192 -0
- package/dist/services/coco-transcript.js.map +1 -0
- package/dist/services/codex-bridge-queue.d.ts +56 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -0
- package/dist/services/codex-bridge-queue.js +150 -0
- package/dist/services/codex-bridge-queue.js.map +1 -0
- package/dist/services/codex-transcript.d.ts +84 -0
- package/dist/services/codex-transcript.d.ts.map +1 -0
- package/dist/services/codex-transcript.js +298 -0
- package/dist/services/codex-transcript.js.map +1 -0
- package/dist/services/group-creator.d.ts +23 -0
- package/dist/services/group-creator.d.ts.map +1 -0
- package/dist/services/group-creator.js +75 -0
- package/dist/services/group-creator.js.map +1 -0
- package/dist/services/groups-store.d.ts +98 -0
- package/dist/services/groups-store.d.ts.map +1 -0
- package/dist/services/groups-store.js +213 -0
- package/dist/services/groups-store.js.map +1 -0
- package/dist/services/oncall-store.d.ts +80 -8
- package/dist/services/oncall-store.d.ts.map +1 -1
- package/dist/services/oncall-store.js +265 -55
- package/dist/services/oncall-store.js.map +1 -1
- package/dist/services/project-scanner.d.ts +1 -2
- package/dist/services/project-scanner.d.ts.map +1 -1
- package/dist/services/project-scanner.js +118 -68
- package/dist/services/project-scanner.js.map +1 -1
- package/dist/services/schedule-store.d.ts +5 -0
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +77 -1
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/services/session-store.d.ts +22 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +62 -4
- package/dist/services/session-store.js.map +1 -1
- package/dist/setup/bots-store.d.ts +3 -0
- package/dist/setup/bots-store.d.ts.map +1 -0
- package/dist/setup/bots-store.js +24 -0
- package/dist/setup/bots-store.js.map +1 -0
- package/dist/setup/detect-platform.d.ts +14 -0
- package/dist/setup/detect-platform.d.ts.map +1 -0
- package/dist/setup/detect-platform.js +139 -0
- package/dist/setup/detect-platform.js.map +1 -0
- package/dist/setup/ensure-fonts.d.ts +13 -0
- package/dist/setup/ensure-fonts.d.ts.map +1 -0
- package/dist/setup/ensure-fonts.js +225 -0
- package/dist/setup/ensure-fonts.js.map +1 -0
- package/dist/setup/ensure-tmux.d.ts +60 -0
- package/dist/setup/ensure-tmux.d.ts.map +1 -0
- package/dist/setup/ensure-tmux.js +236 -0
- package/dist/setup/ensure-tmux.js.map +1 -0
- package/dist/setup/index.d.ts +9 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +46 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/setup/lark-scopes.json +301 -0
- package/dist/setup/register-app.d.ts +52 -0
- package/dist/setup/register-app.d.ts.map +1 -0
- package/dist/setup/register-app.js +91 -0
- package/dist/setup/register-app.js.map +1 -0
- package/dist/setup/verify-permissions.d.ts +115 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -0
- package/dist/setup/verify-permissions.js +207 -0
- package/dist/setup/verify-permissions.js.map +1 -0
- package/dist/skills/definitions.d.ts +4 -0
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +133 -19
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/installer.d.ts +3 -1
- package/dist/skills/installer.d.ts.map +1 -1
- package/dist/skills/installer.js +18 -3
- package/dist/skills/installer.js.map +1 -1
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +6 -0
- package/dist/utils/bot-routing.d.ts.map +1 -0
- package/dist/utils/bot-routing.js +50 -0
- package/dist/utils/bot-routing.js.map +1 -0
- package/dist/utils/file-lock.d.ts +2 -0
- package/dist/utils/file-lock.d.ts.map +1 -0
- package/dist/utils/file-lock.js +114 -0
- package/dist/utils/file-lock.js.map +1 -0
- package/dist/utils/font-installer.js +1 -1
- package/dist/utils/font-installer.js.map +1 -1
- package/dist/utils/idle-detector.d.ts +6 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +25 -4
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +60 -8
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/render-dimensions.d.ts +48 -0
- package/dist/utils/render-dimensions.d.ts.map +1 -0
- package/dist/utils/render-dimensions.js +55 -0
- package/dist/utils/render-dimensions.js.map +1 -0
- package/dist/utils/screen-analyzer.d.ts.map +1 -1
- package/dist/utils/screen-analyzer.js +24 -0
- package/dist/utils/screen-analyzer.js.map +1 -1
- package/dist/utils/screenshot-renderer.d.ts.map +1 -1
- package/dist/utils/screenshot-renderer.js +67 -23
- package/dist/utils/screenshot-renderer.js.map +1 -1
- package/dist/utils/terminal-renderer.d.ts +16 -0
- package/dist/utils/terminal-renderer.d.ts.map +1 -1
- package/dist/utils/terminal-renderer.js +40 -23
- package/dist/utils/terminal-renderer.js.map +1 -1
- package/dist/utils/transient-snapshot.d.ts +28 -0
- package/dist/utils/transient-snapshot.d.ts.map +1 -0
- package/dist/utils/transient-snapshot.js +96 -0
- package/dist/utils/transient-snapshot.js.map +1 -0
- package/dist/worker.js +2220 -83
- package/dist/worker.js.map +1 -1
- package/package.json +12 -5
package/dist/core/worker-pool.js
CHANGED
|
@@ -12,12 +12,18 @@ import { randomBytes } from 'node:crypto';
|
|
|
12
12
|
import { config } from '../config.js';
|
|
13
13
|
import * as sessionStore from '../services/session-store.js';
|
|
14
14
|
import { persistStreamCardState } from './session-manager.js';
|
|
15
|
-
import { updateMessage, MessageWithdrawnError } from '../im/lark/client.js';
|
|
15
|
+
import { updateMessage, deleteMessage, MessageWithdrawnError } from '../im/lark/client.js';
|
|
16
16
|
import { buildStreamingCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, getCliDisplayName } from '../im/lark/card-builder.js';
|
|
17
|
+
import { loadFrozenCards, saveFrozenCards } from '../services/frozen-card-store.js';
|
|
17
18
|
import { logger } from '../utils/logger.js';
|
|
18
19
|
import { createCliAdapterSync } from '../adapters/cli/registry.js';
|
|
20
|
+
import { claudeJsonlPathForSession } from '../adapters/cli/claude-code.js';
|
|
21
|
+
import { buildMarkdownCard, buildContextualReplyCard } from '../im/lark/md-card.js';
|
|
19
22
|
import { TmuxBackend } from '../adapters/backend/tmux-backend.js';
|
|
20
23
|
import { getBot, getAllBots } from '../bot-registry.js';
|
|
24
|
+
import { dashboardEventBus } from './dashboard-events.js';
|
|
25
|
+
import { composeRowFromActive } from './dashboard-rows.js';
|
|
26
|
+
import { sessionKey, sessionAnchorId } from './types.js';
|
|
21
27
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
28
|
const __dirname = dirname(__filename);
|
|
23
29
|
let callbacks;
|
|
@@ -32,14 +38,124 @@ function requireCallbacks() {
|
|
|
32
38
|
throw new Error('WorkerPool not initialised — call initWorkerPool() first');
|
|
33
39
|
return callbacks;
|
|
34
40
|
}
|
|
41
|
+
// ─── Active session registry (daemon-owned, accessor for IPC) ───────────────
|
|
42
|
+
// The activeSessions Map physically lives in daemon.ts. To let the dashboard
|
|
43
|
+
// IPC server (and other modules) read it without reaching back into daemon, the
|
|
44
|
+
// daemon registers its Map here at boot. Helpers below return a snapshot or
|
|
45
|
+
// linear-scan by sessionId.
|
|
46
|
+
let activeSessionsRegistry;
|
|
47
|
+
export function setActiveSessionsRegistry(m) {
|
|
48
|
+
activeSessionsRegistry = m;
|
|
49
|
+
}
|
|
50
|
+
export function listActiveSessions() {
|
|
51
|
+
return activeSessionsRegistry ? [...activeSessionsRegistry.values()] : [];
|
|
52
|
+
}
|
|
53
|
+
/** Linear-scan lookup of the active-sessions Map by `Session.sessionId`.
|
|
54
|
+
* The Map's actual key is `sessionKey(rootId, larkAppId)` (composite), so we
|
|
55
|
+
* cannot use Map.get here. */
|
|
56
|
+
export function findActiveBySessionId(sessionId) {
|
|
57
|
+
if (!activeSessionsRegistry)
|
|
58
|
+
return undefined;
|
|
59
|
+
for (const s of activeSessionsRegistry.values())
|
|
60
|
+
if (s.session.sessionId === sessionId)
|
|
61
|
+
return s;
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
/** Direct access to the active-sessions Map. Reserved for callers that need
|
|
65
|
+
* to mutate (e.g. resumeSession reactivating a closed record); read-only
|
|
66
|
+
* callers should prefer listActiveSessions / findActiveBySessionId. */
|
|
67
|
+
export function getActiveSessionsRegistry() {
|
|
68
|
+
return activeSessionsRegistry;
|
|
69
|
+
}
|
|
35
70
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
36
71
|
function tag(ds) {
|
|
37
72
|
return ds.session.sessionId.substring(0, 8);
|
|
38
73
|
}
|
|
74
|
+
const WORKER_ERROR_MARKER = '[botmux-worker-error]';
|
|
75
|
+
function logWorkerStderr(t, line) {
|
|
76
|
+
if (!line)
|
|
77
|
+
return;
|
|
78
|
+
const taggedLine = `[${t}:err] ${line}`;
|
|
79
|
+
if (line.includes(WORKER_ERROR_MARKER)) {
|
|
80
|
+
logger.error(taggedLine);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
logger.info(taggedLine);
|
|
84
|
+
}
|
|
39
85
|
// Sentinel value for streamCardId while a POST (new card) is in-flight.
|
|
40
86
|
// Prevents duplicate card POSTs when multiple screen_updates arrive before
|
|
41
87
|
// the first POST returns a real message_id.
|
|
42
88
|
export const CARD_POSTING_SENTINEL = '__posting__';
|
|
89
|
+
/**
|
|
90
|
+
* Move the current streaming card into `frozenCards` without freezing it
|
|
91
|
+
* cosmetically. The next successful card POST will sweep it via
|
|
92
|
+
* `recallFrozenCards`. Used on paths that bypass the normal freeze step
|
|
93
|
+
* (worker dead before a new turn, repo switch tearing down the session) so
|
|
94
|
+
* we never delete the only visible card before its successor exists — if
|
|
95
|
+
* fork / worker_ready / POST fails, the parked card stays in the thread.
|
|
96
|
+
*
|
|
97
|
+
* Lazy-loads `frozenCards` from disk if the in-memory Map is missing
|
|
98
|
+
* (post daemon-restart, before any card-handler action has loaded it).
|
|
99
|
+
* Without this, parking would synthesize an empty Map and the subsequent
|
|
100
|
+
* `saveFrozenCards` would overwrite earlier turns' entries on disk —
|
|
101
|
+
* stranding their cards in the thread with no way to recall them.
|
|
102
|
+
*
|
|
103
|
+
* No-op when there is no live card to park.
|
|
104
|
+
*/
|
|
105
|
+
export function parkStreamCard(ds) {
|
|
106
|
+
if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL)
|
|
107
|
+
return;
|
|
108
|
+
if (!ds.streamCardNonce)
|
|
109
|
+
return;
|
|
110
|
+
if (!ds.frozenCards)
|
|
111
|
+
ds.frozenCards = loadFrozenCards(ds.session.sessionId);
|
|
112
|
+
ds.frozenCards.set(ds.streamCardNonce, {
|
|
113
|
+
messageId: ds.streamCardId,
|
|
114
|
+
content: ds.lastScreenContent ?? '',
|
|
115
|
+
title: ds.currentTurnTitle ?? '',
|
|
116
|
+
displayMode: ds.displayMode ?? 'hidden',
|
|
117
|
+
imageKey: ds.currentImageKey,
|
|
118
|
+
});
|
|
119
|
+
saveFrozenCards(ds.session.sessionId, ds.frozenCards);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Delete previously-frozen streaming cards from Lark and clear the cache.
|
|
123
|
+
* Called whenever a new streaming card becomes the active one — old turns'
|
|
124
|
+
* cards just add visual clutter when scrolling thread history.
|
|
125
|
+
*
|
|
126
|
+
* Lazy-loads `frozenCards` from disk if the in-memory Map is missing
|
|
127
|
+
* (post daemon-restart). Best-effort delete; failures (already withdrawn,
|
|
128
|
+
* expired) are non-fatal.
|
|
129
|
+
*
|
|
130
|
+
* Skips any entry whose messageId matches `ds.streamCardId` — guards the
|
|
131
|
+
* daemon-restart window where a turn was frozen (entry persisted to disk)
|
|
132
|
+
* but a new card was never POSTed before the crash. After restart the same
|
|
133
|
+
* messageId is the live `streamCardId` again, and recalling it would delete
|
|
134
|
+
* the only card the user can see.
|
|
135
|
+
*/
|
|
136
|
+
export function recallFrozenCards(ds) {
|
|
137
|
+
if (!ds.frozenCards)
|
|
138
|
+
ds.frozenCards = loadFrozenCards(ds.session.sessionId);
|
|
139
|
+
if (ds.frozenCards.size === 0)
|
|
140
|
+
return;
|
|
141
|
+
const activeId = ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL
|
|
142
|
+
? ds.streamCardId
|
|
143
|
+
: undefined;
|
|
144
|
+
const targets = [];
|
|
145
|
+
for (const [nonce, fc] of [...ds.frozenCards.entries()]) {
|
|
146
|
+
if (activeId && fc.messageId === activeId)
|
|
147
|
+
continue;
|
|
148
|
+
targets.push(fc.messageId);
|
|
149
|
+
ds.frozenCards.delete(nonce);
|
|
150
|
+
}
|
|
151
|
+
if (targets.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
saveFrozenCards(ds.session.sessionId, ds.frozenCards);
|
|
154
|
+
for (const messageId of targets) {
|
|
155
|
+
deleteMessage(ds.larkAppId, messageId).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
logger.info(`[${tag(ds)}] Recalled ${targets.length} previous streaming card(s)`);
|
|
158
|
+
}
|
|
43
159
|
// ─── Card PATCH serialization queue ─────────────────────────────────────────
|
|
44
160
|
// Only one PATCH in-flight at a time per session. New PATCHes queue on
|
|
45
161
|
// ds.pendingCardJson (latest wins). When the in-flight PATCH completes,
|
|
@@ -74,9 +190,21 @@ function flushCardPatch(ds) {
|
|
|
74
190
|
updateMessage(ds.larkAppId, cardId, json)
|
|
75
191
|
.catch(err => {
|
|
76
192
|
if (err instanceof MessageWithdrawnError) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
193
|
+
// Only clear streamCardId when the withdrawn message is still the
|
|
194
|
+
// active one. With auto-recall a new turn may have advanced
|
|
195
|
+
// ds.streamCardId past `cardId` while this PATCH was in flight (the
|
|
196
|
+
// recall on the new POST deletes the previous card, which surfaces
|
|
197
|
+
// here as MessageWithdrawnError). Clearing unconditionally would
|
|
198
|
+
// forget the live new card and trigger a duplicate POST on the next
|
|
199
|
+
// screen_update.
|
|
200
|
+
if (ds.streamCardId === cardId) {
|
|
201
|
+
logger.warn(`[${tag(ds)}] Stream card withdrawn, clearing reference`);
|
|
202
|
+
ds.streamCardId = undefined;
|
|
203
|
+
persistStreamCardState(ds);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
logger.debug(`[${tag(ds)}] Stale card ${cardId.substring(0, 12)} withdrawn (current: ${ds.streamCardId?.substring(0, 12) ?? 'none'})`);
|
|
207
|
+
}
|
|
80
208
|
return;
|
|
81
209
|
}
|
|
82
210
|
logger.debug(`[${tag(ds)}] Failed to update streaming card: ${err}`);
|
|
@@ -228,6 +356,51 @@ export function killWorker(ds) {
|
|
|
228
356
|
ds.workerPort = null;
|
|
229
357
|
ds.workerToken = null;
|
|
230
358
|
}
|
|
359
|
+
// ─── Idempotent session close (dashboard IPC) ───────────────────────────────
|
|
360
|
+
/**
|
|
361
|
+
* Idempotent close: kill worker if alive, mark Session status='closed' + closedAt,
|
|
362
|
+
* publish session.exited (if a live worker was killed) and session.update
|
|
363
|
+
* (if the persistence row transitioned to closed).
|
|
364
|
+
*
|
|
365
|
+
* Calling this on an unknown sessionId, an already-closed session, or a session
|
|
366
|
+
* whose worker died asynchronously must still resolve with `{ ok: true }`.
|
|
367
|
+
*/
|
|
368
|
+
export async function closeSession(sessionId) {
|
|
369
|
+
const ds = findActiveBySessionId(sessionId);
|
|
370
|
+
let killedLive = false;
|
|
371
|
+
if (ds) {
|
|
372
|
+
killWorker(ds);
|
|
373
|
+
activeSessionsRegistry?.delete(sessionKey(sessionAnchorId(ds), ds.larkAppId));
|
|
374
|
+
killedLive = true;
|
|
375
|
+
if (!ds.exitEventEmitted) {
|
|
376
|
+
ds.exitEventEmitted = true;
|
|
377
|
+
dashboardEventBus.publish({
|
|
378
|
+
type: 'session.exited',
|
|
379
|
+
body: { sessionId, reason: 'dashboard_close' },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Persistence path — load → mark closed → save (delegated to sessionStore).
|
|
384
|
+
const stored = sessionStore.getSession(sessionId);
|
|
385
|
+
const wasOpen = !!stored && stored.status !== 'closed';
|
|
386
|
+
if (wasOpen) {
|
|
387
|
+
sessionStore.closeSession(sessionId);
|
|
388
|
+
const after = sessionStore.getSession(sessionId);
|
|
389
|
+
dashboardEventBus.publish({
|
|
390
|
+
type: 'session.update',
|
|
391
|
+
body: {
|
|
392
|
+
sessionId,
|
|
393
|
+
patch: {
|
|
394
|
+
status: 'closed',
|
|
395
|
+
closedAt: after?.closedAt ? Date.parse(after.closedAt) : Date.now(),
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
// alreadyClosed = nothing happened on either path.
|
|
401
|
+
const alreadyClosed = !killedLive && !wasOpen;
|
|
402
|
+
return { ok: true, alreadyClosed };
|
|
403
|
+
}
|
|
231
404
|
// ─── Fork worker ────────────────────────────────────────────────────────────
|
|
232
405
|
export function forkWorker(ds, prompt, resume = false) {
|
|
233
406
|
const cb = requireCallbacks();
|
|
@@ -264,13 +437,18 @@ export function forkWorker(ds, prompt, resume = false) {
|
|
|
264
437
|
...process.env,
|
|
265
438
|
PATH: pathWithBotmux,
|
|
266
439
|
CLAUDECODE: undefined,
|
|
267
|
-
BOTMUX: '1', //
|
|
440
|
+
BOTMUX: '1', // Marker so user scripts/skills can detect a botmux-spawned CLI
|
|
268
441
|
SESSION_DATA_DIR: config.session.dataDir,
|
|
269
442
|
LARK_APP_ID: botCfg.larkAppId,
|
|
270
443
|
LARK_APP_SECRET: botCfg.larkAppSecret,
|
|
271
444
|
},
|
|
272
445
|
});
|
|
273
|
-
// Pipe worker stdout/stderr to daemon logger
|
|
446
|
+
// Pipe worker stdout/stderr to daemon logger.
|
|
447
|
+
// Both go through logger.info → daemon.log (not error.log). Worker stderr
|
|
448
|
+
// is NOT necessarily an error: CLI adapters (claude, codex, etc.) write
|
|
449
|
+
// progress, version banners, deprecation warnings, etc. there. The line
|
|
450
|
+
// is still visible (tagged `:err`) for triage. Real worker faults arrive
|
|
451
|
+
// separately via the IPC `Worker error` branch and stay as logger.error.
|
|
274
452
|
worker.stdout?.on('data', (data) => {
|
|
275
453
|
for (const line of data.toString().split('\n')) {
|
|
276
454
|
const trimmed = line.trim();
|
|
@@ -280,9 +458,7 @@ export function forkWorker(ds, prompt, resume = false) {
|
|
|
280
458
|
});
|
|
281
459
|
worker.stderr?.on('data', (data) => {
|
|
282
460
|
for (const line of data.toString().split('\n')) {
|
|
283
|
-
|
|
284
|
-
if (trimmed)
|
|
285
|
-
logger.error(`[${t}:worker] ${trimmed}`);
|
|
461
|
+
logWorkerStderr(t, line.trim());
|
|
286
462
|
}
|
|
287
463
|
});
|
|
288
464
|
// Send init config — use per-bot settings
|
|
@@ -290,13 +466,14 @@ export function forkWorker(ds, prompt, resume = false) {
|
|
|
290
466
|
type: 'init',
|
|
291
467
|
sessionId: ds.session.sessionId,
|
|
292
468
|
chatId: ds.chatId,
|
|
293
|
-
rootMessageId: ds
|
|
469
|
+
rootMessageId: sessionAnchorId(ds),
|
|
294
470
|
workingDir: cwd,
|
|
295
471
|
cliId: botCfg.cliId,
|
|
296
472
|
cliPathOverride: botCfg.cliPathOverride,
|
|
297
473
|
backendType: botCfg.backendType ?? config.daemon.backendType,
|
|
298
474
|
prompt,
|
|
299
475
|
resume,
|
|
476
|
+
cliSessionId: ds.session.cliSessionId,
|
|
300
477
|
ownerOpenId: ds.ownerOpenId,
|
|
301
478
|
webPort: ds.session.webPort,
|
|
302
479
|
larkAppId: botCfg.larkAppId,
|
|
@@ -311,8 +488,23 @@ export function forkWorker(ds, prompt, resume = false) {
|
|
|
311
488
|
ds.worker = worker;
|
|
312
489
|
ds.spawnedAt = Date.now();
|
|
313
490
|
ds.cliVersion = currentCliVersion;
|
|
491
|
+
// Stamp cliId on the persisted session so the dashboard can show a CLI badge
|
|
492
|
+
// even after the session is closed. Subsequent updateSession spreads carry
|
|
493
|
+
// this field forward for free.
|
|
494
|
+
if (ds.session.cliId !== botCfg.cliId) {
|
|
495
|
+
ds.session.cliId = botCfg.cliId;
|
|
496
|
+
sessionStore.updateSession(ds.session);
|
|
497
|
+
}
|
|
314
498
|
sessionStore.updateSessionPid(ds.session.sessionId, worker.pid ?? null);
|
|
315
499
|
logger.info(`[${t}] Worker forked (pid: ${worker.pid}, active: ${cb.getActiveCount()})`);
|
|
500
|
+
// Reset the exit-emit flag for the freshly spawned worker so a subsequent
|
|
501
|
+
// exit publishes again (the previous lifecycle's flag would otherwise mask it).
|
|
502
|
+
ds.exitEventEmitted = false;
|
|
503
|
+
// Notify dashboard SSE subscribers a new session is live.
|
|
504
|
+
dashboardEventBus.publish({
|
|
505
|
+
type: 'session.spawned',
|
|
506
|
+
body: { session: composeRowFromActive(ds) },
|
|
507
|
+
});
|
|
316
508
|
}
|
|
317
509
|
// ─── Shared worker IPC handler ──────────────────────────────────────────────
|
|
318
510
|
function setupWorkerHandlers(ds, worker) {
|
|
@@ -320,9 +512,15 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
320
512
|
const t = tag(ds);
|
|
321
513
|
const bot = getBot(ds.larkAppId);
|
|
322
514
|
const botCfg = bot.config;
|
|
323
|
-
// Adopt mode flags — computed once, used in all buildStreamingCard calls
|
|
515
|
+
// Adopt mode flags — computed once, used in all buildStreamingCard calls.
|
|
516
|
+
// Bridge mode (the v3 default for /adopt) hides the legacy takeover
|
|
517
|
+
// button: the model never sees botmux, daemon harvests final output via
|
|
518
|
+
// the transcript watcher, and the old takeover path would SIGKILL the
|
|
519
|
+
// user's original CLI 1.5s after fork — incompatible with bridge intent.
|
|
520
|
+
// Explicit takeover will be re-introduced as `/adopt --takeover` in a
|
|
521
|
+
// follow-up patch with safe semantics (no implicit kill).
|
|
324
522
|
const isAdopt = !!ds.adoptedFrom;
|
|
325
|
-
const showTakeover =
|
|
523
|
+
const showTakeover = false;
|
|
326
524
|
worker.on('message', async (msg) => {
|
|
327
525
|
switch (msg.type) {
|
|
328
526
|
case 'ready': {
|
|
@@ -334,6 +532,14 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
334
532
|
const readOnlyUrl = `http://${config.web.externalHost}:${msg.port}`;
|
|
335
533
|
const writeUrl = `${readOnlyUrl}?token=${msg.token}`;
|
|
336
534
|
logger.info(`[${t}] Worker ready, terminal at ${readOnlyUrl}`);
|
|
535
|
+
// Dashboard: surface the new xterm port so the live terminal link works.
|
|
536
|
+
dashboardEventBus.publish({
|
|
537
|
+
type: 'session.update',
|
|
538
|
+
body: {
|
|
539
|
+
sessionId: ds.session.sessionId,
|
|
540
|
+
patch: { webPort: msg.port },
|
|
541
|
+
},
|
|
542
|
+
});
|
|
337
543
|
// If a previous streaming card survived (e.g. daemon restart), try to
|
|
338
544
|
// PATCH it with the new "starting" state instead of POSTing a fresh card.
|
|
339
545
|
// ds.streamCardPending forces a new card (e.g. mid-session repo switch
|
|
@@ -347,13 +553,17 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
347
553
|
// Reuse persisted nonce so existing card buttons (toggle/etc) keep working.
|
|
348
554
|
if (!ds.streamCardNonce)
|
|
349
555
|
ds.streamCardNonce = randomBytes(4).toString('hex');
|
|
350
|
-
const streamCardJson = buildStreamingCard(ds.session.sessionId, ds
|
|
556
|
+
const streamCardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, initTitle, ds.lastScreenContent ?? '', 'starting', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
|
|
351
557
|
await updateMessage(ds.larkAppId, restoredCardId, streamCardJson);
|
|
352
558
|
persistStreamCardState(ds);
|
|
353
559
|
// Re-sync worker's display mode (it starts fresh in 'hidden')
|
|
354
560
|
if (ds.worker && ds.displayMode && ds.displayMode !== 'hidden') {
|
|
355
561
|
ds.worker.send({ type: 'set_display_mode', mode: ds.displayMode });
|
|
356
562
|
}
|
|
563
|
+
// The restored card is now the active one — withdraw any cards
|
|
564
|
+
// frozen before the daemon went down so they don't pile up in the
|
|
565
|
+
// thread on each restart.
|
|
566
|
+
recallFrozenCards(ds);
|
|
357
567
|
logger.info(`[${t}] Reused existing streaming card ${restoredCardId.substring(0, 12)} after worker (re)start`);
|
|
358
568
|
break;
|
|
359
569
|
}
|
|
@@ -371,9 +581,13 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
371
581
|
try {
|
|
372
582
|
ds.streamCardNonce = randomBytes(4).toString('hex');
|
|
373
583
|
const initTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
|
|
374
|
-
const streamCardJson = buildStreamingCard(ds.session.sessionId, ds
|
|
375
|
-
ds.streamCardId = await cb.sessionReply(ds
|
|
584
|
+
const streamCardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, initTitle, '', 'starting', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
|
|
585
|
+
ds.streamCardId = await cb.sessionReply(sessionAnchorId(ds), streamCardJson, 'interactive', ds.larkAppId);
|
|
376
586
|
persistStreamCardState(ds);
|
|
587
|
+
// New card is live — recall any cards frozen by previous turns.
|
|
588
|
+
// Done after `streamCardId` is committed so we never delete the old
|
|
589
|
+
// card without a successor visible to the user.
|
|
590
|
+
recallFrozenCards(ds);
|
|
377
591
|
}
|
|
378
592
|
catch (err) {
|
|
379
593
|
if (err instanceof MessageWithdrawnError) {
|
|
@@ -388,8 +602,8 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
388
602
|
persistStreamCardState(ds);
|
|
389
603
|
// Fallback: send static session card
|
|
390
604
|
try {
|
|
391
|
-
const cardJson = buildSessionCard(ds.session.sessionId, ds
|
|
392
|
-
await cb.sessionReply(ds
|
|
605
|
+
const cardJson = buildSessionCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, ds.session.title || getCliDisplayName(botCfg.cliId), botCfg.cliId, undefined, !!ds.adoptedFrom);
|
|
606
|
+
await cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId);
|
|
393
607
|
}
|
|
394
608
|
catch (fallbackErr) {
|
|
395
609
|
if (fallbackErr instanceof MessageWithdrawnError) {
|
|
@@ -413,6 +627,23 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
413
627
|
const prevStatus = ds.lastScreenStatus;
|
|
414
628
|
ds.lastScreenContent = msg.content;
|
|
415
629
|
ds.lastScreenStatus = msg.status;
|
|
630
|
+
// Dashboard: publish a patch only when status truly transitioned, so
|
|
631
|
+
// SSE clients reflect real state changes (starting → working → idle)
|
|
632
|
+
// without flooding on every PTY tick. The screen analyzer is the
|
|
633
|
+
// upstream debouncer — by the time we get here, status flips are
|
|
634
|
+
// already coarse-grained.
|
|
635
|
+
if (prevStatus !== msg.status) {
|
|
636
|
+
dashboardEventBus.publish({
|
|
637
|
+
type: 'session.update',
|
|
638
|
+
body: {
|
|
639
|
+
sessionId: ds.session.sessionId,
|
|
640
|
+
patch: {
|
|
641
|
+
status: ds.lastScreenStatus,
|
|
642
|
+
lastMessageAt: ds.lastMessageAt,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
}
|
|
416
647
|
const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
|
|
417
648
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
|
|
418
649
|
const mode = ds.displayMode ?? 'hidden';
|
|
@@ -428,13 +659,22 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
428
659
|
// New turn → image_key from previous turn no longer valid
|
|
429
660
|
if (isNewTurn)
|
|
430
661
|
ds.currentImageKey = undefined;
|
|
431
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, ds
|
|
662
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, isNewTurn ? '' : msg.content, msg.status, botCfg.cliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
|
|
432
663
|
// Mark POST in-flight so subsequent screen_updates are dropped,
|
|
433
664
|
// not POSTed as duplicate cards.
|
|
434
665
|
ds.streamCardPending = false;
|
|
435
666
|
ds.streamCardId = CARD_POSTING_SENTINEL;
|
|
436
|
-
cb.sessionReply(ds
|
|
437
|
-
.then(msgId => {
|
|
667
|
+
cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId)
|
|
668
|
+
.then(msgId => {
|
|
669
|
+
ds.streamCardId = msgId;
|
|
670
|
+
persistStreamCardState(ds);
|
|
671
|
+
// New card live — recall any cards parked by previous turns
|
|
672
|
+
// (user message, bot @mention, adopt-bridge new turn, etc.).
|
|
673
|
+
// This is the main turn-to-turn POST path; without recall here,
|
|
674
|
+
// every long session would leak old streaming cards into the
|
|
675
|
+
// thread.
|
|
676
|
+
recallFrozenCards(ds);
|
|
677
|
+
})
|
|
438
678
|
.catch(err => {
|
|
439
679
|
if (err instanceof MessageWithdrawnError) {
|
|
440
680
|
logger.warn(`[${t}] Root message withdrawn, closing stale session`);
|
|
@@ -453,7 +693,7 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
453
693
|
const statusChanged = prevStatus !== msg.status;
|
|
454
694
|
if (!statusChanged)
|
|
455
695
|
break;
|
|
456
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, ds
|
|
696
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, msg.content, msg.status, botCfg.cliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
|
|
457
697
|
scheduleCardPatch(ds, cardJson);
|
|
458
698
|
}
|
|
459
699
|
break;
|
|
@@ -472,7 +712,7 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
472
712
|
break;
|
|
473
713
|
const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
|
|
474
714
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
|
|
475
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, ds
|
|
715
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', msg.status, botCfg.cliId, 'screenshot', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
|
|
476
716
|
scheduleCardPatch(ds, cardJson);
|
|
477
717
|
break;
|
|
478
718
|
}
|
|
@@ -487,10 +727,20 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
487
727
|
ds.tuiPromptOptions = msg.options;
|
|
488
728
|
ds.tuiPromptMultiSelect = msg.multiSelect;
|
|
489
729
|
ds.tuiToggledIndices = [];
|
|
730
|
+
const prevTuiTurnTitle = ds.currentTurnTitle;
|
|
490
731
|
ds.currentTurnTitle = msg.description; // store for card PATCH on toggle
|
|
732
|
+
if (prevTuiTurnTitle !== ds.currentTurnTitle) {
|
|
733
|
+
dashboardEventBus.publish({
|
|
734
|
+
type: 'session.update',
|
|
735
|
+
body: {
|
|
736
|
+
sessionId: ds.session.sessionId,
|
|
737
|
+
patch: { title: ds.currentTurnTitle },
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
}
|
|
491
741
|
try {
|
|
492
|
-
const cardJson = buildTuiPromptCard(ds
|
|
493
|
-
const cardMsgId = await cb.sessionReply(ds
|
|
742
|
+
const cardJson = buildTuiPromptCard(sessionAnchorId(ds), ds.session.sessionId, msg.description, msg.options, msg.multiSelect);
|
|
743
|
+
const cardMsgId = await cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId);
|
|
494
744
|
ds.tuiPromptCardId = cardMsgId;
|
|
495
745
|
}
|
|
496
746
|
catch (err) {
|
|
@@ -519,14 +769,21 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
519
769
|
if (ds.streamCardId && ds.workerPort) {
|
|
520
770
|
const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
|
|
521
771
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
|
|
522
|
-
const frozenCard = buildStreamingCard(ds.session.sessionId, ds
|
|
772
|
+
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover);
|
|
523
773
|
scheduleCardPatch(ds, frozenCard);
|
|
524
774
|
}
|
|
525
775
|
killWorker(ds);
|
|
526
|
-
|
|
527
|
-
|
|
776
|
+
// Skip the exit notice when the session was already closed via the
|
|
777
|
+
// ⏏ card button — card-handler already posted "已断开,原 CLI 会话
|
|
778
|
+
// 不受影响" right before killing us, so another exit message here
|
|
779
|
+
// is just noise. Natural exits (user typed `exit`, CLI crashed)
|
|
780
|
+
// leave status='active' and still get the notice.
|
|
781
|
+
if (ds.session.status !== 'closed') {
|
|
782
|
+
try {
|
|
783
|
+
await cb.sessionReply(sessionAnchorId(ds), '\u23cf 已采纳的 CLI 会话已退出', 'text', ds.larkAppId);
|
|
784
|
+
}
|
|
785
|
+
catch { /* best effort */ }
|
|
528
786
|
}
|
|
529
|
-
catch { /* best effort */ }
|
|
530
787
|
break;
|
|
531
788
|
}
|
|
532
789
|
// Rate-limit auto-restart to prevent crash loops
|
|
@@ -544,14 +801,14 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
544
801
|
if (ds.streamCardId && ds.workerPort) {
|
|
545
802
|
const readUrl = `http://${config.web.externalHost}:${ds.workerPort}`;
|
|
546
803
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(botCfg.cliId);
|
|
547
|
-
const frozenCard = buildStreamingCard(ds.session.sessionId, ds
|
|
804
|
+
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', botCfg.cliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey);
|
|
548
805
|
scheduleCardPatch(ds, frozenCard);
|
|
549
806
|
}
|
|
550
807
|
// Kill the worker process to free resources
|
|
551
808
|
killWorker(ds);
|
|
552
809
|
const cliName = getCliDisplayName(botCfg.cliId);
|
|
553
810
|
try {
|
|
554
|
-
await cb.sessionReply(ds
|
|
811
|
+
await cb.sessionReply(sessionAnchorId(ds), `\u26a0\ufe0f ${cliName} 在 1 分钟内崩溃 ${rc.count} 次,已停止自动重启。发消息可触发重新启动。`, 'text', ds.larkAppId);
|
|
555
812
|
}
|
|
556
813
|
catch (replyErr) {
|
|
557
814
|
if (replyErr instanceof MessageWithdrawnError) {
|
|
@@ -575,13 +832,55 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
575
832
|
case 'user_notify': {
|
|
576
833
|
logger.warn(`[${t}] Worker user_notify: ${msg.message}`);
|
|
577
834
|
try {
|
|
578
|
-
await cb.sessionReply(ds
|
|
835
|
+
await cb.sessionReply(sessionAnchorId(ds), msg.message, 'text', ds.larkAppId);
|
|
579
836
|
}
|
|
580
837
|
catch (err) {
|
|
581
838
|
logger.error(`[${t}] Failed to deliver user_notify to Lark: ${err.message}`);
|
|
582
839
|
}
|
|
583
840
|
break;
|
|
584
841
|
}
|
|
842
|
+
case 'final_output': {
|
|
843
|
+
// Adopt-bridge: worker harvested the assistant turn from Claude Code's
|
|
844
|
+
// transcript JSONL and forwarded it to us. Dedup by lastUuid so a
|
|
845
|
+
// re-drain after a noisy idle doesn't re-send the same answer.
|
|
846
|
+
if (!msg.content || !msg.content.trim())
|
|
847
|
+
break;
|
|
848
|
+
if (msg.lastUuid && ds.lastBridgeEmittedUuid === msg.lastUuid) {
|
|
849
|
+
logger.debug(`[${t}] final_output deduped (uuid ${msg.lastUuid.substring(0, 8)})`);
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
// Worker pops the turn off its queue right after emit, so it will
|
|
853
|
+
// NOT re-send this payload on its own. Daemon owns retry on
|
|
854
|
+
// transient Lark failures.
|
|
855
|
+
deliverFinalOutput(ds, msg, t, 0);
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
case 'adopt_preamble': {
|
|
859
|
+
// Adopt-bridge: surface the last completed user/assistant exchange
|
|
860
|
+
// from the adopted CLI session so the Lark thread has context to
|
|
861
|
+
// continue from. Best-effort — failure here just means the user
|
|
862
|
+
// won't see the preamble; adopt itself isn't blocked. Card chrome
|
|
863
|
+
// matches the regular markdown-card path (schema 2.0 + footer) so
|
|
864
|
+
// the assistant body renders with proper code blocks / tables /
|
|
865
|
+
// lists instead of arriving as a wall of plain text.
|
|
866
|
+
if (!ds.adoptedFrom) {
|
|
867
|
+
logger.warn(`[${t}] Ignored adopt_preamble from non-adopt worker`);
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
if (!msg.userText.trim() && !msg.assistantText.trim())
|
|
871
|
+
break;
|
|
872
|
+
const cardJson = buildContextualReplyCard({
|
|
873
|
+
title: '📜 /adopt 前最后一轮',
|
|
874
|
+
userText: msg.userText,
|
|
875
|
+
assistantText: msg.assistantText,
|
|
876
|
+
assistantLabel: getCliDisplayName(botCfg.cliId),
|
|
877
|
+
recipientOpenId: ds.session.ownerOpenId,
|
|
878
|
+
});
|
|
879
|
+
cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId).catch((err) => {
|
|
880
|
+
logger.warn(`[${t}] Failed to deliver adopt_preamble to Lark: ${err.message}`);
|
|
881
|
+
});
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
585
884
|
}
|
|
586
885
|
});
|
|
587
886
|
worker.on('exit', (code) => {
|
|
@@ -592,10 +891,91 @@ function setupWorkerHandlers(ds, worker) {
|
|
|
592
891
|
ds.worker = null;
|
|
593
892
|
ds.workerPort = null;
|
|
594
893
|
}
|
|
894
|
+
// Notify dashboard, but only once per session lifecycle. The
|
|
895
|
+
// dashboard-driven `closeSession()` path also publishes; whichever
|
|
896
|
+
// fires first wins, the other's emit is suppressed.
|
|
897
|
+
if (!ds.exitEventEmitted) {
|
|
898
|
+
ds.exitEventEmitted = true;
|
|
899
|
+
dashboardEventBus.publish({
|
|
900
|
+
type: 'session.exited',
|
|
901
|
+
body: {
|
|
902
|
+
sessionId: ds.session.sessionId,
|
|
903
|
+
reason: code === 0 ? 'graceful' : `exit_code_${code}`,
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
}
|
|
595
907
|
});
|
|
596
908
|
}
|
|
909
|
+
// ─── Bridge final-output delivery (with retry) ──────────────────────────────
|
|
910
|
+
const FINAL_OUTPUT_RETRY_BACKOFF_MS = [0, 5000, 15000]; // immediate, +5s, +15s
|
|
911
|
+
/** Deliver a bridge `final_output` to Lark. The worker emits each turn
|
|
912
|
+
* exactly once (it pops the turn off its queue at emit time), so the
|
|
913
|
+
* daemon owns retries on transient failures. After 3 attempts we log
|
|
914
|
+
* and give up — the user's answer is lost; better than leaking memory
|
|
915
|
+
* via an unbounded retry loop. */
|
|
916
|
+
function deliverFinalOutput(ds, msg, t, attempt) {
|
|
917
|
+
const cb = requireCallbacks();
|
|
918
|
+
setTimeout(async () => {
|
|
919
|
+
// Guard: if the user closed the session (or it was torn down for any
|
|
920
|
+
// other reason) between attempts, don't post a stale final answer to
|
|
921
|
+
// a closed thread.
|
|
922
|
+
if (ds.session.status === 'closed') {
|
|
923
|
+
logger.info(`[${t}] Bridge final_output abandoned — session closed (turn ${msg.turnId.substring(0, 8)})`);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
// Wrap the model's reply in the same card chrome `botmux send` uses
|
|
928
|
+
// (schema 2.0 + footer with botmux link + 发送给 owner) so a turn
|
|
929
|
+
// delivered via this fallback path looks identical in the Lark thread
|
|
930
|
+
// to one the model sent itself. Markdown rendering, tables, code
|
|
931
|
+
// blocks all flow through the shared `buildCardBodyElements`.
|
|
932
|
+
//
|
|
933
|
+
// Local-turn variants (kind = 'local-turn' / 'local-turn-headless')
|
|
934
|
+
// also surface the user-side prompt synced from the adopted pane;
|
|
935
|
+
// they use the contextual card so the user prompt sits in a
|
|
936
|
+
// blockquote and only the assistant body goes through full markdown
|
|
937
|
+
// rendering.
|
|
938
|
+
const cardJson = msg.kind === 'local-turn' || msg.kind === 'local-turn-headless'
|
|
939
|
+
? buildContextualReplyCard({
|
|
940
|
+
title: msg.kind === 'local-turn-headless'
|
|
941
|
+
? '🖥️ 终端本地对话续传(daemon 重启时模型正在输出)'
|
|
942
|
+
: '🖥️ 终端本地对话(在 adopted pane 中直接输入,已同步至飞书)',
|
|
943
|
+
userText: msg.kind === 'local-turn' ? msg.userText ?? '' : undefined,
|
|
944
|
+
assistantText: msg.content,
|
|
945
|
+
assistantLabel: getCliDisplayName(getBot(ds.larkAppId).config.cliId),
|
|
946
|
+
recipientOpenId: ds.session.ownerOpenId,
|
|
947
|
+
})
|
|
948
|
+
: buildMarkdownCard(msg.content, ds.session.ownerOpenId);
|
|
949
|
+
await cb.sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId);
|
|
950
|
+
ds.lastBridgeEmittedUuid = msg.lastUuid;
|
|
951
|
+
logger.info(`[${t}] Bridge final_output forwarded (turn ${msg.turnId.substring(0, 8)}, ${msg.content.length} chars, kind=${msg.kind ?? 'bridge'}, attempt ${attempt + 1})`);
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
if (err instanceof MessageWithdrawnError) {
|
|
955
|
+
// Root message gone — no point retrying. Mark as emitted so any
|
|
956
|
+
// duplicate IPC is correctly deduped, and tear the session down.
|
|
957
|
+
ds.lastBridgeEmittedUuid = msg.lastUuid;
|
|
958
|
+
logger.warn(`[${t}] Root message withdrawn while forwarding final_output, closing session`);
|
|
959
|
+
cb.closeSession(ds);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const next = attempt + 1;
|
|
963
|
+
if (next >= FINAL_OUTPUT_RETRY_BACKOFF_MS.length) {
|
|
964
|
+
logger.error(`[${t}] Bridge final_output gave up after ${next} attempts (turn ${msg.turnId.substring(0, 8)}): ${err.message}`);
|
|
965
|
+
// Don't commit the dedup marker — leave room for any future
|
|
966
|
+
// retransmit (e.g. daemon restart that re-fires the IPC).
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
logger.warn(`[${t}] Bridge final_output attempt ${next} failed (${err.message}); retrying in ${FINAL_OUTPUT_RETRY_BACKOFF_MS[next]}ms`);
|
|
970
|
+
deliverFinalOutput(ds, msg, t, next);
|
|
971
|
+
}
|
|
972
|
+
}, FINAL_OUTPUT_RETRY_BACKOFF_MS[attempt] ?? 0);
|
|
973
|
+
}
|
|
974
|
+
/** Test-only alias so the retry pipeline can be exercised without a real
|
|
975
|
+
* fork. Intentionally underscored to discourage non-test callers. */
|
|
976
|
+
export const __testOnly_deliverFinalOutput = deliverFinalOutput;
|
|
597
977
|
// ─── Fork adopt worker ──────────────────────────────────────────────────────
|
|
598
|
-
export function forkAdoptWorker(ds) {
|
|
978
|
+
export function forkAdoptWorker(ds, opts) {
|
|
599
979
|
const cb = requireCallbacks();
|
|
600
980
|
const workerPath = join(__dirname, '..', 'worker.js');
|
|
601
981
|
const t = tag(ds);
|
|
@@ -631,7 +1011,8 @@ export function forkAdoptWorker(ds) {
|
|
|
631
1011
|
LARK_APP_SECRET: botCfg.larkAppSecret,
|
|
632
1012
|
},
|
|
633
1013
|
});
|
|
634
|
-
// Pipe worker stdout/stderr
|
|
1014
|
+
// Pipe worker stdout/stderr — both go through logger.info (→ daemon.log,
|
|
1015
|
+
// not error.log). See forkWorker for the rationale.
|
|
635
1016
|
worker.stdout?.on('data', (data) => {
|
|
636
1017
|
for (const line of data.toString().split('\n')) {
|
|
637
1018
|
const trimmed = line.trim();
|
|
@@ -641,18 +1022,33 @@ export function forkAdoptWorker(ds) {
|
|
|
641
1022
|
});
|
|
642
1023
|
worker.stderr?.on('data', (data) => {
|
|
643
1024
|
for (const line of data.toString().split('\n')) {
|
|
644
|
-
|
|
645
|
-
if (trimmed)
|
|
646
|
-
logger.error(`[${t}:worker] ${trimmed}`);
|
|
1025
|
+
logWorkerStderr(t, line.trim());
|
|
647
1026
|
}
|
|
648
1027
|
});
|
|
1028
|
+
// Bridge mode is gated per-CLI:
|
|
1029
|
+
// - claude-code: needs sessionId to compute jsonl path. PID + cwd let
|
|
1030
|
+
// the worker follow Claude's `/clear` / `/resume` rotations.
|
|
1031
|
+
// - codex: worker resolves the rollout path either from cliSessionId
|
|
1032
|
+
// (passed below when known) or by reading the Codex pid's open fds
|
|
1033
|
+
// in /proc — so we always pass the pid for codex adopt.
|
|
1034
|
+
// - coco: events.jsonl path is `~/.cache/coco/sessions/<sid>/events.jsonl`,
|
|
1035
|
+
// deterministic from cliSessionId. PID is the fallback when discovery
|
|
1036
|
+
// missed (events.jsonl isn't held open continuously, so worker may need
|
|
1037
|
+
// to re-probe via session.log / traces.jsonl fds).
|
|
1038
|
+
// Other CLIs fall back to legacy screen-capture only.
|
|
1039
|
+
const adoptedCliId = adopted.cliId ?? 'claude-code';
|
|
1040
|
+
const bridgeJsonlPath = adoptedCliId === 'claude-code' && adopted.sessionId
|
|
1041
|
+
? claudeJsonlPathForSession(adopted.sessionId, adopted.cwd)
|
|
1042
|
+
: undefined;
|
|
1043
|
+
const isStructuredBridge = adoptedCliId === 'codex' || adoptedCliId === 'coco';
|
|
649
1044
|
const initMsg = {
|
|
650
1045
|
type: 'init',
|
|
651
1046
|
sessionId: ds.session.sessionId,
|
|
652
1047
|
chatId: ds.chatId,
|
|
653
|
-
rootMessageId: ds
|
|
1048
|
+
rootMessageId: sessionAnchorId(ds),
|
|
654
1049
|
workingDir: adopted.cwd,
|
|
655
|
-
cliId:
|
|
1050
|
+
cliId: adoptedCliId,
|
|
1051
|
+
cliSessionId: isStructuredBridge ? adopted.sessionId : undefined,
|
|
656
1052
|
backendType: 'tmux',
|
|
657
1053
|
prompt: '',
|
|
658
1054
|
resume: false,
|
|
@@ -666,6 +1062,19 @@ export function forkAdoptWorker(ds) {
|
|
|
666
1062
|
adoptTmuxTarget: adopted.tmuxTarget,
|
|
667
1063
|
adoptPaneCols: adopted.paneCols,
|
|
668
1064
|
adoptPaneRows: adopted.paneRows,
|
|
1065
|
+
bridgeJsonlPath,
|
|
1066
|
+
// PID + cwd: claude uses for `~/.claude/sessions/<pid>.json` resolver;
|
|
1067
|
+
// codex uses for `/proc/<pid>/fd` rollout discovery (works even if
|
|
1068
|
+
// session-discovery couldn't probe sessionId up-front).
|
|
1069
|
+
adoptCliPid: (adoptedCliId === 'claude-code' || isStructuredBridge) ? adopted.originalCliPid : undefined,
|
|
1070
|
+
adoptCwd: (adoptedCliId === 'claude-code' || isStructuredBridge) ? adopted.cwd : undefined,
|
|
1071
|
+
// Restored-from-metadata: this fork is recreating an /adopt session after
|
|
1072
|
+
// a daemon restart, NOT a fresh /adopt command. The Lark thread already
|
|
1073
|
+
// has every prior turn pushed as cards, so the worker should skip the
|
|
1074
|
+
// "📜 /adopt 前最后一轮" preamble (it would surface a stale turn from
|
|
1075
|
+
// whichever jsonl was current at the original /adopt time, which may be
|
|
1076
|
+
// way out of date if the user has /clear'd since).
|
|
1077
|
+
adoptRestoredFromMetadata: opts?.restoredFromMetadata === true ? true : undefined,
|
|
669
1078
|
};
|
|
670
1079
|
worker.send(initMsg);
|
|
671
1080
|
ds.initConfig = initMsg;
|
|
@@ -674,7 +1083,19 @@ export function forkAdoptWorker(ds) {
|
|
|
674
1083
|
ds.worker = worker;
|
|
675
1084
|
ds.spawnedAt = Date.now();
|
|
676
1085
|
ds.cliVersion = '';
|
|
1086
|
+
// Stamp cliId on the persisted session so the dashboard can show a CLI badge
|
|
1087
|
+
// even after the session is closed. Adopt sessions inherit the adopted CLI's id.
|
|
1088
|
+
const adoptedCliIdTyped = adoptedCliId;
|
|
1089
|
+
if (ds.session.cliId !== adoptedCliIdTyped) {
|
|
1090
|
+
ds.session.cliId = adoptedCliIdTyped;
|
|
1091
|
+
sessionStore.updateSession(ds.session);
|
|
1092
|
+
}
|
|
677
1093
|
logger.info(`[${t}] Adopt worker forked (pid: ${worker.pid}, target: ${adopted.tmuxTarget})`);
|
|
1094
|
+
ds.exitEventEmitted = false;
|
|
1095
|
+
dashboardEventBus.publish({
|
|
1096
|
+
type: 'session.spawned',
|
|
1097
|
+
body: { session: composeRowFromActive(ds) },
|
|
1098
|
+
});
|
|
678
1099
|
}
|
|
679
1100
|
// ─── Kill stale PIDs ────────────────────────────────────────────────────────
|
|
680
1101
|
export function killStalePids(activeSessions_) {
|