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.
Files changed (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. 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
+ }