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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser control plane — launching a top-level session from the dashboard and
|
|
3
|
+
* answering its escalated questions.
|
|
4
|
+
*
|
|
5
|
+
* Two capabilities, both deliberately thin wrappers around what already exists:
|
|
6
|
+
*
|
|
7
|
+
* 1. `launchSession` spawns the SAME CLI invocation `scripts/run-worker.sh`
|
|
8
|
+
* uses (`npx tsx src/cli.ts --task ... --mode ... --workspace ...`) as a
|
|
9
|
+
* DETACHED background process — `detached: true` + `unref()` — so the
|
|
10
|
+
* dashboard HTTP handler returns immediately and the session keeps running
|
|
11
|
+
* after the server's own process group is long gone. The child inherits
|
|
12
|
+
* the dashboard process's environment, which is exactly why
|
|
13
|
+
* `HEADLESSCODE_OPENROUTER_API_KEY` must be exported (e.g. `set -a; source .env`) when
|
|
14
|
+
* the dashboard itself is started — this endpoint does NOT re-source
|
|
15
|
+
* `.env` the way the CLI's other entry points do.
|
|
16
|
+
*
|
|
17
|
+
* 2. `answerSessionDecision` writes `.harness.decision-answer` in the
|
|
18
|
+
* session's workspace root — functionally identical to
|
|
19
|
+
* `scripts/headlesscode-answer.sh`, but without a subprocess for a
|
|
20
|
+
* one-line file write. The worker's `ask_followup_question` poll picks it
|
|
21
|
+
* up, deletes both markers, and resumes (see src/tools/executor.ts).
|
|
22
|
+
*
|
|
23
|
+
* Session id determinism: the launch endpoint generates the session id HERE
|
|
24
|
+
* (a UUID, same generator HeadlessSession uses internally) and passes it to
|
|
25
|
+
* the CLI via `--session-id` (added in src/cli.ts → HeadlessSessionConfig),
|
|
26
|
+
* so the browser can open the session's live event view immediately instead
|
|
27
|
+
* of polling for "whatever session showed up after we launched".
|
|
28
|
+
*
|
|
29
|
+
* The CLI string is split into argv and spawned WITHOUT a shell (spawn's
|
|
30
|
+
* default), so the user's task text never passes through shell quoting —
|
|
31
|
+
* no injection surface for a form-POST endpoint. `detached: true` still puts
|
|
32
|
+
* the child in its own process group, matching the executor.ts idiom.
|
|
33
|
+
*
|
|
34
|
+
* This module is pure fs + child_process — no HTTP, so it's unit-testable
|
|
35
|
+
* without spinning up the dashboard server (see the server.ts wrapper).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import * as fs from "node:fs"
|
|
39
|
+
import * as fsp from "node:fs/promises"
|
|
40
|
+
import * as path from "node:path"
|
|
41
|
+
import { execFileSync, spawn } from "node:child_process"
|
|
42
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
43
|
+
|
|
44
|
+
/** Marker basenames, relative to the session's workspace root (src/tools/executor.ts idiom). */
|
|
45
|
+
const NEEDS_DECISION_FILENAME = ".harness.needs-decision"
|
|
46
|
+
const DECISION_ANSWER_FILENAME = ".harness.decision-answer"
|
|
47
|
+
/** Mid-session message injection marker (see src/engine/loop.ts's checkInjectedMessage). */
|
|
48
|
+
const INJECT_MESSAGE_FILENAME = ".harness.inject-message"
|
|
49
|
+
|
|
50
|
+
/** The default mode for a browser-launched top-level session. */
|
|
51
|
+
export const DEFAULT_LAUNCH_MODE = "multi-agent-orchestrator-headless"
|
|
52
|
+
|
|
53
|
+
/** Request body for POST /api/session/start. */
|
|
54
|
+
export interface SessionStartRequest {
|
|
55
|
+
/** Workspace/repo root the session runs against. */
|
|
56
|
+
repo?: string
|
|
57
|
+
/** The user's typed prompt. */
|
|
58
|
+
task?: string
|
|
59
|
+
/** Mode slug (default: multi-agent-orchestrator-headless). */
|
|
60
|
+
mode?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Request body for POST /api/session/:id/answer. */
|
|
64
|
+
export interface SessionAnswerRequest {
|
|
65
|
+
answer?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface LaunchSessionResult {
|
|
69
|
+
sessionId: string
|
|
70
|
+
pid: number
|
|
71
|
+
workspace: string
|
|
72
|
+
mode: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `chown` target for newly-created worktree files, e.g. "1000:1000". Unset = skip. */
|
|
76
|
+
export const WORKTREE_OWNER_ENV = "HEADLESSCODE_DASHBOARD_WORKTREE_OWNER"
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Return a stable, disposable worktree path for `sourceRepo` under
|
|
80
|
+
* `worktreeRoot`, creating it (a fresh branch off the source repo's
|
|
81
|
+
* current HEAD) the first time this pair is seen. Reused on every
|
|
82
|
+
* subsequent call for the same source repo — NOT reset between
|
|
83
|
+
* sessions, so it accumulates history like a real feature branch
|
|
84
|
+
* rather than behaving as a throwaway per-launch sandbox.
|
|
85
|
+
*
|
|
86
|
+
* Container-deployment note: a worktree's `.git` is a
|
|
87
|
+
* `gitdir: <host-absolute-path>` pointer back into the source repo's
|
|
88
|
+
* `.git/worktrees/<name>` — the caller MUST mount `sourceRepo` at its
|
|
89
|
+
* real host-absolute path (not a container-local alias) for that
|
|
90
|
+
* pointer to resolve. `worktreeRoot` has no such constraint.
|
|
91
|
+
*
|
|
92
|
+
* `git worktree add` writes into BOTH the new worktree dir AND the
|
|
93
|
+
* source repo's own `.git` (a new `worktrees/<name>` admin dir, a new
|
|
94
|
+
* `refs/heads/<branch>`) — when this process runs as root (a
|
|
95
|
+
* container's default; running as the host UID instead breaks the
|
|
96
|
+
* image's own baked root-owned paths — see the compose file's
|
|
97
|
+
* comment), those end up root-owned on the host, breaking ordinary
|
|
98
|
+
* host-side git commands on `sourceRepo` afterward. `ownerEnv`
|
|
99
|
+
* (`HEADLESSCODE_DASHBOARD_WORKTREE_OWNER`, e.g. "1000:1000") chowns
|
|
100
|
+
* every path this call touched back to the host user; unset = skip
|
|
101
|
+
* (today's behavior, for a deployment that doesn't need it).
|
|
102
|
+
*/
|
|
103
|
+
export function ensureWorktree(worktreeRoot: string, sourceRepo: string): string {
|
|
104
|
+
const resolvedRepo = path.resolve(sourceRepo)
|
|
105
|
+
const slug = path.basename(resolvedRepo).replace(/[^a-zA-Z0-9._-]+/g, "-") || "repo"
|
|
106
|
+
const hash = createHash("sha1").update(resolvedRepo).digest("hex").slice(0, 8)
|
|
107
|
+
const wtPath = path.join(worktreeRoot, `${slug}-${hash}`)
|
|
108
|
+
if (fs.existsSync(path.join(wtPath, ".git"))) {
|
|
109
|
+
return wtPath
|
|
110
|
+
}
|
|
111
|
+
fs.mkdirSync(worktreeRoot, { recursive: true })
|
|
112
|
+
const branchLeaf = `${slug}-${hash}`
|
|
113
|
+
const branch = `headlesscode-session/${branchLeaf}`
|
|
114
|
+
execFileSync("git", ["-C", resolvedRepo, "worktree", "add", "-b", branch, wtPath], {
|
|
115
|
+
stdio: "ignore",
|
|
116
|
+
timeout: 60_000,
|
|
117
|
+
})
|
|
118
|
+
chownWorktreeArtifacts(wtPath, resolvedRepo)
|
|
119
|
+
return wtPath
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* chown `target` (recursive) to `HEADLESSCODE_DASHBOARD_WORKTREE_OWNER`, if
|
|
124
|
+
* configured. Shared by `ensureWorktree`'s one-time setup chown and the
|
|
125
|
+
* CLI's own post-session chown (see cli.ts, right after `session.run()`
|
|
126
|
+
* returns) — worktree creation isn't the only thing that leaves root-owned
|
|
127
|
+
* files behind when the whole process runs as root: the SESSION ITSELF
|
|
128
|
+
* writes into the worktree throughout the run (file edits, its own
|
|
129
|
+
* .headlesscode/{events,usage,reports}, git's own index as it stages
|
|
130
|
+
* things) — see the doc comment on ensureWorktree for the container-as-
|
|
131
|
+
* root rationale this all follows from. `label` is only used in the
|
|
132
|
+
* non-fatal failure log line.
|
|
133
|
+
*/
|
|
134
|
+
export function chownPathForHostUser(target: string, label: string): void {
|
|
135
|
+
const owner = process.env[WORKTREE_OWNER_ENV]?.trim()
|
|
136
|
+
if (!owner || !fs.existsSync(target)) {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
execFileSync("chown", ["-R", owner, target], { stdio: "ignore", timeout: 30_000 })
|
|
141
|
+
} catch (error) {
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
`[worktree] chown ${owner} ${target} failed (non-fatal; ${label} may need manual chown): ` +
|
|
144
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `chownPathForHostUser(workspaceRoot, ...)` alone misses one thing: a
|
|
151
|
+
* worktree's own `.git` is a plain-text `gitdir: <path>` pointer into the
|
|
152
|
+
* SOURCE repo's `.git/worktrees/<name>/` admin dir (index, HEAD, etc.),
|
|
153
|
+
* which git keeps updating for the life of the worktree (e.g. its own
|
|
154
|
+
* `index` file changes as the session's `git` commands stage things) —
|
|
155
|
+
* that admin dir lives OUTSIDE workspaceRoot entirely, so it needs its
|
|
156
|
+
* own chown. No-ops (same as chownPathForHostUser) when the owner env
|
|
157
|
+
* isn't configured or `workspaceRoot` isn't actually a worktree (a plain
|
|
158
|
+
* `.git` directory, not a file, when a session runs directly against a
|
|
159
|
+
* real repo instead of ensureWorktree's disposable copy).
|
|
160
|
+
*/
|
|
161
|
+
export function chownWorktreeWorkspace(workspaceRoot: string): void {
|
|
162
|
+
chownPathForHostUser(workspaceRoot, `session workspace ${workspaceRoot}`)
|
|
163
|
+
const gitPath = path.join(workspaceRoot, ".git")
|
|
164
|
+
let stat: fs.Stats
|
|
165
|
+
try {
|
|
166
|
+
stat = fs.statSync(gitPath)
|
|
167
|
+
} catch {
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (!stat.isFile()) {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
const contents = fs.readFileSync(gitPath, "utf-8").trim()
|
|
174
|
+
const match = /^gitdir:\s*(.+)$/.exec(contents)
|
|
175
|
+
if (match) {
|
|
176
|
+
chownPathForHostUser(match[1], `git-worktree admin dir for ${workspaceRoot}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** chown everything `ensureWorktree`'s `git worktree add` call just created, if configured. */
|
|
181
|
+
function chownWorktreeArtifacts(wtPath: string, sourceRepo: string): void {
|
|
182
|
+
const targets = [
|
|
183
|
+
wtPath,
|
|
184
|
+
// The parent .git/worktrees/ dir itself gets created (root-owned)
|
|
185
|
+
// on this repo's FIRST worktree ever — chown the whole thing, not
|
|
186
|
+
// just this call's <name> subdirectory, so an earlier root-owned
|
|
187
|
+
// parent doesn't linger.
|
|
188
|
+
path.join(sourceRepo, ".git", "worktrees"),
|
|
189
|
+
// The branch lives under refs/heads/headlesscode-session/<leaf>,
|
|
190
|
+
// with a matching reflog under logs/ — chown both whole
|
|
191
|
+
// headlesscode-session/ dirs since every worktree this function
|
|
192
|
+
// creates shares them.
|
|
193
|
+
path.join(sourceRepo, ".git", "refs", "heads", "headlesscode-session"),
|
|
194
|
+
path.join(sourceRepo, ".git", "logs", "refs", "heads", "headlesscode-session"),
|
|
195
|
+
]
|
|
196
|
+
for (const target of targets) {
|
|
197
|
+
chownPathForHostUser(target, `host-side git commands on ${sourceRepo}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Validate a session-start body. Returns a human-readable error string when
|
|
203
|
+
* the body is unusable, undefined when it's acceptable.
|
|
204
|
+
*/
|
|
205
|
+
export function validateSessionStartBody(body: unknown): string | undefined {
|
|
206
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
207
|
+
return "body must be a JSON object"
|
|
208
|
+
}
|
|
209
|
+
const { task, repo, mode } = body as Record<string, unknown>
|
|
210
|
+
if (typeof task !== "string" || task.trim() === "") {
|
|
211
|
+
return "missing or empty 'task' — provide the prompt text to run"
|
|
212
|
+
}
|
|
213
|
+
if (repo !== undefined && (typeof repo !== "string" || repo.trim() === "")) {
|
|
214
|
+
return "'repo' must be a non-empty string path"
|
|
215
|
+
}
|
|
216
|
+
if (mode !== undefined && (typeof mode !== "string" || mode.trim() === "")) {
|
|
217
|
+
return "'mode' must be a non-empty string slug"
|
|
218
|
+
}
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Split a CLI command string into argv (whitespace-separated, no shell). */
|
|
223
|
+
function splitCli(cli: string): string[] {
|
|
224
|
+
return cli.split(/\s+/).filter((part) => part.length > 0)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Launch a top-level HeadlessSession as a detached background process,
|
|
229
|
+
* mirroring scripts/run-worker.sh's launch semantics (nohup-style, no
|
|
230
|
+
* blocking on the child). Resolves once the child has been spawned — NOT
|
|
231
|
+
* when it finishes, which is the whole point of detached/unref.
|
|
232
|
+
*/
|
|
233
|
+
export async function launchSession(
|
|
234
|
+
options: {
|
|
235
|
+
/** Repo root the session runs against (the dashboard's --repo, or a body override). */
|
|
236
|
+
workspace: string
|
|
237
|
+
task: string
|
|
238
|
+
mode?: string
|
|
239
|
+
/**
|
|
240
|
+
* CLI invocation template, e.g. "npx tsx src/cli.ts". Tests point it
|
|
241
|
+
* at a tiny fake script for fast, deterministic assertions.
|
|
242
|
+
*/
|
|
243
|
+
cli?: string
|
|
244
|
+
/** Directory the CLI runs in (default: process.cwd() — the dashboard's cwd). */
|
|
245
|
+
repoRoot?: string
|
|
246
|
+
},
|
|
247
|
+
): Promise<LaunchSessionResult> {
|
|
248
|
+
const sessionId = randomUUID()
|
|
249
|
+
const mode = options.mode?.trim() ? options.mode.trim() : DEFAULT_LAUNCH_MODE
|
|
250
|
+
const workspace = path.resolve(options.workspace)
|
|
251
|
+
const cli = options.cli ?? "npx tsx src/cli.ts"
|
|
252
|
+
// The dashboard may have been started from a different cwd than this repo;
|
|
253
|
+
// the CLI runs with repoRoot as cwd so `npx tsx` finds src/cli.ts no
|
|
254
|
+
// matter where the dashboard process was launched from.
|
|
255
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd())
|
|
256
|
+
|
|
257
|
+
const argv = [...splitCli(cli), "--task", options.task, "--mode", mode, "--workspace", workspace, "--session-id", sessionId]
|
|
258
|
+
|
|
259
|
+
// NOTE (control-plane): the child inherits this process's environment —
|
|
260
|
+
// HEADLESSCODE_OPENROUTER_API_KEY and OPENROUTER_BASE_URL (the e2e mock) included. The
|
|
261
|
+
// dashboard is expected to have sourced .env at startup (see cli.ts's
|
|
262
|
+
// dashboard help); this endpoint deliberately does not re-source it.
|
|
263
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
264
|
+
cwd: repoRoot,
|
|
265
|
+
detached: true,
|
|
266
|
+
stdio: "ignore",
|
|
267
|
+
env: process.env,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Detach fully: the child must neither keep the dashboard's event loop
|
|
271
|
+
// alive nor die when the dashboard exits. It becomes its own process
|
|
272
|
+
// group leader (detached: true) and keeps running independently.
|
|
273
|
+
child.unref()
|
|
274
|
+
|
|
275
|
+
return { sessionId, pid: child.pid ?? 0, workspace, mode }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Answer a blocked session's pending `ask_followup_question` by writing
|
|
280
|
+
* `<workspaceRoot>/.harness.decision-answer` (the marker
|
|
281
|
+
* `ask_followup_question` polls for — see src/tools/executor.ts). Mirrors
|
|
282
|
+
* scripts/headlesscode-answer.sh, in-process (no subprocess for a one-line
|
|
283
|
+
* write; the two implementations must stay in sync).
|
|
284
|
+
*/
|
|
285
|
+
export async function answerSessionDecision(workspaceRoot: string, answer: string): Promise<void> {
|
|
286
|
+
await fsp.writeFile(path.join(workspaceRoot, DECISION_ANSWER_FILENAME), answer, "utf-8")
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Inject a new user message into a RUNNING session by writing
|
|
291
|
+
* `<workspaceRoot>/.harness.inject-message` (JSON: `{ text, injectedAt }`).
|
|
292
|
+
* The loop's checkInjectedMessage (src/engine/loop.ts) picks it up before its
|
|
293
|
+
* next LLM call, appends `text` as a plain user-role message, and deletes the
|
|
294
|
+
* marker — the model sees it as if the user had just typed it, NOT as a tool
|
|
295
|
+
* result or interruption. Mirrors answerSessionDecision's marker protocol;
|
|
296
|
+
* deliberately a SEPARATE code path from answering an escalated question.
|
|
297
|
+
*
|
|
298
|
+
* Policy: ONE pending message per session — a second injection while the
|
|
299
|
+
* first is still pending OVERWRITES it (overwrite-with-latest, no queue).
|
|
300
|
+
*/
|
|
301
|
+
export async function injectSessionMessage(workspaceRoot: string, text: string): Promise<void> {
|
|
302
|
+
const marker = JSON.stringify({ text, injectedAt: new Date().toISOString() }) + "\n"
|
|
303
|
+
await fsp.writeFile(path.join(workspaceRoot, INJECT_MESSAGE_FILENAME), marker, "utf-8")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Marker filenames, exported for tests. */
|
|
307
|
+
export const DECISION_MARKERS = {
|
|
308
|
+
needsDecision: NEEDS_DECISION_FILENAME,
|
|
309
|
+
decisionAnswer: DECISION_ANSWER_FILENAME,
|
|
310
|
+
} as const
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline-model support for the dashboard's session detail view — reduces
|
|
3
|
+
* the flat chronological event feed (the same data served by
|
|
4
|
+
* `GET /api/session/:id/events`) into a per-iteration "shape" model: what
|
|
5
|
+
* kind of work each iteration did (read/write/exec/search by dominant tool
|
|
6
|
+
* type), the running tool-mix totals, and the structural markers
|
|
7
|
+
* (checkpoints, condensation, pause/resume, decisions) that the horizontal
|
|
8
|
+
* timeline view renders.
|
|
9
|
+
*
|
|
10
|
+
* Like `src/dashboard/chat-thread.ts` this module is deliberately pure (no
|
|
11
|
+
* DOM, no fetch): the classification/bucketing is the part worth
|
|
12
|
+
* unit-testing, and keeping it here lets
|
|
13
|
+
* `src/dashboard/__tests__/timeline.test.ts` exercise it with fixture event
|
|
14
|
+
* arrays. `src/dashboard/page.ts` carries its own inline vanilla-JS twin of
|
|
15
|
+
* `buildTimeline` (no build step means the page cannot import this file) —
|
|
16
|
+
* keep the two in sync.
|
|
17
|
+
*
|
|
18
|
+
* Classification (the four buckets the project owner's tool-mix question
|
|
19
|
+
* needs as real numbers): `read` = file/symbol reading, `search` =
|
|
20
|
+
* semantic codebase search (its own bucket so "N searches" is a real count,
|
|
21
|
+
* colored a read-adjacent hue on the timeline), `write` = editing tools,
|
|
22
|
+
* `exec` = shell execution. Anything else is `other`. An iteration with no
|
|
23
|
+
* tool calls at all is `none` (a faint empty cell).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { EventRecord } from "../engine/events.js"
|
|
27
|
+
|
|
28
|
+
export type ToolCategory = "read" | "write" | "exec" | "search" | "other" | "none"
|
|
29
|
+
|
|
30
|
+
const READ_TOOLS = new Set([
|
|
31
|
+
"read_file",
|
|
32
|
+
"list_files",
|
|
33
|
+
"outline",
|
|
34
|
+
"go_to_definition",
|
|
35
|
+
"find_references",
|
|
36
|
+
"import_graph",
|
|
37
|
+
])
|
|
38
|
+
const WRITE_TOOLS = new Set(["write_to_file", "apply_diff", "search_replace", "edit_file", "set_indentation"])
|
|
39
|
+
const EXEC_TOOLS = new Set(["execute_command"])
|
|
40
|
+
const SEARCH_TOOLS = new Set(["codebase_search"])
|
|
41
|
+
|
|
42
|
+
/** The single bucket a tool contributes to (never more than one). */
|
|
43
|
+
export function classifyTool(tool: string): Exclude<ToolCategory, "none"> {
|
|
44
|
+
if (READ_TOOLS.has(tool)) return "read"
|
|
45
|
+
if (WRITE_TOOLS.has(tool)) return "write"
|
|
46
|
+
if (EXEC_TOOLS.has(tool)) return "exec"
|
|
47
|
+
if (SEARCH_TOOLS.has(tool)) return "search"
|
|
48
|
+
return "other"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tie-break priority when two categories have the same call count in one
|
|
53
|
+
* iteration: a write that appears once alongside a read that appears once is
|
|
54
|
+
* still the iteration's point, so writes beat reads at equal counts.
|
|
55
|
+
*/
|
|
56
|
+
const TIE_BREAK: Array<Exclude<ToolCategory, "none">> = ["write", "exec", "search", "read", "other"]
|
|
57
|
+
|
|
58
|
+
export interface ToolUsage {
|
|
59
|
+
name: string
|
|
60
|
+
count: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IterationSummary {
|
|
64
|
+
iteration: number
|
|
65
|
+
/** Dominant bucket for this iteration's tool calls; "none" when it made none. */
|
|
66
|
+
category: ToolCategory
|
|
67
|
+
toolCalls: ToolUsage[]
|
|
68
|
+
toolCount: number
|
|
69
|
+
firstTs: number
|
|
70
|
+
lastTs: number
|
|
71
|
+
inputTokens: number
|
|
72
|
+
outputTokens: number
|
|
73
|
+
cachedTokens: number
|
|
74
|
+
/** True when any tool_result in this iteration reported an error. */
|
|
75
|
+
hasError: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type MarkerKind =
|
|
79
|
+
| "start"
|
|
80
|
+
| "checkpoint"
|
|
81
|
+
| "condensed"
|
|
82
|
+
| "paused"
|
|
83
|
+
| "resumed"
|
|
84
|
+
| "decision"
|
|
85
|
+
| "end"
|
|
86
|
+
|
|
87
|
+
export interface TimelineMarker {
|
|
88
|
+
kind: MarkerKind
|
|
89
|
+
ts: number
|
|
90
|
+
iteration?: number
|
|
91
|
+
/** checkpoint_saved / condensed / session_end carry extra payload fields. */
|
|
92
|
+
[field: string]: unknown
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ToolMixTotals {
|
|
96
|
+
read: number
|
|
97
|
+
write: number
|
|
98
|
+
exec: number
|
|
99
|
+
search: number
|
|
100
|
+
other: number
|
|
101
|
+
toolCalls: number
|
|
102
|
+
inputTokens: number
|
|
103
|
+
outputTokens: number
|
|
104
|
+
cachedTokens: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TimelineModel {
|
|
108
|
+
sessionId: string
|
|
109
|
+
task?: string
|
|
110
|
+
model?: string
|
|
111
|
+
mode?: string
|
|
112
|
+
/** One summary per iteration that produced events, in iteration order. */
|
|
113
|
+
iterations: IterationSummary[]
|
|
114
|
+
/** Structural markers in chronological order (start/checkpoint/condensed/pause/…). */
|
|
115
|
+
markers: TimelineMarker[]
|
|
116
|
+
totals: ToolMixTotals
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildTimeline(events: EventRecord[]): TimelineModel {
|
|
120
|
+
const model: TimelineModel = {
|
|
121
|
+
sessionId: events[0]?.sessionId ?? "",
|
|
122
|
+
iterations: [],
|
|
123
|
+
markers: [],
|
|
124
|
+
totals: { read: 0, write: 0, exec: 0, search: 0, other: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** iteration -> category counts for tool_calls seen so far (insertion order). */
|
|
128
|
+
const categoryCounts = new Map<number, Map<Exclude<ToolCategory, "none">, number>>()
|
|
129
|
+
/** iteration -> distinct tool name -> call count. */
|
|
130
|
+
const toolCounts = new Map<number, Map<string, number>>()
|
|
131
|
+
const iterMeta = new Map<number, { firstTs: number; lastTs: number; inputTokens: number; outputTokens: number; cachedTokens: number; hasError: boolean }>()
|
|
132
|
+
|
|
133
|
+
// The feed's ts is an ISO string; the model works in epoch ms so the UI can
|
|
134
|
+
// position cells/markers on a real time axis. Non-timestamp feeds degrade
|
|
135
|
+
// to event order (each event gets a strictly increasing fallback value).
|
|
136
|
+
let tsFallback = 0
|
|
137
|
+
const toTs = (t: unknown): number => {
|
|
138
|
+
if (typeof t === "number") return t
|
|
139
|
+
const ms = typeof t === "string" ? Date.parse(t) : NaN
|
|
140
|
+
if (Number.isFinite(ms)) return ms
|
|
141
|
+
return tsFallback++
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ensureIteration = (iteration: number): void => {
|
|
145
|
+
if (!iterMeta.has(iteration)) {
|
|
146
|
+
iterMeta.set(iteration, { firstTs: Number.MAX_SAFE_INTEGER, lastTs: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, hasError: false })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const event of events) {
|
|
151
|
+
const ts = toTs(event.ts)
|
|
152
|
+
switch (event.type) {
|
|
153
|
+
case "session_start":
|
|
154
|
+
model.task = typeof event.task === "string" ? event.task : undefined
|
|
155
|
+
model.model = typeof event.model === "string" ? event.model : undefined
|
|
156
|
+
model.mode = typeof event.mode === "string" ? event.mode : undefined
|
|
157
|
+
model.markers.push({ kind: "start", ts })
|
|
158
|
+
break
|
|
159
|
+
case "session_end":
|
|
160
|
+
model.markers.push({ kind: "end", ts, status: event.status })
|
|
161
|
+
break
|
|
162
|
+
case "iteration_start": {
|
|
163
|
+
if (event.iteration === undefined) break
|
|
164
|
+
ensureIteration(event.iteration)
|
|
165
|
+
const meta = iterMeta.get(event.iteration)!
|
|
166
|
+
meta.firstTs = Math.min(meta.firstTs, ts)
|
|
167
|
+
meta.lastTs = Math.max(meta.lastTs, ts)
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
case "llm_response": {
|
|
171
|
+
const iteration = event.iteration
|
|
172
|
+
if (iteration === undefined) break
|
|
173
|
+
ensureIteration(iteration)
|
|
174
|
+
const meta = iterMeta.get(iteration)!
|
|
175
|
+
meta.firstTs = Math.min(meta.firstTs, ts)
|
|
176
|
+
meta.lastTs = Math.max(meta.lastTs, ts)
|
|
177
|
+
const input = typeof event.inputTokens === "number" ? event.inputTokens : 0
|
|
178
|
+
const output = typeof event.outputTokens === "number" ? event.outputTokens : 0
|
|
179
|
+
const cached = typeof event.cachedTokens === "number" ? event.cachedTokens : 0
|
|
180
|
+
meta.inputTokens += input
|
|
181
|
+
meta.outputTokens += output
|
|
182
|
+
meta.cachedTokens += cached
|
|
183
|
+
model.totals.inputTokens += input
|
|
184
|
+
model.totals.outputTokens += output
|
|
185
|
+
model.totals.cachedTokens += cached
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
case "tool_call": {
|
|
189
|
+
const iteration = event.iteration
|
|
190
|
+
if (iteration === undefined) break
|
|
191
|
+
ensureIteration(iteration)
|
|
192
|
+
const meta = iterMeta.get(iteration)!
|
|
193
|
+
meta.firstTs = Math.min(meta.firstTs, ts)
|
|
194
|
+
meta.lastTs = Math.max(meta.lastTs, ts)
|
|
195
|
+
const tool = typeof event.tool === "string" ? event.tool : "unknown"
|
|
196
|
+
const category = classifyTool(tool)
|
|
197
|
+
const counts = categoryCounts.get(iteration) ?? new Map()
|
|
198
|
+
counts.set(category, (counts.get(category) ?? 0) + 1)
|
|
199
|
+
categoryCounts.set(iteration, counts)
|
|
200
|
+
const tools = toolCounts.get(iteration) ?? new Map()
|
|
201
|
+
tools.set(tool, (tools.get(tool) ?? 0) + 1)
|
|
202
|
+
toolCounts.set(iteration, tools)
|
|
203
|
+
model.totals[category] += 1
|
|
204
|
+
model.totals.toolCalls += 1
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
case "tool_result": {
|
|
208
|
+
const iteration = event.iteration
|
|
209
|
+
if (iteration !== undefined && iterMeta.has(iteration) && event.isError === true) {
|
|
210
|
+
iterMeta.get(iteration)!.hasError = true
|
|
211
|
+
}
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
case "checkpoint_saved":
|
|
215
|
+
model.markers.push({ kind: "checkpoint", ts, iteration: event.iteration })
|
|
216
|
+
break
|
|
217
|
+
case "condensed":
|
|
218
|
+
model.markers.push({
|
|
219
|
+
kind: "condensed",
|
|
220
|
+
ts,
|
|
221
|
+
iteration: event.iteration,
|
|
222
|
+
messagesBefore: event.messagesBefore,
|
|
223
|
+
messagesAfter: event.messagesAfter,
|
|
224
|
+
inputTokens: event.inputTokens,
|
|
225
|
+
outputTokens: event.outputTokens,
|
|
226
|
+
cachedTokens: event.cachedTokens,
|
|
227
|
+
})
|
|
228
|
+
break
|
|
229
|
+
case "paused":
|
|
230
|
+
model.markers.push({ kind: "paused", ts, iteration: event.iteration, reason: event.reason })
|
|
231
|
+
break
|
|
232
|
+
case "resumed":
|
|
233
|
+
model.markers.push({ kind: "resumed", ts, iteration: event.iteration, reason: event.reason })
|
|
234
|
+
break
|
|
235
|
+
case "decision_blocked":
|
|
236
|
+
model.markers.push({ kind: "decision", ts, question: event.question })
|
|
237
|
+
break
|
|
238
|
+
case "decision_answered":
|
|
239
|
+
model.markers.push({ kind: "decision", ts, answer: event.answer, timedOut: event.timedOut })
|
|
240
|
+
break
|
|
241
|
+
default:
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Materialize iterations in ascending iteration order (Map insertion order).
|
|
247
|
+
for (const [iteration, meta] of iterMeta) {
|
|
248
|
+
const counts = categoryCounts.get(iteration) ?? new Map()
|
|
249
|
+
const tools = toolCounts.get(iteration) ?? new Map()
|
|
250
|
+
const dominant: ToolCategory =
|
|
251
|
+
counts.size === 0
|
|
252
|
+
? "none"
|
|
253
|
+
: [...counts.entries()].sort(
|
|
254
|
+
(a, b) => b[1] - a[1] || TIE_BREAK.indexOf(a[0]) - TIE_BREAK.indexOf(b[0]),
|
|
255
|
+
)[0][0]
|
|
256
|
+
model.iterations.push({
|
|
257
|
+
iteration,
|
|
258
|
+
category: dominant,
|
|
259
|
+
toolCalls: [...tools.entries()]
|
|
260
|
+
.map(([name, count]) => ({ name, count }))
|
|
261
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
|
|
262
|
+
toolCount: [...tools.values()].reduce((sum, n) => sum + n, 0),
|
|
263
|
+
firstTs: meta.firstTs === Number.MAX_SAFE_INTEGER ? 0 : meta.firstTs,
|
|
264
|
+
lastTs: meta.lastTs,
|
|
265
|
+
inputTokens: meta.inputTokens,
|
|
266
|
+
outputTokens: meta.outputTokens,
|
|
267
|
+
cachedTokens: meta.cachedTokens,
|
|
268
|
+
hasError: meta.hasError,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return model
|
|
273
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous single-tool execution for the dashboard control plane.
|
|
3
|
+
*
|
|
4
|
+
* Gives callers (e.g. a host app's code-mode agent tools — see the host
|
|
5
|
+
* project's uwuchat server `proxy_tools.py`) the same
|
|
6
|
+
* headlesscode tool set WITHOUT spinning up a full session: build one
|
|
7
|
+
* `ToolExecutor` for a workspace root, run exactly one tool call, return the
|
|
8
|
+
* `ToolResult`. Every guardrail is inherited from the executor — workspace
|
|
9
|
+
* path safety (`resolveWithinWorkspace`), command allow/deny + protected-file
|
|
10
|
+
* permissions (`src/permissions/`), output truncation, timeout→background
|
|
11
|
+
* semantics for `execute_command`.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately STATELESS: each call constructs a fresh executor, so the
|
|
14
|
+
* session-scoped read_file cache, list_files repeat-call guard, and todo list
|
|
15
|
+
* do NOT carry across calls. UwUChat's own conversation history is the cache.
|
|
16
|
+
* Revisit with a session-keyed executor pool if token cost from re-reading
|
|
17
|
+
* files ever becomes material.
|
|
18
|
+
*
|
|
19
|
+
* Background-command semantics: `execute_command`'s timeout leaves the child
|
|
20
|
+
* running in the background (the model gets partial output + "running in the
|
|
21
|
+
* background"). For the proxy we call `executor.dispose()` in a `finally`,
|
|
22
|
+
* which hard-kills any backgrounded child (see ToolExecutor.dispose /
|
|
23
|
+
* killBackgroundCommands) — the proxy does NOT support long-running
|
|
24
|
+
* background commands; a timed-out call returns partial output and the child
|
|
25
|
+
* is reaped. Callers should pass an explicit `timeout` to bound long commands.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as fs from "node:fs"
|
|
29
|
+
|
|
30
|
+
import { createHeadlessExecutor } from "../tools/executor.js"
|
|
31
|
+
import type { ToolResult } from "../engine/types.js"
|
|
32
|
+
|
|
33
|
+
/** Request body for POST /api/tool/execute. */
|
|
34
|
+
export interface ToolExecuteRequest {
|
|
35
|
+
/** Workspace root the tool runs against (the registered project's worktree). */
|
|
36
|
+
workspace: string
|
|
37
|
+
/** Tool name, e.g. "execute_command". */
|
|
38
|
+
name: string
|
|
39
|
+
/** Tool args (command/cwd for execute_command, path/content for file tools…). */
|
|
40
|
+
args: Record<string, unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Response body for POST /api/tool/execute. */
|
|
44
|
+
export interface ToolExecuteResult {
|
|
45
|
+
ok: boolean
|
|
46
|
+
isError: boolean
|
|
47
|
+
content: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run one tool call against `req.workspace` and return the executor result.
|
|
52
|
+
*
|
|
53
|
+
* Validates the request shape first (workspace exists + absolute, name
|
|
54
|
+
* non-empty, args an object). `executor.dispose()` always runs afterwards so
|
|
55
|
+
* a timed-out backgrounded `execute_command` child is hard-killed rather than
|
|
56
|
+
* orphaned.
|
|
57
|
+
*/
|
|
58
|
+
export function executeTool(req: ToolExecuteRequest): Promise<ToolExecuteResult> {
|
|
59
|
+
const err = validateToolExecuteRequest(req)
|
|
60
|
+
if (err) {
|
|
61
|
+
return Promise.resolve({ ok: false, isError: true, content: err })
|
|
62
|
+
}
|
|
63
|
+
const executor = createHeadlessExecutor(req.workspace)
|
|
64
|
+
try {
|
|
65
|
+
return executor.execute(req.name, req.args).then((result: ToolResult) => ({
|
|
66
|
+
ok: !result.isError,
|
|
67
|
+
isError: result.isError,
|
|
68
|
+
content: result.content,
|
|
69
|
+
}))
|
|
70
|
+
} finally {
|
|
71
|
+
executor.dispose()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Return a validation error string, or null when `req` is well-formed. */
|
|
76
|
+
export function validateToolExecuteRequest(req: ToolExecuteRequest): string | null {
|
|
77
|
+
if (!req || typeof req !== "object") {
|
|
78
|
+
return "invalid request body — expected { workspace, name, args }"
|
|
79
|
+
}
|
|
80
|
+
if (typeof req.workspace !== "string" || req.workspace.trim() === "") {
|
|
81
|
+
return "missing 'workspace' — provide the absolute workspace root the tool should run against"
|
|
82
|
+
}
|
|
83
|
+
if (!pathIsAbsolute(req.workspace)) {
|
|
84
|
+
return `'workspace' must be an absolute path: ${req.workspace}`
|
|
85
|
+
}
|
|
86
|
+
if (!fs.existsSync(req.workspace)) {
|
|
87
|
+
return `'workspace' does not exist: ${req.workspace}`
|
|
88
|
+
}
|
|
89
|
+
if (typeof req.name !== "string" || req.name.trim() === "") {
|
|
90
|
+
return "missing 'name' — provide the tool name to execute"
|
|
91
|
+
}
|
|
92
|
+
if (req.args === null || typeof req.args !== "object" || Array.isArray(req.args)) {
|
|
93
|
+
return "'args' must be an object of tool arguments"
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `path.isAbsolute` without importing node:path (kept dependency-light). */
|
|
99
|
+
function pathIsAbsolute(p: string): boolean {
|
|
100
|
+
if (p.startsWith("/")) {
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
if (/^[A-Za-z]:[\\/]/.test(p)) {
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|