mstro-app 0.5.1 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +9 -9
- package/README.md +71 -28
- package/bin/commands/config.js +1 -1
- package/bin/mstro.js +55 -4
- package/dist/server/cli/eta-estimator.d.ts +55 -0
- package/dist/server/cli/eta-estimator.d.ts.map +1 -0
- package/dist/server/cli/eta-estimator.js +222 -0
- package/dist/server/cli/eta-estimator.js.map +1 -0
- package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-process.js +9 -1
- package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.d.ts +22 -5
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +7 -5
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +19 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +64 -9
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +19 -12
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +16 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.js +5 -1
- package/dist/server/cli/improvisation-history-store.js.map +1 -1
- package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
- package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
- package/dist/server/cli/improvisation-output-queue.js +30 -7
- package/dist/server/cli/improvisation-output-queue.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +35 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +58 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +9 -0
- package/dist/server/cli/improvisation-types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-types.js.map +1 -1
- package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -1
- package/dist/server/cli/retry/retry-runner-factory.js +1 -0
- package/dist/server/cli/retry/retry-runner-factory.js.map +1 -1
- package/dist/server/engines/EngineEvent.d.ts +126 -0
- package/dist/server/engines/EngineEvent.d.ts.map +1 -0
- package/dist/server/engines/EngineEvent.js +11 -0
- package/dist/server/engines/EngineEvent.js.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
- package/dist/server/engines/factory.d.ts +21 -0
- package/dist/server/engines/factory.d.ts.map +1 -0
- package/dist/server/engines/factory.js +152 -0
- package/dist/server/engines/factory.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
- package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
- package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
- package/dist/server/engines/opencode/model-catalog.js +141 -0
- package/dist/server/engines/opencode/model-catalog.js.map +1 -0
- package/dist/server/engines/types.d.ts +146 -0
- package/dist/server/engines/types.d.ts.map +1 -0
- package/dist/server/engines/types.js +4 -0
- package/dist/server/engines/types.js.map +1 -0
- package/dist/server/index.js +9 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +8 -124
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +45 -0
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +69 -5
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/factory.d.ts +70 -0
- package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
- package/dist/server/mcp/classifier/factory.js +155 -0
- package/dist/server/mcp/classifier/factory.js.map +1 -0
- package/dist/server/mcp/server.js +52 -0
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.d.ts.map +1 -1
- package/dist/server/routes/index.js +1 -0
- package/dist/server/routes/index.js.map +1 -1
- package/dist/server/routes/internal.d.ts +16 -0
- package/dist/server/routes/internal.d.ts.map +1 -0
- package/dist/server/routes/internal.js +94 -0
- package/dist/server/routes/internal.js.map +1 -0
- package/dist/server/services/plan/agent-resolver.d.ts +26 -0
- package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/agent-resolver.js +102 -0
- package/dist/server/services/plan/agent-resolver.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +59 -11
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +3 -1
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.js +33 -1
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +1 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/runtime-info.d.ts +3 -0
- package/dist/server/services/runtime-info.d.ts.map +1 -0
- package/dist/server/services/runtime-info.js +21 -0
- package/dist/server/services/runtime-info.js.map +1 -0
- package/dist/server/services/settings.d.ts +76 -2
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +127 -4
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/websocket/ask-user-question-bridge.d.ts +32 -0
- package/dist/server/services/websocket/ask-user-question-bridge.d.ts.map +1 -0
- package/dist/server/services/websocket/ask-user-question-bridge.js +115 -0
- package/dist/server/services/websocket/ask-user-question-bridge.js.map +1 -0
- package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.js +19 -6
- package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +25 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +84 -2
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-complexity.js +78 -26
- package/dist/server/services/websocket/quality-complexity.js.map +1 -1
- package/dist/server/services/websocket/quality-eta.d.ts +47 -0
- package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-eta.js +110 -0
- package/dist/server/services/websocket/quality-eta.js.map +1 -0
- package/dist/server/services/websocket/quality-grading.d.ts +27 -4
- package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-grading.js +369 -201
- package/dist/server/services/websocket/quality-grading.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +145 -7
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-operations.d.ts +34 -0
- package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-operations.js +47 -0
- package/dist/server/services/websocket/quality-operations.js.map +1 -0
- package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +10 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +105 -56
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +9 -1
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +334 -14
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts +21 -0
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +49 -0
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +35 -2
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +3 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +60 -9
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-history.js +3 -0
- package/dist/server/services/websocket/session-history.js.map +1 -1
- package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
- package/dist/server/services/websocket/session-initialization.js +158 -42
- package/dist/server/services/websocket/session-initialization.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +25 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +19 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/settings-handlers.js +35 -4
- package/dist/server/services/websocket/settings-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
- package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.js +10 -2
- package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
- package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.js +138 -12
- package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
- package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.js +55 -2
- package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +47 -2
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +67 -7
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +12 -6
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +5 -3
- package/server/cli/eta-estimator.ts +249 -0
- package/server/cli/headless/claude-invoker-process.ts +9 -1
- package/server/cli/headless/mcp-config.ts +30 -5
- package/server/cli/headless/runner.ts +21 -0
- package/server/cli/headless/stall-assessor.ts +93 -0
- package/server/cli/headless/tool-watchdog.ts +21 -0
- package/server/cli/headless/types.ts +16 -1
- package/server/cli/improvisation-history-store.ts +4 -1
- package/server/cli/improvisation-output-queue.ts +29 -7
- package/server/cli/improvisation-session-manager.ts +63 -1
- package/server/cli/improvisation-types.ts +9 -0
- package/server/cli/retry/retry-runner-factory.ts +1 -0
- package/server/engines/EngineEvent.ts +156 -0
- package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
- package/server/engines/factory.ts +176 -0
- package/server/engines/opencode/OpenCodeEngine.ts +786 -0
- package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
- package/server/engines/opencode/model-catalog.ts +217 -0
- package/server/engines/types.ts +173 -0
- package/server/index.ts +9 -1
- package/server/mcp/bouncer-haiku.ts +21 -145
- package/server/mcp/bouncer-integration.ts +107 -5
- package/server/mcp/classifier/BouncerClassifier.ts +40 -0
- package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
- package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
- package/server/mcp/classifier/factory.ts +195 -0
- package/server/mcp/server.ts +57 -0
- package/server/routes/index.ts +1 -0
- package/server/routes/internal.ts +112 -0
- package/server/services/plan/agent-resolver.ts +115 -0
- package/server/services/plan/agents/code-review.md +38 -8
- package/server/services/plan/composer.ts +63 -11
- package/server/services/plan/executor.ts +3 -1
- package/server/services/plan/issue-prompt-builder.ts +39 -1
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/types.ts +4 -0
- package/server/services/runtime-info.ts +24 -0
- package/server/services/settings.ts +161 -4
- package/server/services/websocket/ask-user-question-bridge.ts +148 -0
- package/server/services/websocket/git-branch-handlers.ts +20 -6
- package/server/services/websocket/handler.ts +89 -2
- package/server/services/websocket/quality-complexity.ts +80 -26
- package/server/services/websocket/quality-eta.ts +155 -0
- package/server/services/websocket/quality-grading.ts +445 -222
- package/server/services/websocket/quality-handlers.ts +153 -7
- package/server/services/websocket/quality-operations.ts +72 -0
- package/server/services/websocket/quality-persistence.ts +17 -0
- package/server/services/websocket/quality-review-agent.ts +154 -64
- package/server/services/websocket/quality-service.ts +361 -13
- package/server/services/websocket/quality-tools.ts +51 -0
- package/server/services/websocket/quality-types.ts +41 -2
- package/server/services/websocket/session-handlers.ts +67 -10
- package/server/services/websocket/session-history.ts +3 -0
- package/server/services/websocket/session-initialization.ts +189 -46
- package/server/services/websocket/session-registry.ts +37 -0
- package/server/services/websocket/settings-handlers.ts +41 -4
- package/server/services/websocket/tab-broadcast.ts +10 -2
- package/server/services/websocket/tab-event-buffer.ts +143 -11
- package/server/services/websocket/tab-event-replay.ts +70 -3
- package/server/services/websocket/tab-handlers.ts +53 -5
- package/server/services/websocket/types.ts +85 -7
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ETA estimator for the chat composing indicator.
|
|
5
|
+
*
|
|
6
|
+
* Reads recent movements from `.mstro/history/*.json` and builds a small
|
|
7
|
+
* conditional-quantile table: for each elapsed-time checkpoint, the p50/p90
|
|
8
|
+
* of TOTAL movement duration among movements that hadn't finished yet at
|
|
9
|
+
* that elapsed time. The web indicator interpolates against this table to
|
|
10
|
+
* render "Composing · {elapsed} · ~{p50} typical · {tokens}".
|
|
11
|
+
*
|
|
12
|
+
* Why conditional-on-elapsed and not a regression on prompt features:
|
|
13
|
+
* - prompt length is uncorrelated with duration (r≈0.05); tool count is
|
|
14
|
+
* strong (r≈0.74) but unknown a priori. Conditioning on elapsed alone
|
|
15
|
+
* beats a static estimate dramatically — accuracy at 5m elapsed is
|
|
16
|
+
* ~38% MAPE vs 160% at 0s with the same lookup, because the longer the
|
|
17
|
+
* run goes, the smaller the cohort it could still belong to.
|
|
18
|
+
*
|
|
19
|
+
* Why a quantile table and not a regression model:
|
|
20
|
+
* - The duration distribution is heavily skewed (mean 4m20s, median 1m49s,
|
|
21
|
+
* p99 29m). A point estimate from a regression would be misleading; the
|
|
22
|
+
* web shows a typical/range pair so users see "around X, can be up to Y".
|
|
23
|
+
*
|
|
24
|
+
* Sample selection:
|
|
25
|
+
* - Up to MAX_SAMPLE_FILES most recent files by mtime, keeping work bounded
|
|
26
|
+
* and biasing toward recent behavior. Movements with durationMs < 1s or
|
|
27
|
+
* above SANITY_CEILING_MS are dropped as outliers (cancelled before they
|
|
28
|
+
* started, or runaway sessions that don't represent typical waits).
|
|
29
|
+
*
|
|
30
|
+
* Returns `null` when there are fewer than MIN_SAMPLES movements; the caller
|
|
31
|
+
* falls back to "no ETA" rather than inventing one from too little data.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { promises as fsp } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import type { SessionHistory } from './improvisation-types.js';
|
|
37
|
+
|
|
38
|
+
/** Bucket boundaries (ms) at which we precompute conditional quantiles. */
|
|
39
|
+
const ELAPSED_CHECKPOINTS_MS = [
|
|
40
|
+
0, // a-priori (elapsed=0)
|
|
41
|
+
10_000, // 10s
|
|
42
|
+
30_000, // 30s
|
|
43
|
+
60_000, // 1m
|
|
44
|
+
120_000, // 2m
|
|
45
|
+
300_000, // 5m
|
|
46
|
+
600_000, // 10m
|
|
47
|
+
900_000, // 15m
|
|
48
|
+
1_500_000, // 25m
|
|
49
|
+
2_400_000, // 40m
|
|
50
|
+
3_600_000, // 60m
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const MAX_SAMPLE_FILES = 200;
|
|
54
|
+
const MIN_SAMPLES = 30;
|
|
55
|
+
const SANITY_FLOOR_MS = 1_000; // <1s = noise (errors, instant cancels)
|
|
56
|
+
const SANITY_CEILING_MS = 6 * 60 * 60_000; // 6h cap
|
|
57
|
+
|
|
58
|
+
export interface EtaBucket {
|
|
59
|
+
/** Elapsed-ms threshold for this bucket. */
|
|
60
|
+
elapsedMs: number;
|
|
61
|
+
/** Conditional p50 of TOTAL duration among movements still running at elapsedMs. */
|
|
62
|
+
p50TotalMs: number;
|
|
63
|
+
/** Conditional p90 of TOTAL duration. */
|
|
64
|
+
p90TotalMs: number;
|
|
65
|
+
/** Sample count behind this bucket. */
|
|
66
|
+
n: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EtaProfile {
|
|
70
|
+
/** Buckets in ascending elapsedMs. */
|
|
71
|
+
buckets: EtaBucket[];
|
|
72
|
+
/** Number of movements the profile was built from. */
|
|
73
|
+
sampleSize: number;
|
|
74
|
+
/** ISO timestamp of when this profile was computed. */
|
|
75
|
+
computedAt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface EtaPrediction {
|
|
79
|
+
/** Predicted total duration (p50). Always >= elapsed. */
|
|
80
|
+
p50TotalMs: number;
|
|
81
|
+
/** Predicted upper bound (p90). Always >= p50. */
|
|
82
|
+
p90TotalMs: number;
|
|
83
|
+
/** Sample size for the bucket used. */
|
|
84
|
+
n: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build an EtaProfile from a `.mstro/history/` directory. Returns null if
|
|
89
|
+
* there isn't enough data to form a stable estimate.
|
|
90
|
+
*/
|
|
91
|
+
export async function buildEtaProfile(
|
|
92
|
+
historyDir: string,
|
|
93
|
+
opts: { maxFiles?: number } = {},
|
|
94
|
+
): Promise<EtaProfile | null> {
|
|
95
|
+
const maxFiles = opts.maxFiles ?? MAX_SAMPLE_FILES;
|
|
96
|
+
const durations = await collectRecentDurations(historyDir, maxFiles);
|
|
97
|
+
if (durations.length < MIN_SAMPLES) return null;
|
|
98
|
+
return buildProfileFromDurations(durations);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Cached variant for the WebSocket flow: same project's many tabs ask for
|
|
103
|
+
* the same profile within minutes of each other, and rescanning 200 files
|
|
104
|
+
* each time wastes I/O. Cache by historyDir with a TTL so that fresh
|
|
105
|
+
* movements eventually feed back into the estimate.
|
|
106
|
+
*
|
|
107
|
+
* Falls back to BASELINE_ETA_PROFILE when the local history is too thin —
|
|
108
|
+
* new installs still get a sensible "Composing · Xs / ~Ys" indicator from
|
|
109
|
+
* prompt 1 instead of waiting for 30+ runs to accumulate.
|
|
110
|
+
*/
|
|
111
|
+
const PROFILE_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
|
|
112
|
+
const profileCache = new Map<string, { profile: EtaProfile | null; expiresAt: number; pending?: Promise<EtaProfile | null> }>();
|
|
113
|
+
|
|
114
|
+
export async function getEtaProfileCached(historyDir: string): Promise<EtaProfile | null> {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const hit = profileCache.get(historyDir);
|
|
117
|
+
if (hit && hit.expiresAt > now) return hit.profile ?? BASELINE_ETA_PROFILE;
|
|
118
|
+
if (hit?.pending) return hit.pending;
|
|
119
|
+
const pending = buildEtaProfile(historyDir).then(profile => {
|
|
120
|
+
profileCache.set(historyDir, { profile, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS });
|
|
121
|
+
return profile ?? BASELINE_ETA_PROFILE;
|
|
122
|
+
}).catch(() => {
|
|
123
|
+
profileCache.set(historyDir, { profile: null, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS });
|
|
124
|
+
return BASELINE_ETA_PROFILE;
|
|
125
|
+
});
|
|
126
|
+
profileCache.set(historyDir, { profile: hit?.profile ?? null, expiresAt: hit?.expiresAt ?? 0, pending });
|
|
127
|
+
return pending;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Test hook: clear the in-process cache. */
|
|
131
|
+
export function _clearEtaCache(): void { profileCache.clear(); }
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Baseline profile shipped in the package so a fresh install (no
|
|
135
|
+
* `.mstro/history`) still gets a useful "typical" estimate from the very
|
|
136
|
+
* first prompt. Numbers below were computed offline from the largest
|
|
137
|
+
* available real-world history sample (mstro's own project, 379 movements
|
|
138
|
+
* spanning short Q&A through multi-hour autonomous runs); they reflect a
|
|
139
|
+
* heavy mix of chat, planning, and execution prompts. Once a project
|
|
140
|
+
* accumulates >= MIN_SAMPLES local movements its own profile takes over.
|
|
141
|
+
*/
|
|
142
|
+
export const BASELINE_ETA_PROFILE: EtaProfile = {
|
|
143
|
+
buckets: [
|
|
144
|
+
{ elapsedMs: 0, p50TotalMs: 108_000, p90TotalMs: 768_000, n: 379 },
|
|
145
|
+
{ elapsedMs: 10_000, p50TotalMs: 117_000, p90TotalMs: 769_000, n: 368 },
|
|
146
|
+
{ elapsedMs: 30_000, p50TotalMs: 155_000, p90TotalMs: 860_000, n: 328 },
|
|
147
|
+
{ elapsedMs: 60_000, p50TotalMs: 245_000, p90TotalMs: 1_013_000, n: 252 },
|
|
148
|
+
{ elapsedMs: 120_000, p50TotalMs: 392_000, p90TotalMs: 1_171_000, n: 182 },
|
|
149
|
+
{ elapsedMs: 300_000, p50TotalMs: 605_000, p90TotalMs: 1_412_000, n: 116 },
|
|
150
|
+
{ elapsedMs: 600_000, p50TotalMs: 945_000, p90TotalMs: 1_679_000, n: 58 },
|
|
151
|
+
{ elapsedMs: 900_000, p50TotalMs: 1_265_000, p90TotalMs: 1_845_000, n: 30 },
|
|
152
|
+
{ elapsedMs: 1_500_000, p50TotalMs: 1_728_000, p90TotalMs: 1_986_000, n: 10 },
|
|
153
|
+
],
|
|
154
|
+
sampleSize: 379,
|
|
155
|
+
computedAt: '2026-05-06T00:00:00.000Z',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Synchronously build a profile from an in-memory list of durationMs values. Exposed for tests. */
|
|
159
|
+
export function buildProfileFromDurations(durationsMs: number[]): EtaProfile {
|
|
160
|
+
const cleaned = durationsMs
|
|
161
|
+
.filter(d => Number.isFinite(d) && d >= SANITY_FLOOR_MS && d <= SANITY_CEILING_MS)
|
|
162
|
+
.sort((a, b) => a - b);
|
|
163
|
+
const buckets: EtaBucket[] = [];
|
|
164
|
+
for (const elapsedMs of ELAPSED_CHECKPOINTS_MS) {
|
|
165
|
+
const stillRunning = cleaned.filter(d => d > elapsedMs);
|
|
166
|
+
if (stillRunning.length === 0) break;
|
|
167
|
+
buckets.push({
|
|
168
|
+
elapsedMs,
|
|
169
|
+
p50TotalMs: quantile(stillRunning, 0.5),
|
|
170
|
+
p90TotalMs: quantile(stillRunning, 0.9),
|
|
171
|
+
n: stillRunning.length,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
buckets,
|
|
176
|
+
sampleSize: cleaned.length,
|
|
177
|
+
computedAt: new Date().toISOString(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Predict total duration given current elapsed ms. Returns null if the
|
|
183
|
+
* profile has no usable buckets. The returned p50 is clamped to elapsed (so
|
|
184
|
+
* the indicator never shows a typical that has already passed).
|
|
185
|
+
*/
|
|
186
|
+
export function predictEta(profile: EtaProfile, elapsedMs: number): EtaPrediction | null {
|
|
187
|
+
if (profile.buckets.length === 0) return null;
|
|
188
|
+
let bucket: EtaBucket = profile.buckets[0];
|
|
189
|
+
for (const b of profile.buckets) {
|
|
190
|
+
if (b.elapsedMs <= elapsedMs) bucket = b;
|
|
191
|
+
else break;
|
|
192
|
+
}
|
|
193
|
+
// If elapsed has surpassed the last bucket's p50, the run is in the long
|
|
194
|
+
// tail. Keep the last bucket's quantiles but never report a "typical" that
|
|
195
|
+
// is shorter than elapsed itself — that would be nonsensical UX.
|
|
196
|
+
const p50TotalMs = Math.max(bucket.p50TotalMs, elapsedMs);
|
|
197
|
+
const p90TotalMs = Math.max(bucket.p90TotalMs, p50TotalMs);
|
|
198
|
+
return { p50TotalMs, p90TotalMs, n: bucket.n };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// -- internals --
|
|
202
|
+
|
|
203
|
+
async function collectRecentDurations(historyDir: string, maxFiles: number): Promise<number[]> {
|
|
204
|
+
let entries: string[];
|
|
205
|
+
try {
|
|
206
|
+
entries = (await fsp.readdir(historyDir)).filter(f => f.endsWith('.json'));
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
if (entries.length === 0) return [];
|
|
211
|
+
|
|
212
|
+
// Sort by mtime DESC for recency. statting up to N files is acceptable —
|
|
213
|
+
// even a few thousand stats is sub-100ms on local disk.
|
|
214
|
+
const stats = await Promise.all(
|
|
215
|
+
entries.map(async name => {
|
|
216
|
+
try {
|
|
217
|
+
const full = join(historyDir, name);
|
|
218
|
+
const s = await fsp.stat(full);
|
|
219
|
+
return { full, mtime: s.mtimeMs };
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
const ordered = stats
|
|
226
|
+
.filter((x): x is { full: string; mtime: number } => x !== null)
|
|
227
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
228
|
+
.slice(0, maxFiles);
|
|
229
|
+
|
|
230
|
+
const durations: number[] = [];
|
|
231
|
+
for (const { full } of ordered) {
|
|
232
|
+
let raw: string;
|
|
233
|
+
try { raw = await fsp.readFile(full, 'utf-8'); } catch { continue; }
|
|
234
|
+
let data: SessionHistory;
|
|
235
|
+
try { data = JSON.parse(raw) as SessionHistory; } catch { continue; }
|
|
236
|
+
if (!Array.isArray(data.movements)) continue;
|
|
237
|
+
for (const m of data.movements) {
|
|
238
|
+
const d = m.durationMs;
|
|
239
|
+
if (typeof d === 'number' && Number.isFinite(d)) durations.push(d);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return durations;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function quantile(sortedAsc: number[], q: number): number {
|
|
246
|
+
if (sortedAsc.length === 0) return 0;
|
|
247
|
+
const idx = Math.min(sortedAsc.length - 1, Math.floor(sortedAsc.length * q));
|
|
248
|
+
return sortedAsc[idx];
|
|
249
|
+
}
|
|
@@ -138,7 +138,15 @@ export async function spawnAndRegister(
|
|
|
138
138
|
runningProcesses: Map<number, ChildProcess>,
|
|
139
139
|
perfStart: number,
|
|
140
140
|
): Promise<ChildProcess> {
|
|
141
|
-
const
|
|
141
|
+
const askUserQuestionRouting = (config.tabId && config.mstroPort && config.bouncerSecret)
|
|
142
|
+
? { tabId: config.tabId, port: config.mstroPort, bouncerSecret: config.bouncerSecret }
|
|
143
|
+
: undefined;
|
|
144
|
+
const mcpConfigPath = generateMcpConfig(config.workingDir, config.verbose, {
|
|
145
|
+
userPrompt: prompt,
|
|
146
|
+
sessionId: randomUUID(),
|
|
147
|
+
deployMode: config.deployMode,
|
|
148
|
+
askUserQuestionRouting,
|
|
149
|
+
});
|
|
142
150
|
|
|
143
151
|
if (!mcpConfigPath && config.outputCallback) {
|
|
144
152
|
config.outputCallback(
|
|
@@ -58,16 +58,36 @@ function truncatePrompt(prompt: string): string {
|
|
|
58
58
|
return `${clean}... [truncated]`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Routing context for the AskUserQuestion bridge. The bouncer subprocess
|
|
63
|
+
* uses these env vars to call back into the CLI server when Claude pauses
|
|
64
|
+
* on AskUserQuestion. Optional — without them the bouncer falls back to
|
|
65
|
+
* passing the tool through with no answers (same as legacy behavior).
|
|
66
|
+
*/
|
|
67
|
+
export interface AskUserQuestionRouting {
|
|
68
|
+
/** Local CLI server port (e.g. 4101). */
|
|
69
|
+
port: number;
|
|
70
|
+
/** Tab the question should be routed to in the web UI. */
|
|
71
|
+
tabId: string;
|
|
72
|
+
/** Per-process bouncer secret from `getBouncerSecret()`. */
|
|
73
|
+
bouncerSecret: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface GenerateMcpConfigOptions {
|
|
77
|
+
userPrompt?: string;
|
|
78
|
+
/** Unique sessionId for the per-session config file name (filename only). */
|
|
79
|
+
sessionId?: string;
|
|
80
|
+
deployMode?: boolean;
|
|
81
|
+
askUserQuestionRouting?: AskUserQuestionRouting;
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
/**
|
|
62
85
|
* Generate MCP config with bouncer + user's MCP servers from ~/.claude.json.
|
|
63
86
|
* Writes to ~/.mstro/mcp-config-{sessionId}.json for use with --mcp-config flag.
|
|
64
87
|
* Per-session files prevent concurrent sessions from overwriting each other's config.
|
|
65
|
-
*
|
|
66
|
-
* @param userPrompt — The user's original prompt, passed to the bouncer so its
|
|
67
|
-
* AI layer can distinguish user-requested operations from prompt injection.
|
|
68
|
-
* @param sessionId — Unique session identifier for per-session config isolation.
|
|
69
88
|
*/
|
|
70
|
-
export function generateMcpConfig(workingDir: string, verbose: boolean = false,
|
|
89
|
+
export function generateMcpConfig(workingDir: string, verbose: boolean = false, options: GenerateMcpConfigOptions = {}): string | null {
|
|
90
|
+
const { userPrompt, sessionId, deployMode, askUserQuestionRouting } = options;
|
|
71
91
|
try {
|
|
72
92
|
if (!existsSync(MCP_SERVER_PATH)) {
|
|
73
93
|
herror(`[${new Date().toISOString()}] MCP server not found at ${MCP_SERVER_PATH}`);
|
|
@@ -86,6 +106,11 @@ export function generateMcpConfig(workingDir: string, verbose: boolean = false,
|
|
|
86
106
|
? truncatePrompt(userPrompt)
|
|
87
107
|
: userPrompt;
|
|
88
108
|
}
|
|
109
|
+
if (askUserQuestionRouting) {
|
|
110
|
+
bouncerEnv.MSTRO_PORT = String(askUserQuestionRouting.port);
|
|
111
|
+
bouncerEnv.MSTRO_TAB_ID = askUserQuestionRouting.tabId;
|
|
112
|
+
bouncerEnv.MSTRO_BOUNCER_SECRET = askUserQuestionRouting.bouncerSecret;
|
|
113
|
+
}
|
|
89
114
|
|
|
90
115
|
const mcpServers: Record<string, unknown> = {
|
|
91
116
|
'mstro-bouncer': {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ChildProcess } from 'node:child_process';
|
|
11
|
+
import { getCurrentMstroPort } from '../../services/runtime-info.js';
|
|
12
|
+
import { getBouncerSecret } from '../../services/websocket/ask-user-question-bridge.js';
|
|
11
13
|
import { type ClaudeInvokerOptions, executeClaudeCommand } from './claude-invoker.js';
|
|
12
14
|
import { estimateTokensFromOutput } from './output-utils.js';
|
|
13
15
|
import { enrichPromptWithContext } from './prompt-utils.js';
|
|
@@ -19,6 +21,22 @@ import type {
|
|
|
19
21
|
SessionResult,
|
|
20
22
|
} from './types.js';
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Process-wide singletons used to wire AskUserQuestion routing. Both return
|
|
26
|
+
* undefined if the server hasn't started yet (e.g. unit-test contexts that
|
|
27
|
+
* construct HeadlessRunner directly), in which case AskUserQuestion falls
|
|
28
|
+
* back to legacy "no answers" behavior.
|
|
29
|
+
*/
|
|
30
|
+
function readDefaultMstroPort(): number | undefined {
|
|
31
|
+
return getCurrentMstroPort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readDefaultBouncerSecret(): string | undefined {
|
|
35
|
+
// The bridge module's secret is generated at module-eval time, so it's
|
|
36
|
+
// always defined. We still null-coalesce in the caller for symmetry.
|
|
37
|
+
return getBouncerSecret();
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
// Re-export types for backward compatibility
|
|
23
41
|
export type { ExecutionCheckpoint, HeadlessConfig, ImageAttachment, SessionResult, SessionState, ToolTimeoutProfile, ToolUseEvent } from './types.js';
|
|
24
42
|
|
|
@@ -129,6 +147,9 @@ export class HeadlessRunner {
|
|
|
129
147
|
onToolTimeout: config.onToolTimeout,
|
|
130
148
|
extraEnv: config.extraEnv,
|
|
131
149
|
deployMode: config.deployMode,
|
|
150
|
+
tabId: config.tabId,
|
|
151
|
+
mstroPort: config.mstroPort ?? readDefaultMstroPort(),
|
|
152
|
+
bouncerSecret: config.bouncerSecret ?? readDefaultBouncerSecret(),
|
|
132
153
|
};
|
|
133
154
|
}
|
|
134
155
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* best result, error classification) live in haiku-assessments.ts.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { EngineEvent } from '../../engines/EngineEvent.js';
|
|
13
14
|
import { loadSkillPrompt } from '../../services/plan/agent-loader.js';
|
|
14
15
|
import { spawnHaikuRaw } from './haiku-assessments.js';
|
|
15
16
|
import { hlog } from './headless-logger.js';
|
|
@@ -36,6 +37,98 @@ export interface StallVerdict {
|
|
|
36
37
|
reason: string;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Mutable tool-activity accumulator fed by an engine-agnostic `EngineEvent`
|
|
42
|
+
* stream. Consumed by {@link buildStallContext} to produce the tool-related
|
|
43
|
+
* fields of a {@link StallContext} without coupling to any specific engine's
|
|
44
|
+
* internal shapes.
|
|
45
|
+
*/
|
|
46
|
+
export interface ToolActivityState {
|
|
47
|
+
/** Tool calls observed by `tool.start` but not yet ended. */
|
|
48
|
+
pendingToolIds: Set<string>;
|
|
49
|
+
/** Names of tools still pending (used by the stall heuristic). */
|
|
50
|
+
pendingToolNames: Set<string>;
|
|
51
|
+
/** Map of toolId -> toolName so `tool.end` can drop names when the last id goes. */
|
|
52
|
+
pendingToolNameById: Map<string, string>;
|
|
53
|
+
/** Last tool name seen via `tool.start`. */
|
|
54
|
+
lastToolName?: string;
|
|
55
|
+
/** Short summary of the last tool input (url/query/command/prompt). */
|
|
56
|
+
lastToolInputSummary?: string;
|
|
57
|
+
/** Total number of `tool.start` events observed this session. */
|
|
58
|
+
totalToolCalls: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Allocate a fresh, empty tool-activity state. */
|
|
62
|
+
export function createToolActivityState(): ToolActivityState {
|
|
63
|
+
return {
|
|
64
|
+
pendingToolIds: new Set(),
|
|
65
|
+
pendingToolNames: new Set(),
|
|
66
|
+
pendingToolNameById: new Map(),
|
|
67
|
+
totalToolCalls: 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Update a {@link ToolActivityState} from a single engine event. Non-tool
|
|
73
|
+
* events are ignored. This lets the stall assessor operate on any
|
|
74
|
+
* CodingAgentEngine's event stream without knowing the engine's internals.
|
|
75
|
+
*/
|
|
76
|
+
export function applyEngineEventToActivity(state: ToolActivityState, event: EngineEvent): void {
|
|
77
|
+
if (event.kind === 'tool.start') {
|
|
78
|
+
state.pendingToolIds.add(event.toolCallId);
|
|
79
|
+
state.pendingToolNames.add(event.toolName);
|
|
80
|
+
state.pendingToolNameById.set(event.toolCallId, event.toolName);
|
|
81
|
+
state.lastToolName = event.toolName;
|
|
82
|
+
state.lastToolInputSummary = summarizeToolInput(event.input);
|
|
83
|
+
state.totalToolCalls++;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (event.kind === 'tool.end') {
|
|
87
|
+
state.pendingToolIds.delete(event.toolCallId);
|
|
88
|
+
state.pendingToolNameById.delete(event.toolCallId);
|
|
89
|
+
// Only drop the name from pendingToolNames if no other pending call uses it.
|
|
90
|
+
const stillPending = Array.from(state.pendingToolNameById.values()).includes(event.toolName);
|
|
91
|
+
if (!stillPending) state.pendingToolNames.delete(event.toolName);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a {@link StallContext} from an engine-agnostic activity state plus
|
|
97
|
+
* the caller-owned timing fields. The stall heuristics and Haiku assessment
|
|
98
|
+
* in this module already operate on {@link StallContext}, so they are now
|
|
99
|
+
* fully drivable by any CodingAgentEngine's event stream.
|
|
100
|
+
*/
|
|
101
|
+
export function buildStallContext(
|
|
102
|
+
activity: ToolActivityState,
|
|
103
|
+
timing: {
|
|
104
|
+
originalPrompt: string;
|
|
105
|
+
silenceMs: number;
|
|
106
|
+
elapsedTotalMs: number;
|
|
107
|
+
tokenSilenceMs?: number;
|
|
108
|
+
},
|
|
109
|
+
): StallContext {
|
|
110
|
+
return {
|
|
111
|
+
originalPrompt: timing.originalPrompt,
|
|
112
|
+
silenceMs: timing.silenceMs,
|
|
113
|
+
elapsedTotalMs: timing.elapsedTotalMs,
|
|
114
|
+
tokenSilenceMs: timing.tokenSilenceMs,
|
|
115
|
+
lastToolName: activity.lastToolName,
|
|
116
|
+
lastToolInputSummary: activity.lastToolInputSummary,
|
|
117
|
+
pendingToolCount: activity.pendingToolIds.size,
|
|
118
|
+
pendingToolNames: new Set(activity.pendingToolNames),
|
|
119
|
+
totalToolCalls: activity.totalToolCalls,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function summarizeToolInput(input: Record<string, unknown>): string | undefined {
|
|
124
|
+
if (input.url) return `URL: ${String(input.url).slice(0, 200)}`;
|
|
125
|
+
if (input.query) return `Query: ${String(input.query).slice(0, 200)}`;
|
|
126
|
+
if (input.command) return `Command: ${String(input.command).slice(0, 200)}`;
|
|
127
|
+
if (input.prompt) return `Prompt: ${String(input.prompt).slice(0, 200)}`;
|
|
128
|
+
const serialized = JSON.stringify(input);
|
|
129
|
+
return serialized ? serialized.slice(0, 200) : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
39
132
|
// ========== Fast Heuristic ==========
|
|
40
133
|
|
|
41
134
|
function hasSubagentPending(pendingNames: Set<string>, lastToolName: string | undefined, hasPendingTools: boolean): boolean {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* 3. Haiku tiebreaker: optional AI assessment before killing ambiguous cases
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import type { EngineEvent } from '../../engines/EngineEvent.js';
|
|
16
17
|
import { hlog } from './headless-logger.js';
|
|
17
18
|
import type {
|
|
18
19
|
ExecutionCheckpoint,
|
|
@@ -349,6 +350,26 @@ export class ToolWatchdog {
|
|
|
349
350
|
}, extensionMs);
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Drive the watchdog from an engine-agnostic `EngineEvent` stream.
|
|
355
|
+
* Routes `tool.start` to `startWatch`, and `tool.end` to `clearWatch` +
|
|
356
|
+
* `recordCompletion` — so any CodingAgentEngine (Claude Code, OpenCode)
|
|
357
|
+
* can feed this watchdog without leaking engine-specific shapes.
|
|
358
|
+
* Non-tool events are ignored.
|
|
359
|
+
*/
|
|
360
|
+
onEngineEvent(event: EngineEvent, onTimeout: (toolId: string) => void): void {
|
|
361
|
+
if (event.kind === 'tool.start') {
|
|
362
|
+
this.startWatch(event.toolCallId, event.toolName, event.input, () => onTimeout(event.toolCallId));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (event.kind === 'tool.end') {
|
|
366
|
+
this.clearWatch(event.toolCallId);
|
|
367
|
+
if (typeof event.durationMs === 'number' && event.durationMs >= 0) {
|
|
368
|
+
this.recordCompletion(event.toolName, event.durationMs);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
352
373
|
/** Stop watching a tool (it completed normally) */
|
|
353
374
|
clearWatch(toolId: string): void {
|
|
354
375
|
const watch = this.activeWatches.get(toolId);
|
|
@@ -129,6 +129,18 @@ export interface HeadlessConfig {
|
|
|
129
129
|
disallowedTools?: string[];
|
|
130
130
|
/** Enable deploy-mode patterns in the bouncer (stricter rules for end-user-driven sessions) */
|
|
131
131
|
deployMode?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Tab id used to route AskUserQuestion calls back to the right web client.
|
|
134
|
+
* When set together with `mstroPort` and `bouncerSecret`, the MCP bouncer
|
|
135
|
+
* pauses Claude on AskUserQuestion and waits for the user to answer in the
|
|
136
|
+
* web UI before resuming. When unset (e.g. CLI ad-hoc runs), the bouncer
|
|
137
|
+
* falls back to legacy behavior (allow with no answers).
|
|
138
|
+
*/
|
|
139
|
+
tabId?: string;
|
|
140
|
+
/** CLI server port for the AskUserQuestion bridge. Pairs with `tabId`. */
|
|
141
|
+
mstroPort?: number;
|
|
142
|
+
/** Per-process bouncer secret for the AskUserQuestion bridge. Pairs with `tabId`. */
|
|
143
|
+
bouncerSecret?: string;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
export interface SessionState {
|
|
@@ -215,7 +227,7 @@ export interface ExecutionResult {
|
|
|
215
227
|
}
|
|
216
228
|
|
|
217
229
|
/** Resolved config with all defaults applied */
|
|
218
|
-
export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'tokenUsageCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'effortLevel' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'extraEnv' | 'disallowedTools' | 'deployMode'> & {
|
|
230
|
+
export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'tokenUsageCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'effortLevel' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'extraEnv' | 'disallowedTools' | 'deployMode' | 'tabId' | 'mstroPort' | 'bouncerSecret'> & {
|
|
219
231
|
outputCallback?: (text: string) => void;
|
|
220
232
|
thinkingCallback?: (text: string) => void;
|
|
221
233
|
toolUseCallback?: (event: ToolUseEvent) => void;
|
|
@@ -230,6 +242,9 @@ export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallb
|
|
|
230
242
|
extraEnv?: Record<string, string>;
|
|
231
243
|
disallowedTools?: string[];
|
|
232
244
|
deployMode?: boolean;
|
|
245
|
+
tabId?: string;
|
|
246
|
+
mstroPort?: number;
|
|
247
|
+
bouncerSecret?: string;
|
|
233
248
|
};
|
|
234
249
|
|
|
235
250
|
|
|
@@ -39,7 +39,9 @@ export function loadHistory(historyPath: string, sessionId: string): SessionHist
|
|
|
39
39
|
if (existsSync(historyPath)) {
|
|
40
40
|
try {
|
|
41
41
|
const data = readFileSync(historyPath, 'utf-8');
|
|
42
|
-
|
|
42
|
+
const parsed = JSON.parse(data) as SessionHistory;
|
|
43
|
+
if (!parsed.engine) parsed.engine = 'claude-code';
|
|
44
|
+
return parsed;
|
|
43
45
|
} catch (error) {
|
|
44
46
|
herror('Failed to load history:', error);
|
|
45
47
|
}
|
|
@@ -51,6 +53,7 @@ export function loadHistory(historyPath: string, sessionId: string): SessionHist
|
|
|
51
53
|
lastActivityAt: now,
|
|
52
54
|
totalTokens: 0,
|
|
53
55
|
movements: [],
|
|
56
|
+
engine: 'claude-code',
|
|
54
57
|
};
|
|
55
58
|
}
|
|
56
59
|
|
|
@@ -4,12 +4,29 @@
|
|
|
4
4
|
* Small FIFO output buffer with a fixed-interval flush timer, used by the
|
|
5
5
|
* improvisation session manager to coalesce rapid stdout writes into
|
|
6
6
|
* steady `onOutput` emissions.
|
|
7
|
+
*
|
|
8
|
+
* ## Why coalesce inside `flush()`
|
|
9
|
+
*
|
|
10
|
+
* Claude's stdout arrives as many small chunks during streaming. Each chunk
|
|
11
|
+
* lands here via `queue_`. When `flush()` ran one `onEmit` per queued chunk,
|
|
12
|
+
* a streaming-heavy run produced thousands of `onOutput` events per minute,
|
|
13
|
+
* each becoming a tab-scoped broadcast that consumes a slot in the per-tab
|
|
14
|
+
* replay buffer (`tab-event-buffer.ts`). For 14-min runs with ~120 tool
|
|
15
|
+
* calls, that easily exceeded the buffer's 1000-event cap and triggered
|
|
16
|
+
* silent replay gaps on web reconnect.
|
|
17
|
+
*
|
|
18
|
+
* The flush window (50ms) is below the human-perceptible paint threshold and
|
|
19
|
+
* below WebSocket roundtrip latency, so concatenating all queued text into a
|
|
20
|
+
* single `onEmit` per tick is invisible to the user but cuts buffer pressure
|
|
21
|
+
* by 3-10× during streaming. No call site downstream depends on chunk
|
|
22
|
+
* boundaries — `onOutput` consumers (terminal renderer, history persistence)
|
|
23
|
+
* already treat the text as an opaque append.
|
|
7
24
|
*/
|
|
8
25
|
|
|
9
26
|
const FLUSH_INTERVAL_MS = 50;
|
|
10
27
|
|
|
11
28
|
export class OutputQueue {
|
|
12
|
-
private queue:
|
|
29
|
+
private queue: string[] = [];
|
|
13
30
|
private timer: NodeJS.Timeout | null = null;
|
|
14
31
|
|
|
15
32
|
constructor(private readonly onEmit: (text: string) => void) {}
|
|
@@ -20,15 +37,20 @@ export class OutputQueue {
|
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
queue_(text: string): void {
|
|
23
|
-
|
|
40
|
+
if (text.length === 0) return;
|
|
41
|
+
this.queue.push(text);
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Drain all buffered entries, emitting them as a single concatenated
|
|
46
|
+
* string via `onEmit`. Order is preserved (FIFO). No-op when the queue is
|
|
47
|
+
* empty so the periodic timer doesn't fire spurious empty-string emits.
|
|
48
|
+
*/
|
|
27
49
|
flush(): void {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
if (this.queue.length === 0) return;
|
|
51
|
+
const merged = this.queue.join('');
|
|
52
|
+
this.queue.length = 0;
|
|
53
|
+
this.onEmit(merged);
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
/** Stop the flush timer. Does NOT drain; call `flush()` first if needed. */
|