headlesscode 1.0.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/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost/token dashboard — plain `node:http` server (workstream 3) + live
|
|
3
|
+
* per-session events + pause/resume control (live worker monitoring) +
|
|
4
|
+
* per-mode model settings (mode-model-assignment) + browser control-plane
|
|
5
|
+
* (session launch + answer) + checkpoint list/diff/restore.
|
|
6
|
+
*
|
|
7
|
+
* ZERO new UI/framework dependencies, matching this project's established
|
|
8
|
+
* "plain fs + JSON, no schema library" ethos (see `src/orchestrator/state.ts`'s
|
|
9
|
+
* own file-header comment). Bound to `127.0.0.1` only, no auth — explicitly a
|
|
10
|
+
* local-only tool per the plan doc.
|
|
11
|
+
*
|
|
12
|
+
* GET / the static HTML page (src/dashboard/page.ts)
|
|
13
|
+
* GET /api/summary JSON from src/dashboard/aggregate.ts's buildSummary()
|
|
14
|
+
* GET /api/session/:id/events JSON events feed (incremental via ?since=<offset>)
|
|
15
|
+
* POST /api/session/:id/pause write `.harness.pause-requested` (worker pauses between iterations)
|
|
16
|
+
* POST /api/session/:id/resume remove `.harness.pause-requested` (worker resumes)
|
|
17
|
+
* GET /api/settings/mode-models?repo=<path> current mode-models.json content ({} if absent)
|
|
18
|
+
* POST /api/settings/mode-models?repo=<path> validate + write mode-models.json (creates .headlesscode/)
|
|
19
|
+
* POST /api/session/start launch a detached top-level session (see src/dashboard/session-launch.ts)
|
|
20
|
+
* POST /api/tool/execute run ONE executor tool synchronously against a workspace
|
|
21
|
+
* (see src/dashboard/tool-exec.ts; used by UwUChat's code-mode
|
|
22
|
+
* agent tools — inherits all executor guardrails)
|
|
23
|
+
* POST /api/session/:id/answer write `.harness.decision-answer` to unblock an escalated question
|
|
24
|
+
* POST /api/session/:id/message write `.harness.inject-message` ({ text, injectedAt }) — a new
|
|
25
|
+
* user message the RUNNING session picks up before its next LLM call
|
|
26
|
+
* (mid-session message injection; see src/engine/loop.ts)
|
|
27
|
+
* GET /api/modes?repo=<path> merged available modes (.roomodes + global) for the mode selector
|
|
28
|
+
* GET /api/checkpoints?repo=<path>&session=<taskId>
|
|
29
|
+
* list checkpoints for a session (CheckpointService.list())
|
|
30
|
+
* GET /api/checkpoints/diff?repo=<path>&session=<taskId>&from=<hash>&to=<hash?>
|
|
31
|
+
* diff between two checkpoints, or a checkpoint vs. the
|
|
32
|
+
* current working tree when `to` is omitted
|
|
33
|
+
* POST /api/checkpoints/restore?repo=<path>&session=<taskId> body {hash} — restores
|
|
34
|
+
* a checkpoint onto the REAL workspace (destructive;
|
|
35
|
+
* gated by the same optional bearer token as
|
|
36
|
+
* /api/session/* POSTs — see below)
|
|
37
|
+
* GET /api/files?repo=<path>&dir=<rel> list a workspace directory (file browser)
|
|
38
|
+
* GET /api/files/content?repo=<path>&file=<rel> single file's text content (capped/binary-safe)
|
|
39
|
+
* GET /api/settings/permissions?repo=<path> current permissions.json + resolved defaults
|
|
40
|
+
* POST /api/settings/permissions?repo=<path> validate + write permissions.json (creates .headlesscode/)
|
|
41
|
+
* GET /api/codemap?repo=<path> the project's codemap.json (deterministic
|
|
42
|
+
* module/import map; see src/codemap/) — from the
|
|
43
|
+
* central per-project store; 404 until
|
|
44
|
+
* `headlesscode codemap --workspace <path>` has run
|
|
45
|
+
* GET /api/codemap/html?repo=<path> the self-contained interactive visualizer
|
|
46
|
+
* (same store; opens standalone in a browser)
|
|
47
|
+
* GET /api/cost-history?repo=<path>[&since=<iso>][&limit=N]
|
|
48
|
+
* recorded per-group + per-session cost/token/
|
|
49
|
+
* duration history from the central store's
|
|
50
|
+
* cost-history.jsonl / session-cost-history.jsonl
|
|
51
|
+
* (see src/orchestrator/cost-history.ts) as JSON,
|
|
52
|
+
* optionally windowed by recordedAt (since) or
|
|
53
|
+
* most-recent-N (limit)
|
|
54
|
+
* GET /api/projects[?all=1] enumerate the central per-project store
|
|
55
|
+
* (see src/project-store.ts's listProjectEntries):
|
|
56
|
+
* every registered or still-existing project by
|
|
57
|
+
* default; ?all=1 also shows pure stale-unregistered
|
|
58
|
+
* litter (the pre-Part-A orphaned dirs)
|
|
59
|
+
*
|
|
60
|
+
* NOTE (control plane): the POST /api/session/start route can launch real,
|
|
61
|
+
* billed work and run arbitrary shell via `gh`/`orchestrate` from a form
|
|
62
|
+
* POST — a bigger step across the read-only-to-control line than the
|
|
63
|
+
* pause/resume or settings POSTs were individually. POST
|
|
64
|
+
* /api/checkpoints/restore is MORE dangerous still: it reverts REAL
|
|
65
|
+
* workspace files (the shadow repo's core.worktree). Both are local-only /
|
|
66
|
+
* no-auth by default (binds 127.0.0.1), but if these endpoints are ever
|
|
67
|
+
* exposed beyond localhost, that assumption needs revisiting — the restore
|
|
68
|
+
* endpoint in particular MUST stay behind the optional bearer-token gate
|
|
69
|
+
* when one is configured.
|
|
70
|
+
* pause/resume or settings POSTs were individually. Still local-only /
|
|
71
|
+
* no-auth (binds 127.0.0.1), but if this endpoint is ever exposed beyond
|
|
72
|
+
* localhost, that assumption needs revisiting — no auth was added here.
|
|
73
|
+
*
|
|
74
|
+
* NOTE (permissions settings): POST /api/settings/permissions is covered by
|
|
75
|
+
* the same optional bearer-token gate as /api/session/* when a token IS
|
|
76
|
+
* configured. When it isn't, this route is (deliberately) no-auth like
|
|
77
|
+
* everything else — the whole dashboard binds 127.0.0.1 only and is
|
|
78
|
+
* documented as a local-only tool. A separate default-on auth for just this
|
|
79
|
+
* route was considered and rejected: it would be the ONLY endpoint with a
|
|
80
|
+
* different default, surprising anyone running the established local
|
|
81
|
+
* workflow, and the binding/localhost contract is the actual security
|
|
82
|
+
* boundary (a real auth system is a separate, deliberate project-owner
|
|
83
|
+
* decision — see DashboardServerOptions.token). The one thing that is NOT
|
|
84
|
+
* negotiable is that when the gate IS configured, this route is inside it —
|
|
85
|
+
* a weakened permissions file is more consequential than starting a session,
|
|
86
|
+
* so it must never be gated *less* strictly than the session routes.
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
import * as fsp from "node:fs/promises"
|
|
90
|
+
import * as http from "node:http"
|
|
91
|
+
import * as path from "node:path"
|
|
92
|
+
|
|
93
|
+
import { buildSummary, findSessionEventsFile, readSessionEvents } from "./aggregate.js"
|
|
94
|
+
import { computeSelfImprovementMetrics, type SelfImprovementMetrics } from "./self-improvement-metrics.js"
|
|
95
|
+
import { diffCheckpoints, listCheckpoints, renderUnifiedDiff, restoreCheckpoint } from "./checkpoints.js"
|
|
96
|
+
import { codemapMissingError, readCodemapHtml, readCodemapJson } from "./codemap.js"
|
|
97
|
+
import { renderPage } from "./page.js"
|
|
98
|
+
import { answerSessionDecision, ensureWorktree, injectSessionMessage, launchSession, validateSessionStartBody } from "./session-launch.js"
|
|
99
|
+
import { loadCustomModes } from "../engine/prompt.js"
|
|
100
|
+
import {
|
|
101
|
+
loadModeModelsFile,
|
|
102
|
+
modeModelsFilePath,
|
|
103
|
+
stringifyModeModelsFile,
|
|
104
|
+
validateModeModelsBody,
|
|
105
|
+
type ModeModelsFile,
|
|
106
|
+
} from "../config/mode-models.js"
|
|
107
|
+
import { readCostHistory, readSessionCostHistory } from "../orchestrator/cost-history.js"
|
|
108
|
+
import { listProjectEntries } from "../project-store.js"
|
|
109
|
+
import { listWorkspaceDir, readWorkspaceFile } from "./files.js"
|
|
110
|
+
import { executeTool, type ToolExecuteRequest } from "./tool-exec.js"
|
|
111
|
+
import {
|
|
112
|
+
loadPermissionsFile,
|
|
113
|
+
parsePermissionsFileBody,
|
|
114
|
+
permissionsFilePath,
|
|
115
|
+
resolvePermissions,
|
|
116
|
+
stringifyPermissionsFile,
|
|
117
|
+
type PermissionsFile,
|
|
118
|
+
} from "../permissions/config.js"
|
|
119
|
+
import { PathTraversalError } from "../tools/executor.js"
|
|
120
|
+
|
|
121
|
+
/** Default dashboard port (overridable via --port or $HEADLESSCODE_DASHBOARD_PORT). */
|
|
122
|
+
export const DEFAULT_DASHBOARD_PORT = 4390
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Self-improvement metrics (issue #145) cache: computeSelfImprovementMetrics
|
|
126
|
+
* shells out to `gh issue list` (real network + GitHub API), which the
|
|
127
|
+
* dashboard's poll loop hitting this endpoint every few seconds would
|
|
128
|
+
* otherwise fire on every poll — a short in-memory TTL keeps the page
|
|
129
|
+
* responsive and avoids hammering the GitHub API for a value that only
|
|
130
|
+
* meaningfully changes on the order of minutes, not seconds. Deliberately
|
|
131
|
+
* a server/HTTP-layer concern, not baked into the (pure, unit-tested)
|
|
132
|
+
* computation module itself.
|
|
133
|
+
*/
|
|
134
|
+
const SELF_IMPROVEMENT_CACHE_TTL_MS = 30_000
|
|
135
|
+
let selfImprovementCache: { repo: string; computedAt: number; data: SelfImprovementMetrics } | undefined
|
|
136
|
+
|
|
137
|
+
export interface DashboardServerOptions {
|
|
138
|
+
port: number
|
|
139
|
+
/** Repo root to scan for usage files + .orchestrator-state.json (optional). */
|
|
140
|
+
repo?: string
|
|
141
|
+
/**
|
|
142
|
+
* Optional control-plane bearer token. When set, POST routes under
|
|
143
|
+
* /api/session/* require `Authorization: Bearer <token>`. Unset (default)
|
|
144
|
+
* = today's behavior: no auth at all. Deliberately optional — a real auth
|
|
145
|
+
* system is a separate, deliberate project-owner decision (see the header
|
|
146
|
+
* note); this is a cheap hook, not a substitute.
|
|
147
|
+
*/
|
|
148
|
+
token?: string
|
|
149
|
+
/**
|
|
150
|
+
* CLI invocation for POST /api/session/start (default "npx tsx src/cli.ts").
|
|
151
|
+
* Test-only injection point so the HTTP layer can be exercised against a
|
|
152
|
+
* tiny fake script instead of the real CLI; not a public config surface.
|
|
153
|
+
*/
|
|
154
|
+
cli?: string
|
|
155
|
+
/** Directory the launched CLI runs in (default: process.cwd()). Test-only. */
|
|
156
|
+
repoRoot?: string
|
|
157
|
+
/**
|
|
158
|
+
* Root directory for POST /api/projects/ensure-worktree's disposable
|
|
159
|
+
* per-project worktrees (see session-launch.ts's ensureWorktree).
|
|
160
|
+
* Unset (default) = that endpoint responds 501; registration is
|
|
161
|
+
* expected to fall back to the raw repo path in that case.
|
|
162
|
+
*/
|
|
163
|
+
worktreeRoot?: string
|
|
164
|
+
/**
|
|
165
|
+
* Shadow-git storage root for the /api/checkpoints/* routes (default:
|
|
166
|
+
* `~/.headlesscode/checkpoints` — the same default the CLI/engine use).
|
|
167
|
+
* Test-only injection so the routes can be exercised against a temp dir
|
|
168
|
+
* instead of the developer's home directory; not a public config surface.
|
|
169
|
+
*/
|
|
170
|
+
checkpointDir?: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Start the dashboard HTTP server. Resolves once it's listening. */
|
|
174
|
+
export function startDashboardServer(options: DashboardServerOptions): Promise<http.Server> {
|
|
175
|
+
const server = http.createServer((req, res) => {
|
|
176
|
+
// .catch (issue #82): handleRequest is fire-and-forget from this
|
|
177
|
+
// callback's perspective (http.createServer's handler isn't awaited),
|
|
178
|
+
// so an unhandled rejection here would crash the whole process on
|
|
179
|
+
// Node 15+ if any route ever threw instead of writing a response.
|
|
180
|
+
// Fall back to a bare 500 — best-effort, since the response may
|
|
181
|
+
// already be (partially) written by the time a route fails.
|
|
182
|
+
void handleRequest(req, res, options).catch((error) => {
|
|
183
|
+
if (!res.headersSent) {
|
|
184
|
+
res.writeHead(500, { "content-type": "application/json; charset=utf-8" }).end(
|
|
185
|
+
JSON.stringify({ error: `internal error: ${error instanceof Error ? error.message : String(error)}` }),
|
|
186
|
+
)
|
|
187
|
+
} else {
|
|
188
|
+
res.end()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
server.once("error", reject)
|
|
195
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
196
|
+
server.removeListener("error", reject)
|
|
197
|
+
resolve(server)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function handleRequest(
|
|
203
|
+
req: http.IncomingMessage,
|
|
204
|
+
res: http.ServerResponse,
|
|
205
|
+
options: DashboardServerOptions,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
208
|
+
|
|
209
|
+
// Optional control-plane token (HEADLESSCODE_DASHBOARD_TOKEN — see
|
|
210
|
+
// DashboardServerOptions.token). When configured, every state-changing
|
|
211
|
+
// POST requires the bearer header: /api/session/* (start/pause/resume/
|
|
212
|
+
// answer), the destructive POST /api/checkpoints/restore (reverts real
|
|
213
|
+
// workspace files — never gated looser than the session routes), and
|
|
214
|
+
// POST /api/settings/permissions (a weakened permissions file is more
|
|
215
|
+
// consequential than starting a session — same gate).
|
|
216
|
+
//
|
|
217
|
+
// SEC-7: the same token ALSO gates the GET routes that read workspace
|
|
218
|
+
// files or project data (file browser, checkpoints, codemap, cost
|
|
219
|
+
// history, project listing). Without this, any page open in the
|
|
220
|
+
// operator's browser — including a malicious one, via DNS-rebinding or
|
|
221
|
+
// simple localhost-CSRF (`fetch("http://127.0.0.1:4390/api/files/content?...")`
|
|
222
|
+
// from an unrelated origin) — could read `.env` and other workspace
|
|
223
|
+
// files with zero auth, since GETs carry no CSRF protection by default
|
|
224
|
+
// and the dashboard binds 127.0.0.1 only (reachable by anything running
|
|
225
|
+
// on the same machine, including browser tabs). Unset token = no auth,
|
|
226
|
+
// exactly today's behavior — this is opt-in defense in depth, not a
|
|
227
|
+
// guarantee (see SECURITY.md's "Known limitations").
|
|
228
|
+
const tokenGatedGetPrefixes = [
|
|
229
|
+
"/api/files",
|
|
230
|
+
"/api/checkpoints",
|
|
231
|
+
"/api/codemap",
|
|
232
|
+
"/api/cost-history",
|
|
233
|
+
"/api/projects",
|
|
234
|
+
]
|
|
235
|
+
const isTokenGatedGet =
|
|
236
|
+
req.method === "GET" && tokenGatedGetPrefixes.some((p) => url.pathname === p || url.pathname.startsWith(`${p}/`))
|
|
237
|
+
if (
|
|
238
|
+
options.token &&
|
|
239
|
+
((req.method === "POST" &&
|
|
240
|
+
(url.pathname.startsWith("/api/session/") ||
|
|
241
|
+
url.pathname === "/api/checkpoints/restore" ||
|
|
242
|
+
url.pathname === "/api/settings/permissions" ||
|
|
243
|
+
url.pathname === "/api/tool/execute")) ||
|
|
244
|
+
isTokenGatedGet)
|
|
245
|
+
) {
|
|
246
|
+
const auth = req.headers.authorization ?? ""
|
|
247
|
+
const expected = `Bearer ${options.token}`
|
|
248
|
+
if (auth !== expected) {
|
|
249
|
+
res.writeHead(401, { "content-type": "application/json; charset=utf-8" }).end(
|
|
250
|
+
JSON.stringify({ error: "unauthorized — set Authorization: Bearer <token>" }),
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Live session detail: GET /api/session/:id/events?since=<offset>&repo=<path>
|
|
257
|
+
const eventsMatch = /^\/api\/session\/([^/]+)\/events$/.exec(url.pathname)
|
|
258
|
+
if (eventsMatch && req.method === "GET") {
|
|
259
|
+
const sessionId = decodeURIComponent(eventsMatch[1])
|
|
260
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
261
|
+
if (!repo) {
|
|
262
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
263
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
const sinceRaw = url.searchParams.get("since")
|
|
268
|
+
const since = sinceRaw === null || sinceRaw === "" ? 0 : Number(sinceRaw)
|
|
269
|
+
if (!Number.isFinite(since) || since < 0) {
|
|
270
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
271
|
+
JSON.stringify({ error: "invalid since offset" }),
|
|
272
|
+
)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const { events, nextOffset } = await readSessionEvents(repo, sessionId, since)
|
|
277
|
+
res
|
|
278
|
+
.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
279
|
+
.end(JSON.stringify({ sessionId, events, nextOffset }))
|
|
280
|
+
} catch (error) {
|
|
281
|
+
res
|
|
282
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
283
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
284
|
+
}
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Pause/resume control (Phase 3): POST /api/session/:id/pause|resume —
|
|
289
|
+
// deliberately POST-only (a state-changing action must not be triggerable
|
|
290
|
+
// by a GET/prefetch). Pause writes `.harness.pause-requested`; resume
|
|
291
|
+
// removes it (presence/absence IS the signal — see src/engine/loop.ts).
|
|
292
|
+
const pauseMatch = /^\/api\/session\/([^/]+)\/(pause|resume)$/.exec(url.pathname)
|
|
293
|
+
if (pauseMatch && req.method !== "POST") {
|
|
294
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (pauseMatch && req.method === "POST") {
|
|
298
|
+
const sessionId = decodeURIComponent(pauseMatch[1])
|
|
299
|
+
const action = pauseMatch[2]
|
|
300
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
301
|
+
if (!repo) {
|
|
302
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
303
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
304
|
+
)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const found = await findSessionEventsFile(repo, sessionId)
|
|
309
|
+
if (!found) {
|
|
310
|
+
res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
|
|
311
|
+
JSON.stringify({ error: `no session ${sessionId} found under ${repo}` }),
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
// The marker lives in the worktree that owns the session's events feed.
|
|
316
|
+
const worktreeRoot = found.source === "." ? path.resolve(repo) : path.resolve(repo, found.source)
|
|
317
|
+
const markerPath = path.join(worktreeRoot, ".harness.pause-requested")
|
|
318
|
+
if (action === "pause") {
|
|
319
|
+
await fsp.writeFile(markerPath, new Date().toISOString() + "\n", "utf-8")
|
|
320
|
+
} else {
|
|
321
|
+
await fsp.rm(markerPath, { force: true })
|
|
322
|
+
}
|
|
323
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
324
|
+
JSON.stringify({ sessionId, action, ok: true, worktree: found.source }),
|
|
325
|
+
)
|
|
326
|
+
} catch (error) {
|
|
327
|
+
res
|
|
328
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
329
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Resolve (creating if needed) the disposable worktree a registered
|
|
335
|
+
// project's sessions should run against: POST /api/projects/ensure-worktree
|
|
336
|
+
// {repo: <source repo path>} -> {workspace: <worktree path>}. Called once
|
|
337
|
+
// at project-registration time (see the host project's headlesscode service),
|
|
338
|
+
// not per-launch — the caller stores the returned path and reuses it for
|
|
339
|
+
// every session/poll/pause/resume/message operation on that project.
|
|
340
|
+
if (url.pathname === "/api/projects/ensure-worktree" && req.method !== "POST") {
|
|
341
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
if (url.pathname === "/api/projects/ensure-worktree" && req.method === "POST") {
|
|
345
|
+
if (!options.worktreeRoot) {
|
|
346
|
+
res
|
|
347
|
+
.writeHead(501, { "content-type": "application/json; charset=utf-8" })
|
|
348
|
+
.end(JSON.stringify({ error: "worktreeRoot not configured (HEADLESSCODE_DASHBOARD_WORKTREE_ROOT)" }))
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const raw = await readJsonBody(req)
|
|
353
|
+
let body: unknown
|
|
354
|
+
try {
|
|
355
|
+
body = JSON.parse(raw || "{}")
|
|
356
|
+
} catch (err) {
|
|
357
|
+
res
|
|
358
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
359
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
const repo = (body as { repo?: string }).repo?.trim()
|
|
363
|
+
if (!repo) {
|
|
364
|
+
res
|
|
365
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
366
|
+
.end(JSON.stringify({ error: "missing 'repo'" }))
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
const workspace = ensureWorktree(options.worktreeRoot, repo)
|
|
370
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ workspace }))
|
|
371
|
+
} catch (error) {
|
|
372
|
+
res
|
|
373
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
374
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
375
|
+
}
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Launch a top-level session (browser control plane): POST /api/session/start
|
|
380
|
+
// spawns a detached `npx tsx src/cli.ts --task ... --mode ... --workspace ...`
|
|
381
|
+
// background process (see src/dashboard/session-launch.ts) and returns the
|
|
382
|
+
// new session's id immediately, so the browser can open its live event view
|
|
383
|
+
// before the first event exists. repo defaults to the dashboard's --repo;
|
|
384
|
+
// mode defaults to multi-agent-orchestrator-headless.
|
|
385
|
+
if (url.pathname === "/api/session/start" && req.method !== "POST") {
|
|
386
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (url.pathname === "/api/session/start" && req.method === "POST") {
|
|
390
|
+
try {
|
|
391
|
+
const raw = await readJsonBody(req)
|
|
392
|
+
let body: unknown
|
|
393
|
+
try {
|
|
394
|
+
body = JSON.parse(raw || "{}")
|
|
395
|
+
} catch (err) {
|
|
396
|
+
res
|
|
397
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
398
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
const validationError = validateSessionStartBody(body)
|
|
402
|
+
if (validationError) {
|
|
403
|
+
res
|
|
404
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
405
|
+
.end(JSON.stringify({ error: validationError }))
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
const start = body as { task: string; repo?: string; mode?: string }
|
|
409
|
+
const repo = start.repo?.trim() || options.repo
|
|
410
|
+
if (!repo) {
|
|
411
|
+
res
|
|
412
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
413
|
+
.end(
|
|
414
|
+
JSON.stringify({
|
|
415
|
+
error: "missing repo — pass 'repo' in the body or start the dashboard with --repo",
|
|
416
|
+
}),
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const launched = await launchSession({
|
|
421
|
+
workspace: repo,
|
|
422
|
+
task: start.task,
|
|
423
|
+
mode: start.mode,
|
|
424
|
+
cli: options.cli,
|
|
425
|
+
repoRoot: options.repoRoot,
|
|
426
|
+
})
|
|
427
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(launched))
|
|
428
|
+
} catch (error) {
|
|
429
|
+
res
|
|
430
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
431
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
432
|
+
}
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Run ONE executor tool synchronously against a workspace (UwUChat's
|
|
437
|
+
// code-mode agent tools): POST /api/tool/execute { workspace, name, args }.
|
|
438
|
+
// Builds a fresh ToolExecutor per call (stateless), runs the named tool,
|
|
439
|
+
// returns { ok, isError, content }. Every guardrail is inherited from the
|
|
440
|
+
// executor — workspace path safety, command allow/deny, protected-file
|
|
441
|
+
// permissions, output truncation. Backgrounded execute_command children are
|
|
442
|
+
// hard-killed after the call (executeTool's dispose() — the proxy does not
|
|
443
|
+
// support long-running background commands). Gated by the same optional
|
|
444
|
+
// bearer token as /api/session/* (see the token check above).
|
|
445
|
+
if (url.pathname === "/api/tool/execute" && req.method !== "POST") {
|
|
446
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
if (url.pathname === "/api/tool/execute" && req.method === "POST") {
|
|
450
|
+
try {
|
|
451
|
+
const raw = await readJsonBody(req)
|
|
452
|
+
let body: unknown
|
|
453
|
+
try {
|
|
454
|
+
body = JSON.parse(raw || "{}")
|
|
455
|
+
} catch (err) {
|
|
456
|
+
res
|
|
457
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
458
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
const request = body as ToolExecuteRequest
|
|
462
|
+
const result = await executeTool(request)
|
|
463
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result))
|
|
464
|
+
} catch (error) {
|
|
465
|
+
res
|
|
466
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
467
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
468
|
+
}
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Answer a blocked session's escalated question (browser control plane):
|
|
473
|
+
// POST /api/session/:id/answer writes `.harness.decision-answer` in the
|
|
474
|
+
// session's workspace root, which ask_followup_question polls for (see
|
|
475
|
+
// src/tools/executor.ts). Works for ANY session — worker or top-level —
|
|
476
|
+
// because the marker mechanism doesn't know who answers it.
|
|
477
|
+
const answerMatch = /^\/api\/session\/([^/]+)\/answer$/.exec(url.pathname)
|
|
478
|
+
if (answerMatch && req.method !== "POST") {
|
|
479
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (answerMatch && req.method === "POST") {
|
|
483
|
+
const sessionId = decodeURIComponent(answerMatch[1])
|
|
484
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
485
|
+
if (!repo) {
|
|
486
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
487
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
488
|
+
)
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
const found = await findSessionEventsFile(repo, sessionId)
|
|
493
|
+
if (!found) {
|
|
494
|
+
res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
|
|
495
|
+
JSON.stringify({ error: `no session ${sessionId} found under ${repo}` }),
|
|
496
|
+
)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const raw = await readJsonBody(req)
|
|
500
|
+
let body: unknown
|
|
501
|
+
try {
|
|
502
|
+
body = JSON.parse(raw || "{}")
|
|
503
|
+
} catch (err) {
|
|
504
|
+
res
|
|
505
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
506
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
const answer = (body as { answer?: unknown })?.answer
|
|
510
|
+
if (typeof answer !== "string" || answer.trim() === "") {
|
|
511
|
+
res
|
|
512
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
513
|
+
.end(JSON.stringify({ error: "missing or empty 'answer' — provide the answer text to unblock the session" }))
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
// The marker lives in the worktree that owns the session's events
|
|
517
|
+
// feed (same resolution pause/resume uses above).
|
|
518
|
+
const worktreeRoot = found.source === "." ? path.resolve(repo) : path.resolve(repo, found.source)
|
|
519
|
+
await answerSessionDecision(worktreeRoot, answer.trim())
|
|
520
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
521
|
+
JSON.stringify({ sessionId, ok: true, worktree: found.source }),
|
|
522
|
+
)
|
|
523
|
+
} catch (error) {
|
|
524
|
+
res
|
|
525
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
526
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
527
|
+
}
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Inject a new user message into a RUNNING session (live chat-UI control):
|
|
532
|
+
// POST /api/session/:id/message writes `.harness.inject-message` (JSON:
|
|
533
|
+
// { text, injectedAt }) in the session's workspace root — the SAME marker
|
|
534
|
+
// protocol pause/resume/answer use. The loop's checkInjectedMessage (see
|
|
535
|
+
// src/engine/loop.ts) picks it up before its next LLM call, appends the
|
|
536
|
+
// text as a plain user-role message, and deletes the marker — the model
|
|
537
|
+
// sees it as if the user had just typed it, NOT as a tool result or an
|
|
538
|
+
// interruption. Deliberately distinct from /answer (answering a pending
|
|
539
|
+
// ask_followup_question vs. an unprompted new message). Policy: one
|
|
540
|
+
// pending message — a second POST before the first is picked up
|
|
541
|
+
// OVERWRITES it (overwrite-with-latest, no queue). Same auth gate as the
|
|
542
|
+
// other /api/session/* POSTs (the generic check at the top).
|
|
543
|
+
const messageMatch = /^\/api\/session\/([^/]+)\/message$/.exec(url.pathname)
|
|
544
|
+
if (messageMatch && req.method !== "POST") {
|
|
545
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (messageMatch && req.method === "POST") {
|
|
549
|
+
const sessionId = decodeURIComponent(messageMatch[1])
|
|
550
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
551
|
+
if (!repo) {
|
|
552
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
553
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
554
|
+
)
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const found = await findSessionEventsFile(repo, sessionId)
|
|
559
|
+
if (!found) {
|
|
560
|
+
res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
|
|
561
|
+
JSON.stringify({ error: `no session ${sessionId} found under ${repo}` }),
|
|
562
|
+
)
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
const raw = await readJsonBody(req)
|
|
566
|
+
let body: unknown
|
|
567
|
+
try {
|
|
568
|
+
body = JSON.parse(raw || "{}")
|
|
569
|
+
} catch (err) {
|
|
570
|
+
res
|
|
571
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
572
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
const text = (body as { text?: unknown })?.text
|
|
576
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
577
|
+
res
|
|
578
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
579
|
+
.end(JSON.stringify({ error: "missing or empty 'text' — provide the message text to inject into the running session" }))
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
// The marker lives in the worktree that owns the session's events
|
|
583
|
+
// feed (same resolution pause/resume/answer use above).
|
|
584
|
+
const worktreeRoot = found.source === "." ? path.resolve(repo) : path.resolve(repo, found.source)
|
|
585
|
+
await injectSessionMessage(worktreeRoot, text.trim())
|
|
586
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
587
|
+
JSON.stringify({ sessionId, ok: true, worktree: found.source }),
|
|
588
|
+
)
|
|
589
|
+
} catch (error) {
|
|
590
|
+
res
|
|
591
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
592
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
593
|
+
}
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Per-mode model settings (mode-model-assignment): GET returns the current
|
|
598
|
+
// `.headlesscode/mode-models.json` content ({} when absent); POST validates
|
|
599
|
+
// the JSON body with the same rules the config loader uses and writes it
|
|
600
|
+
// (creating `.headlesscode/` if needed). A malformed save is rejected with
|
|
601
|
+
// a clear error — never writes garbage that later throws on a worker read.
|
|
602
|
+
if (url.pathname === "/api/settings/mode-models") {
|
|
603
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
604
|
+
if (!repo) {
|
|
605
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
606
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
607
|
+
)
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
if (req.method === "GET") {
|
|
611
|
+
try {
|
|
612
|
+
// Reuse the strict loader: a malformed on-disk file surfaces as
|
|
613
|
+
// an error here instead of a silently-empty settings page.
|
|
614
|
+
const file = loadModeModelsFile(repo)
|
|
615
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
616
|
+
JSON.stringify(file ?? {}),
|
|
617
|
+
)
|
|
618
|
+
} catch (error) {
|
|
619
|
+
res
|
|
620
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
621
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
622
|
+
}
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
if (req.method === "POST") {
|
|
626
|
+
try {
|
|
627
|
+
const raw = await readJsonBody(req)
|
|
628
|
+
let body: unknown
|
|
629
|
+
try {
|
|
630
|
+
body = JSON.parse(raw)
|
|
631
|
+
} catch (err) {
|
|
632
|
+
res
|
|
633
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
634
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
// Same strict validation the config loader applies on read.
|
|
638
|
+
try {
|
|
639
|
+
validateModeModelsBody(body)
|
|
640
|
+
} catch (err) {
|
|
641
|
+
res
|
|
642
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
643
|
+
.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
const file = modeModelsFilePath(repo)
|
|
647
|
+
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
648
|
+
await fsp.writeFile(file, stringifyModeModelsFile(body as ModeModelsFile), "utf-8")
|
|
649
|
+
res
|
|
650
|
+
.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
651
|
+
.end(JSON.stringify({ ok: true, path: file }))
|
|
652
|
+
} catch (error) {
|
|
653
|
+
res
|
|
654
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
655
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
656
|
+
}
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Checkpoints — browser control plane. `repo` is the workspace whose
|
|
664
|
+
// files the shadow git repo tracks (the dashboard's --repo); `session` is
|
|
665
|
+
// the session/task id (HeadlessSession uses its sessionId as the
|
|
666
|
+
// checkpoint taskId — see src/engine/loop.ts). list/diff wrap the
|
|
667
|
+
// existing CheckpointService (src/checkpoints/service.ts) read-only;
|
|
668
|
+
// restore is the one DESTRUCTIVE action this dashboard can perform and is
|
|
669
|
+
// gated by the optional bearer token above (same gate as /api/session/*
|
|
670
|
+
// POSTs — never looser).
|
|
671
|
+
if (url.pathname === "/api/checkpoints" || url.pathname === "/api/checkpoints/diff") {
|
|
672
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
673
|
+
const session = url.searchParams.get("session")
|
|
674
|
+
if (!repo || !session) {
|
|
675
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
676
|
+
JSON.stringify({ error: "missing repo or session — pass ?repo=<path>&session=<taskId>" }),
|
|
677
|
+
)
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
const routeOptions = { workspaceRoot: repo, sessionId: session, checkpointDir: options.checkpointDir }
|
|
681
|
+
try {
|
|
682
|
+
if (url.pathname === "/api/checkpoints") {
|
|
683
|
+
const entries = await listCheckpoints(routeOptions)
|
|
684
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ entries }))
|
|
685
|
+
} else {
|
|
686
|
+
const from = url.searchParams.get("from")
|
|
687
|
+
if (!from) {
|
|
688
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
689
|
+
JSON.stringify({ error: "missing from — pass ?from=<hash> (optional &to=<hash>)" }),
|
|
690
|
+
)
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
const to = url.searchParams.get("to") ?? undefined
|
|
694
|
+
const changes = await diffCheckpoints(routeOptions, from, to)
|
|
695
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
696
|
+
JSON.stringify({ diff: renderUnifiedDiff(changes), changes }),
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
} catch (error) {
|
|
700
|
+
res
|
|
701
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
702
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
703
|
+
}
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// File browser (dashboard-file-browser-and-permissions-ui): GET
|
|
708
|
+
// /api/files?repo=<path>&dir=<rel> lists a workspace directory's entries.
|
|
709
|
+
// The path-safety guard (resolveWithinWorkspace + PathTraversalError) is
|
|
710
|
+
// applied inside src/dashboard/files.ts — a traversal attempt surfaces as
|
|
711
|
+
// a 400, never a file-system read outside the workspace.
|
|
712
|
+
if (url.pathname === "/api/files" && req.method === "GET") {
|
|
713
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
714
|
+
if (!repo) {
|
|
715
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
716
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
717
|
+
)
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
const dir = url.searchParams.get("dir") || "."
|
|
721
|
+
try {
|
|
722
|
+
const entries = await listWorkspaceDir(repo, dir)
|
|
723
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ dir, entries }))
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error instanceof PathTraversalError) {
|
|
726
|
+
res
|
|
727
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
728
|
+
.end(JSON.stringify({ error: error.message }))
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
res
|
|
732
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
733
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
734
|
+
}
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Restore a checkpoint — DESTRUCTIVE (reverts REAL workspace files).
|
|
739
|
+
// POST-only (a state-changing action must not be triggerable by a GET),
|
|
740
|
+
// body {hash}, gated by the optional bearer token above.
|
|
741
|
+
if (url.pathname === "/api/checkpoints/restore" && req.method !== "POST") {
|
|
742
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
if (url.pathname === "/api/checkpoints/restore" && req.method === "POST") {
|
|
746
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
747
|
+
const session = url.searchParams.get("session")
|
|
748
|
+
if (!repo || !session) {
|
|
749
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
750
|
+
JSON.stringify({ error: "missing repo or session — pass ?repo=<path>&session=<taskId>" }),
|
|
751
|
+
)
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
const raw = await readJsonBody(req)
|
|
756
|
+
let body: unknown
|
|
757
|
+
try {
|
|
758
|
+
body = JSON.parse(raw || "{}")
|
|
759
|
+
} catch (err) {
|
|
760
|
+
res
|
|
761
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
762
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
const hash = (body as { hash?: unknown })?.hash
|
|
766
|
+
if (typeof hash !== "string" || hash.trim() === "") {
|
|
767
|
+
res
|
|
768
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
769
|
+
.end(JSON.stringify({ error: "missing or empty 'hash' — provide the checkpoint hash to restore" }))
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
await restoreCheckpoint(
|
|
773
|
+
{ workspaceRoot: repo, sessionId: session, checkpointDir: options.checkpointDir },
|
|
774
|
+
hash.trim(),
|
|
775
|
+
)
|
|
776
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
777
|
+
JSON.stringify({ ok: true, restored: hash.trim() }),
|
|
778
|
+
)
|
|
779
|
+
} catch (error) {
|
|
780
|
+
res
|
|
781
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
782
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
783
|
+
}
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
// GET /api/files/content?repo=<path>&file=<rel> — one file's text content,
|
|
787
|
+
// capped/binary-safe (see src/dashboard/files.ts). Same traversal guard.
|
|
788
|
+
if (url.pathname === "/api/files/content" && req.method === "GET") {
|
|
789
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
790
|
+
if (!repo) {
|
|
791
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
792
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
793
|
+
)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
const file = url.searchParams.get("file")
|
|
797
|
+
if (!file) {
|
|
798
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
799
|
+
JSON.stringify({ error: "missing file — pass ?file=<relative-path>" }),
|
|
800
|
+
)
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
try {
|
|
804
|
+
const result = await readWorkspaceFile(repo, file)
|
|
805
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ file, ...result }))
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if (error instanceof PathTraversalError) {
|
|
808
|
+
res
|
|
809
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
810
|
+
.end(JSON.stringify({ error: error.message }))
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
res
|
|
814
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
815
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
816
|
+
}
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Permissions settings (dashboard-file-browser-and-permissions-ui): GET
|
|
821
|
+
// /api/settings/permissions?repo=<path> returns the current
|
|
822
|
+
// .headlesscode/permissions.json content OR the resolved built-in defaults
|
|
823
|
+
// clearly labeled as defaults-not-yet-customized (mirroring the
|
|
824
|
+
// mode-models page's "no file yet" UX, plus the resolved view so the page
|
|
825
|
+
// always shows what a session would actually enforce). POST validates with
|
|
826
|
+
// the SAME parser the config loader uses (parsePermissionsFileBody — no
|
|
827
|
+
// second validator that could drift) and writes the file. POST is covered
|
|
828
|
+
// by the optional bearer-token gate above.
|
|
829
|
+
if (url.pathname === "/api/settings/permissions") {
|
|
830
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
831
|
+
if (!repo) {
|
|
832
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
833
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
834
|
+
)
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
if (req.method === "GET") {
|
|
838
|
+
try {
|
|
839
|
+
// Reuse the strict loader: a malformed on-disk file surfaces as
|
|
840
|
+
// an error here instead of a silently-empty settings page.
|
|
841
|
+
const file = loadPermissionsFile(repo)
|
|
842
|
+
const resolved = resolvePermissions({ workspaceRoot: repo })
|
|
843
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
844
|
+
JSON.stringify({ file, resolved, isDefault: file === null }),
|
|
845
|
+
)
|
|
846
|
+
} catch (error) {
|
|
847
|
+
res
|
|
848
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
849
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
850
|
+
}
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
if (req.method === "POST") {
|
|
854
|
+
try {
|
|
855
|
+
const raw = await readJsonBody(req)
|
|
856
|
+
let body: unknown
|
|
857
|
+
try {
|
|
858
|
+
body = JSON.parse(raw)
|
|
859
|
+
} catch (err) {
|
|
860
|
+
res
|
|
861
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
862
|
+
.end(JSON.stringify({ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}` }))
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
// Same strict validation the config loader applies on read —
|
|
866
|
+
// parsePermissionsFileBody IS the loader's validator, so the
|
|
867
|
+
// save path can never drift from what the CLI/env path enforces.
|
|
868
|
+
let parsed: PermissionsFile
|
|
869
|
+
try {
|
|
870
|
+
parsed = parsePermissionsFileBody(body, "body")
|
|
871
|
+
} catch (err) {
|
|
872
|
+
res
|
|
873
|
+
.writeHead(400, { "content-type": "application/json; charset=utf-8" })
|
|
874
|
+
.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
const file = permissionsFilePath(repo)
|
|
878
|
+
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
879
|
+
await fsp.writeFile(file, stringifyPermissionsFile(parsed), "utf-8")
|
|
880
|
+
res
|
|
881
|
+
.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
882
|
+
.end(JSON.stringify({ ok: true, path: file }))
|
|
883
|
+
} catch (error) {
|
|
884
|
+
res
|
|
885
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
886
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
887
|
+
}
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (req.method !== "GET") {
|
|
895
|
+
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed")
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (url.pathname === "/") {
|
|
900
|
+
const html = renderPage(options.repo)
|
|
901
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(html)
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (url.pathname === "/api/summary") {
|
|
906
|
+
try {
|
|
907
|
+
const summary = await buildSummary(options.repo)
|
|
908
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(summary))
|
|
909
|
+
} catch (error) {
|
|
910
|
+
res
|
|
911
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
912
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
913
|
+
}
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Recursive self-improvement progress metrics (issue #145): GET
|
|
918
|
+
// /api/self-improvement?repo=<path>. Local-dashboard-only per direct
|
|
919
|
+
// product direction (2026-08-21) — the external home.capsize.online
|
|
920
|
+
// deploy the original issue named is explicitly out of scope; this repo
|
|
921
|
+
// only needs to expose the data + a local view of it.
|
|
922
|
+
if (url.pathname === "/api/self-improvement" && req.method === "GET") {
|
|
923
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
924
|
+
if (!repo) {
|
|
925
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
926
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
927
|
+
)
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
const cached = selfImprovementCache
|
|
932
|
+
const fresh = cached && cached.repo === repo && Date.now() - cached.computedAt < SELF_IMPROVEMENT_CACHE_TTL_MS
|
|
933
|
+
const data = fresh && cached ? cached.data : await computeSelfImprovementMetrics(repo)
|
|
934
|
+
if (!fresh) {
|
|
935
|
+
selfImprovementCache = { repo, computedAt: Date.now(), data }
|
|
936
|
+
}
|
|
937
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(data))
|
|
938
|
+
} catch (error) {
|
|
939
|
+
res
|
|
940
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
941
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
942
|
+
}
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Historical cost/token/duration (issue #29): GET /api/cost-history?repo=<path>
|
|
947
|
+
// serves the central store's recorded per-group cost history AND per-session
|
|
948
|
+
// breakdown (cost-history.jsonl / session-cost-history.jsonl — see
|
|
949
|
+
// src/orchestrator/cost-history.ts) as JSON. Read-only; a repo with no
|
|
950
|
+
// history yet yields empty arrays, never an error (missing files are handled
|
|
951
|
+
// by the readers). Optional windowing: ?since=<iso> keeps only records
|
|
952
|
+
// recorded at/after the timestamp (ISO-8601 strings compare correctly), and
|
|
953
|
+
// ?limit=N keeps the N most recently recorded — both applied after sorting
|
|
954
|
+
// newest-first, matching the CLI's most-recent-first presentation.
|
|
955
|
+
if (url.pathname === "/api/cost-history" && req.method === "GET") {
|
|
956
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
957
|
+
if (!repo) {
|
|
958
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
959
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
960
|
+
)
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
const sinceRaw = url.searchParams.get("since")
|
|
964
|
+
let since: string | undefined
|
|
965
|
+
if (sinceRaw !== null && sinceRaw !== "") {
|
|
966
|
+
if (Number.isNaN(Date.parse(sinceRaw))) {
|
|
967
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
968
|
+
JSON.stringify({ error: "invalid since — pass an ISO-8601 timestamp, e.g. ?since=2026-08-05T00:00:00Z" }),
|
|
969
|
+
)
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
since = sinceRaw
|
|
973
|
+
}
|
|
974
|
+
const limitRaw = url.searchParams.get("limit")
|
|
975
|
+
let limit: number | undefined
|
|
976
|
+
if (limitRaw !== null && limitRaw !== "") {
|
|
977
|
+
limit = Number(limitRaw)
|
|
978
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
979
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
980
|
+
JSON.stringify({ error: "invalid limit — pass a positive integer" }),
|
|
981
|
+
)
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
let groups = await readCostHistory(repo)
|
|
987
|
+
let sessions = await readSessionCostHistory(repo)
|
|
988
|
+
if (since) {
|
|
989
|
+
// Compare parsed instants, not raw strings: recordedAt is always
|
|
990
|
+
// millisecond-precision ("...000Z") while a hand-typed ?since=
|
|
991
|
+
// (e.g. the error message's own example) is often second-precision
|
|
992
|
+
// ("...Z") — the same instant in both forms compares unequal (and
|
|
993
|
+
// sorts wrong) as strings because "." (0x2E) < "Z" (0x5A).
|
|
994
|
+
const sinceMs = Date.parse(since)
|
|
995
|
+
groups = groups.filter((r) => Date.parse(r.recordedAt) >= sinceMs)
|
|
996
|
+
sessions = sessions.filter((r) => Date.parse(r.recordedAt) >= sinceMs)
|
|
997
|
+
}
|
|
998
|
+
// Newest first (the ordering the CLI prints), then window to N.
|
|
999
|
+
groups.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt))
|
|
1000
|
+
sessions.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt))
|
|
1001
|
+
if (limit !== undefined) {
|
|
1002
|
+
groups = groups.slice(0, limit)
|
|
1003
|
+
sessions = sessions.slice(0, limit)
|
|
1004
|
+
}
|
|
1005
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
1006
|
+
JSON.stringify({ generatedAt: new Date().toISOString(), repo: path.resolve(repo), groups, sessions }),
|
|
1007
|
+
)
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
res
|
|
1010
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
1011
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
1012
|
+
}
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Central per-project store registry (project-registry-and-store-cleanup):
|
|
1017
|
+
// GET /api/projects enumerates every <key>/ dir under the store root via
|
|
1018
|
+
// the same listProjectEntries the `headlesscode projects list` CLI uses.
|
|
1019
|
+
// Default view hides pure stale-unregistered litter (registered or
|
|
1020
|
+
// still-existing only); ?all=1 shows everything. Read-only, no ?repo=
|
|
1021
|
+
// needed — the store root is machine-global, not repo-scoped.
|
|
1022
|
+
if (url.pathname === "/api/projects" && req.method === "GET") {
|
|
1023
|
+
try {
|
|
1024
|
+
const all = url.searchParams.get("all") === "1"
|
|
1025
|
+
const entries = listProjectEntries()
|
|
1026
|
+
const filtered = all ? entries : entries.filter((e) => e.registered || e.exists)
|
|
1027
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(
|
|
1028
|
+
JSON.stringify({ projects: filtered }),
|
|
1029
|
+
)
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
res
|
|
1032
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
1033
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
1034
|
+
}
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Available modes for the "start a session" mode selector: the project's
|
|
1039
|
+
// own .roomodes merged with global ~/.roo/custom_modes.yaml (same loader
|
|
1040
|
+
// the CLI uses — see src/engine/prompt.ts). The selector falls back to a
|
|
1041
|
+
// plain text input when this is unavailable, so a failure here is a
|
|
1042
|
+
// non-fatal degradation, not an error page.
|
|
1043
|
+
if (url.pathname === "/api/modes") {
|
|
1044
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
1045
|
+
if (!repo) {
|
|
1046
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
1047
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
1048
|
+
)
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
try {
|
|
1052
|
+
const customModes = await loadCustomModes(repo)
|
|
1053
|
+
const modes = customModes.map((m) => ({ slug: m.slug, name: m.name ?? m.slug, source: m.source ?? "unknown" }))
|
|
1054
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ modes }))
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
res
|
|
1057
|
+
.writeHead(500, { "content-type": "application/json; charset=utf-8" })
|
|
1058
|
+
.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
1059
|
+
}
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Deterministic per-project codemap (issue #17): GET /api/codemap?repo=<path>
|
|
1064
|
+
// serves the stored codemap.json and GET /api/codemap/html?repo=<path>
|
|
1065
|
+
// serves the self-contained visualizer — both read from the CENTRAL
|
|
1066
|
+
// per-project store, exactly what `headlesscode codemap --workspace`
|
|
1067
|
+
// writes. The dashboard never generates the map on request (it's a
|
|
1068
|
+
// deterministic script job); a missing map is a 404 with a clear pointer
|
|
1069
|
+
// to the generating command.
|
|
1070
|
+
if (url.pathname === "/api/codemap" || url.pathname === "/api/codemap/html") {
|
|
1071
|
+
const repo = url.searchParams.get("repo") || options.repo
|
|
1072
|
+
if (!repo) {
|
|
1073
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(
|
|
1074
|
+
JSON.stringify({ error: "missing repo — pass ?repo=<path> or start the dashboard with --repo" }),
|
|
1075
|
+
)
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
const wantHtml = url.pathname === "/api/codemap/html"
|
|
1079
|
+
const found = wantHtml ? readCodemapHtml(repo) : readCodemapJson(repo)
|
|
1080
|
+
if (found === undefined) {
|
|
1081
|
+
res.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(
|
|
1082
|
+
JSON.stringify({ error: codemapMissingError(repo) }),
|
|
1083
|
+
)
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
1086
|
+
res.writeHead(200, { "content-type": found.contentType }).end(found.content)
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not Found")
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Read a request body as text (bounded — settings payloads are tiny). */
|
|
1094
|
+
async function readJsonBody(req: http.IncomingMessage): Promise<string> {
|
|
1095
|
+
const chunks: Buffer[] = []
|
|
1096
|
+
for await (const chunk of req) {
|
|
1097
|
+
chunks.push(chunk as Buffer)
|
|
1098
|
+
if (chunks.reduce((n, c) => n + c.length, 0) > 1_000_000) {
|
|
1099
|
+
throw new Error("request body too large (limit 1MB)")
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return Buffer.concat(chunks).toString("utf-8")
|
|
1103
|
+
}
|