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,428 @@
1
+ /**
2
+ * Per-session structured event feed — live worker monitoring (Phase 1).
3
+ *
4
+ * Mirrors `src/engine/usage.ts`'s idiom exactly: append-only JSONL, one file
5
+ * per session, loose validation on read, plain `node:fs` — no schema library.
6
+ *
7
+ * <workspaceRoot>/.headlesscode/events/<sessionId>.jsonl
8
+ *
9
+ * Each line is one `EventRecord`:
10
+ *
11
+ * { ts: ISO string, sessionId: string, type: string, ...fields }
12
+ *
13
+ * Event types emitted by HeadlessSession (see src/engine/loop.ts):
14
+ * session_start / iteration_start / llm_response / tool_call / tool_result
15
+ * checkpoint_saved / decision_blocked / decision_answered
16
+ * condensed / todo_updated / paused / resumed / session_end
17
+ * attempt_completion (issue #34): the session's FINAL report, emitted when
18
+ * the loop accepts completion (attempt_completion tool call OR the
19
+ * text-only success fallback) and carries the FULL report text — this is
20
+ * the deliberate exception to EVENT_TRUNCATE_CHARS: the report is exactly
21
+ * the content the model produced, so truncating it would reintroduce the
22
+ * observability gap it exists to close (a surprising review/QA verdict was
23
+ * previously unrecoverable without re-running the whole session). The
24
+ * full report is ALSO persisted to `<workspaceRoot>/.headlesscode/reports/
25
+ * <sessionId>.md` (see src/engine/reports.ts) so it survives even a feed
26
+ * that never gets polled.
27
+ * mode_switched (switch_mode: the session's own active mode changed in
28
+ * place — same session, new system prompt/tool set; see
29
+ * plans/switch-mode-headless.md)
30
+ * message_injected (live chat-UI control: a human wrote a new user message
31
+ * into the RUNNING session via the dashboard — POST /api/session/:id/message
32
+ * → `.harness.inject-message` marker; see loop.ts's checkInjectedMessage.
33
+ * The text has been appended to the session's live history as a plain
34
+ * user-role message and the next LLM call sees it)
35
+ *
36
+ * `condensed` is the one structural event that was originally only a log line
37
+ * (`[condense] oldest turns condensed…` in src/engine/condense.ts) — the
38
+ * dashboard's timeline view needs it as a real event to mark where the
39
+ * session compressed its history, so it is emitted by HeadlessSession when a
40
+ * condensation pass actually replaces the oldest turns.
41
+ *
42
+ * `todo_updated` (update_todo_list: full normalized checklist + counts —
43
+ * done / inProgress / pending; the dashboard reads this to render live
44
+ * planning state)
45
+ *
46
+ * Unlike the live usage snapshot (deleted at session end because the final
47
+ * .jsonl record supersedes it), the event feed is a history/replay log and is
48
+ * KEPT after the session ends — it is never deleted in session teardown.
49
+ *
50
+ * This module is the ONLY place that knows the on-disk events layout, so the
51
+ * dashboard read side (`src/dashboard/aggregate.ts`'s `readSessionEvents`)
52
+ * and the write side (`EventFeed` below) stay in sync.
53
+ */
54
+
55
+ import * as fsp from "node:fs/promises"
56
+ import * as path from "node:path"
57
+
58
+ /** Cap on any single event field's string content (this feed is polled frequently). */
59
+ export const EVENT_TRUNCATE_CHARS = 500
60
+
61
+ /** One line of the per-session event feed. */
62
+ export interface EventRecord {
63
+ ts: string
64
+ sessionId: string
65
+ type: string
66
+ iteration?: number
67
+ /**
68
+ * Recursive task decomposition (`new_task`): the id of the session that
69
+ * spawned this one, present on every event of a child session (absent on
70
+ * root sessions). Lets the dashboard render parent/child nesting without
71
+ * cross-referencing anything else — the lineage is stamped onto each
72
+ * record, so a child feed never needs its parent's feed to be readable.
73
+ */
74
+ parentSessionId?: string
75
+ /**
76
+ * Recursive task decomposition (`new_task`): this session's recursion
77
+ * depth (0 = root). Stamped onto every event alongside parentSessionId.
78
+ */
79
+ recursionDepth?: number
80
+ [field: string]: unknown
81
+ }
82
+
83
+ /** The events dir for a workspace: `<workspaceRoot>/.headlesscode/events`. */
84
+ export function eventsDir(workspaceRoot: string): string {
85
+ return path.join(workspaceRoot, ".headlesscode", "events")
86
+ }
87
+
88
+ /** The event feed file path for one session. */
89
+ export function eventsFilePath(workspaceRoot: string, sessionId: string): string {
90
+ return path.join(eventsDir(workspaceRoot), `${sessionId}.jsonl`)
91
+ }
92
+
93
+ /**
94
+ * Append one event record to a session's feed. Creates the events dir if
95
+ * needed. Callers are expected to wrap this in try/catch and treat failures
96
+ * as non-fatal (see `EventFeed`); this function itself does not swallow
97
+ * errors, so it fails loudly for direct callers/tests.
98
+ */
99
+ export async function appendEvent(workspaceRoot: string, sessionId: string, record: EventRecord): Promise<void> {
100
+ const file = eventsFilePath(workspaceRoot, sessionId)
101
+ await fsp.mkdir(path.dirname(file), { recursive: true })
102
+ await fsp.appendFile(file, JSON.stringify(record) + "\n", "utf-8")
103
+ }
104
+
105
+ /**
106
+ * Read + loosely validate one event feed JSONL file. Malformed /
107
+ * partially-written lines are skipped; a missing file yields an empty array
108
+ * (never throws for ENOENT — matches the usage store's `readUsageFile` idiom).
109
+ */
110
+ export async function readEventsFile(file: string): Promise<EventRecord[]> {
111
+ let raw: string
112
+ try {
113
+ raw = await fsp.readFile(file, "utf-8")
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
116
+ return []
117
+ }
118
+ throw error
119
+ }
120
+ const records: EventRecord[] = []
121
+ for (const line of raw.split("\n")) {
122
+ const trimmed = line.trim()
123
+ if (!trimmed) {
124
+ continue
125
+ }
126
+ try {
127
+ const parsed = JSON.parse(trimmed) as Partial<EventRecord>
128
+ if (
129
+ typeof parsed.ts === "string" &&
130
+ typeof parsed.sessionId === "string" &&
131
+ typeof parsed.type === "string"
132
+ ) {
133
+ records.push(parsed as EventRecord)
134
+ }
135
+ } catch {
136
+ // Loose validation: skip malformed / partially-written lines.
137
+ }
138
+ }
139
+ return records
140
+ }
141
+
142
+ /** A string field that was possibly truncated to `EVENT_TRUNCATE_CHARS`. */
143
+ export interface TruncatedField {
144
+ text: string
145
+ truncated: boolean
146
+ }
147
+
148
+ /** Truncate a long string for the event feed, flagging when it was cut. */
149
+ export function truncateField(value: string, max = EVENT_TRUNCATE_CHARS): TruncatedField {
150
+ if (value.length <= max) {
151
+ return { text: value, truncated: false }
152
+ }
153
+ return { text: value.slice(0, max), truncated: true }
154
+ }
155
+
156
+ /**
157
+ * The session's structured event writer. Every emit is non-fatal: a failure
158
+ * to append is reported via the injected `onError` callback (the loop wires
159
+ * it to `logger.warn`, matching the established memory/usage/checkpoint
160
+ * try/catch idiom) and never affects the session.
161
+ *
162
+ * Emits are serialized through an internal promise chain so fire-and-forget
163
+ * calls (e.g. decision events surfaced from inside a tool handler) still
164
+ * append to the same file in emission order.
165
+ */
166
+ export class EventFeed {
167
+ private queue: Promise<void> = Promise.resolve()
168
+
169
+ constructor(
170
+ private readonly workspaceRoot: string,
171
+ private readonly sessionId: string,
172
+ private readonly onError: (eventType: string, error: unknown) => void,
173
+ /**
174
+ * Recursive task decomposition (`new_task`): the parent session's id
175
+ * and this session's recursion depth, stamped onto EVERY record so a
176
+ * child feed is self-describing. Absent on root sessions (no-op).
177
+ */
178
+ private readonly lineage?: { parentSessionId?: string; recursionDepth?: number },
179
+ ) {}
180
+
181
+ /** Append a raw event record (non-fatal). */
182
+ emit(type: string, fields: Record<string, unknown> = {}): Promise<void> {
183
+ const record: EventRecord = {
184
+ ts: new Date().toISOString(),
185
+ sessionId: this.sessionId,
186
+ type,
187
+ ...(this.lineage?.parentSessionId ? { parentSessionId: this.lineage.parentSessionId } : {}),
188
+ ...(this.lineage?.recursionDepth !== undefined ? { recursionDepth: this.lineage.recursionDepth } : {}),
189
+ ...fields,
190
+ }
191
+ const task = this.queue.then(() => appendEvent(this.workspaceRoot, this.sessionId, record))
192
+ // Keep the chain alive even when a write fails (the error is reported
193
+ // to onError; later events must still be attempted).
194
+ this.queue = task.catch(() => undefined)
195
+ return task
196
+ }
197
+
198
+ sessionStart(fields: { mode: string; model: string; workspaceRoot: string; taskText: string }): Promise<void> {
199
+ const task = truncateField(fields.taskText)
200
+ return this.emit("session_start", {
201
+ mode: fields.mode,
202
+ model: fields.model,
203
+ workspaceRoot: fields.workspaceRoot,
204
+ task: task.text,
205
+ ...(task.truncated ? { taskTruncated: true } : {}),
206
+ })
207
+ }
208
+
209
+ iterationStart(iteration: number, historyMessageCount: number): Promise<void> {
210
+ return this.emit("iteration_start", { iteration, historyMessageCount })
211
+ }
212
+
213
+ llmResponse(fields: {
214
+ iteration: number
215
+ hadToolCalls: boolean
216
+ textPreview?: string
217
+ /** Reasoning/"thinking" text (streaming-and-reasoning), truncated like other large fields. */
218
+ reasoningPreview?: string
219
+ inputTokens?: number
220
+ outputTokens?: number
221
+ cachedTokens?: number
222
+ }): Promise<void> {
223
+ const text = fields.textPreview ? truncateField(fields.textPreview) : undefined
224
+ const reasoning = fields.reasoningPreview ? truncateField(fields.reasoningPreview) : undefined
225
+ return this.emit("llm_response", {
226
+ iteration: fields.iteration,
227
+ hadToolCalls: fields.hadToolCalls,
228
+ ...(text ? { textPreview: text.text, ...(text.truncated ? { textTruncated: true } : {}) } : {}),
229
+ ...(reasoning
230
+ ? { reasoningPreview: reasoning.text, ...(reasoning.truncated ? { reasoningTruncated: true } : {}) }
231
+ : {}),
232
+ ...(fields.inputTokens !== undefined ? { inputTokens: fields.inputTokens } : {}),
233
+ ...(fields.outputTokens !== undefined ? { outputTokens: fields.outputTokens } : {}),
234
+ ...(fields.cachedTokens !== undefined ? { cachedTokens: fields.cachedTokens } : {}),
235
+ })
236
+ }
237
+
238
+ /**
239
+ * One incremental chunk of a streamed LLM response (streaming-and-reasoning,
240
+ * opt-in). Distinct from the single `llm_response` summary event which still
241
+ * fires once at the end of the call with final totals — this is the
242
+ * live-typing feed. `kind` is "text" | "reasoning" | "tool" so the dashboard
243
+ * can render reasoning text differently from the answer text. Truncated like
244
+ * every other large field.
245
+ */
246
+ llmStreamChunk(fields: {
247
+ iteration: number
248
+ kind: "text" | "reasoning" | "tool"
249
+ chunk: string
250
+ }): Promise<void> {
251
+ const c = truncateField(fields.chunk)
252
+ return this.emit("llm_stream_chunk", {
253
+ iteration: fields.iteration,
254
+ kind: fields.kind,
255
+ chunk: c.text,
256
+ ...(c.truncated ? { chunkTruncated: true } : {}),
257
+ })
258
+ }
259
+
260
+ toolCall(fields: { iteration: number; tool: string; args: string; argsTruncated: boolean }): Promise<void> {
261
+ return this.emit("tool_call", {
262
+ iteration: fields.iteration,
263
+ tool: fields.tool,
264
+ args: fields.args,
265
+ ...(fields.argsTruncated ? { argsTruncated: true } : {}),
266
+ })
267
+ }
268
+
269
+ toolResult(fields: {
270
+ iteration: number
271
+ tool: string
272
+ isError: boolean
273
+ result: string
274
+ resultTruncated: boolean
275
+ }): Promise<void> {
276
+ return this.emit("tool_result", {
277
+ iteration: fields.iteration,
278
+ tool: fields.tool,
279
+ isError: fields.isError,
280
+ result: fields.result,
281
+ ...(fields.resultTruncated ? { resultTruncated: true } : {}),
282
+ })
283
+ }
284
+
285
+ /**
286
+ * The session's final report (issue #34). Emitted when the loop accepts
287
+ * completion — the one tool call that matters most for understanding a
288
+ * review/QA/worker verdict was previously invisible to the feed because
289
+ * attempt_completion short-circuits before the tool-execution loop (no
290
+ * tool_call/tool_result pair is ever recorded for it). `result` is the
291
+ * FULL report text, deliberately NOT truncated (the only event field that
292
+ * ignores EVENT_TRUNCATE_CHARS — see the module doc comment for why); the
293
+ * full report is also persisted to `.headlesscode/reports/<sessionId>.md`
294
+ * by the loop, so the feed and the file are two views of the same text.
295
+ */
296
+ attemptCompletion(fields: { iteration: number; result: string }): Promise<void> {
297
+ return this.emit("attempt_completion", {
298
+ iteration: fields.iteration,
299
+ result: fields.result,
300
+ })
301
+ }
302
+
303
+ /** iteration 0 = the session's baseline checkpoint (before iteration 1). */
304
+ checkpointSaved(iteration: number): Promise<void> {
305
+ return this.emit("checkpoint_saved", { iteration })
306
+ }
307
+
308
+ /**
309
+ * A context-condensation pass replaced the oldest turns with one synthetic
310
+ * summary message (see src/engine/condense.ts). Carries the iteration whose
311
+ * call triggered it, the message counts before/after, and the condensation
312
+ * call's own token usage when known (the dashboard timeline marks the
313
+ * point; the tokens make the spend visible — condensation was previously
314
+ * only a log line, invisible to the feed).
315
+ */
316
+ condensed(fields: {
317
+ iteration: number
318
+ messagesBefore: number
319
+ messagesAfter: number
320
+ inputTokens?: number
321
+ outputTokens?: number
322
+ cachedTokens?: number
323
+ }): Promise<void> {
324
+ return this.emit("condensed", {
325
+ iteration: fields.iteration,
326
+ messagesBefore: fields.messagesBefore,
327
+ messagesAfter: fields.messagesAfter,
328
+ ...(fields.inputTokens !== undefined ? { inputTokens: fields.inputTokens } : {}),
329
+ ...(fields.outputTokens !== undefined ? { outputTokens: fields.outputTokens } : {}),
330
+ ...(fields.cachedTokens !== undefined ? { cachedTokens: fields.cachedTokens } : {}),
331
+ })
332
+ }
333
+
334
+ /**
335
+ * Todo-list state change (update_todo_list): the full normalized checklist
336
+ * (truncated like every other large field) plus done / in-progress /
337
+ * pending counts, so the dashboard can show live planning state and the
338
+ * history can show how the checklist evolved across the session.
339
+ */
340
+ todoUpdated(fields: { todos: string; done: number; inProgress: number; pending: number }): Promise<void> {
341
+ const t = truncateField(fields.todos)
342
+ return this.emit("todo_updated", {
343
+ todos: t.text,
344
+ ...(t.truncated ? { todosTruncated: true } : {}),
345
+ done: fields.done,
346
+ inProgress: fields.inProgress,
347
+ pending: fields.pending,
348
+ })
349
+ }
350
+
351
+ decisionBlocked(question: string, suggestions?: string[]): Promise<void> {
352
+ const q = truncateField(question)
353
+ return this.emit("decision_blocked", {
354
+ question: q.text,
355
+ ...(q.truncated ? { questionTruncated: true } : {}),
356
+ ...(suggestions && suggestions.length > 0 ? { suggestions } : {}),
357
+ })
358
+ }
359
+
360
+ /** Answer received, or the wait timed out (timedOut: true) and the worker moved on. */
361
+ decisionAnswered(answer?: string, timedOut = false): Promise<void> {
362
+ const a = answer !== undefined ? truncateField(answer) : undefined
363
+ return this.emit("decision_answered", {
364
+ ...(timedOut ? { timedOut: true } : {}),
365
+ ...(a ? { answer: a.text, ...(a.truncated ? { answerTruncated: true } : {}) } : {}),
366
+ })
367
+ }
368
+
369
+ /**
370
+ * switch_mode changed the session's OWN active mode in place (same
371
+ * session/history/budget, new system prompt + tool set from this point
372
+ * forward — see plans/switch-mode-headless.md). autoApproved tells the
373
+ * dashboard whether the switch went through the human/orchestrator
374
+ * approval gate (false) or the off-by-default auto-approve opt-in (true).
375
+ */
376
+ modeSwitched(fields: { from: string; to: string; reason: string; autoApproved: boolean }): Promise<void> {
377
+ const reason = truncateField(fields.reason)
378
+ return this.emit("mode_switched", {
379
+ from: fields.from,
380
+ to: fields.to,
381
+ reason: reason.text,
382
+ ...(reason.truncated ? { reasonTruncated: true } : {}),
383
+ autoApproved: fields.autoApproved,
384
+ })
385
+ }
386
+
387
+ /**
388
+ * Mid-session message injection (live chat-UI control): a human wrote a
389
+ * new message into a RUNNING session (POST /api/session/:id/message →
390
+ * `.harness.inject-message` marker; see loop.ts's checkInjectedMessage).
391
+ * The text is a plain user-role message the session appended to its live
392
+ * history; the dashboard renders it inline as if the user had just typed
393
+ * it. Truncated like every other large field.
394
+ */
395
+ messageInjected(fields: { text: string }): Promise<void> {
396
+ const t = truncateField(fields.text)
397
+ return this.emit("message_injected", {
398
+ text: t.text,
399
+ ...(t.truncated ? { textTruncated: true } : {}),
400
+ })
401
+ }
402
+
403
+ paused(reason?: string): Promise<void> {
404
+ return this.emit("paused", { ...(reason ? { reason } : {}) })
405
+ }
406
+
407
+ resumed(reason?: string): Promise<void> {
408
+ return this.emit("resumed", { ...(reason ? { reason } : {}) })
409
+ }
410
+
411
+ sessionEnd(fields: {
412
+ status: string
413
+ iterations: number
414
+ costUsd: number
415
+ inputTokens: number
416
+ outputTokens: number
417
+ cachedTokens: number
418
+ }): Promise<void> {
419
+ return this.emit("session_end", {
420
+ status: fields.status,
421
+ iterations: fields.iterations,
422
+ costUsd: fields.costUsd,
423
+ inputTokens: fields.inputTokens,
424
+ outputTokens: fields.outputTokens,
425
+ cachedTokens: fields.cachedTokens,
426
+ })
427
+ }
428
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Cross-restart handoff summary — `<workspaceRoot>/.headlesscode/handoff-summary.md`.
3
+ *
4
+ * Mirrors the on-disk-layout modules idiom (src/engine/reports.ts for final
5
+ * reports, src/engine/usage.ts for usage): one module owns one layout.
6
+ *
7
+ * Why this exists: when a session hits the iteration cap, the orchestrator
8
+ * spawns a continuation worker on the SAME worktree, but as a BRAND NEW
9
+ * conversation — the previous session's entire message history (every
10
+ * `read_file`/`execute_command` result, every bit of reasoning about where
11
+ * things live) is gone. Only the continuation task file and whatever is on
12
+ * disk (git diff, commits) survive. In practice the continuation task file
13
+ * only says "go inspect the worktree yourself", so the new session re-derives
14
+ * everything the old one already learned at real cost — this was measured on
15
+ * a live job: a continuation session re-read the exact same files (in full)
16
+ * that the session before it had already read in full.
17
+ *
18
+ * The fix: right before a session gives up on the iteration cap, it
19
+ * summarizes its own history (via condense.ts's condenseOldestTurns — the
20
+ * same LLM-compression machinery the mid-session condensation path already
21
+ * uses) and writes the result here. The orchestrator's continuation-task-file
22
+ * builder reads it and inlines it as "what the previous session already
23
+ * learned", so the new session starts from a compact summary instead of a
24
+ * blank slate.
25
+ *
26
+ * A single FIXED filename (not per-session): the orchestrator only ever needs
27
+ * "the latest handoff for this worktree", and each continuation attempt
28
+ * refreshes it. `clearHandoffSummary` removes it once consumed, so a stale
29
+ * summary from a finished task never leaks into an unrelated later one.
30
+ *
31
+ * Like every other auxiliary write path in this codebase, writing is
32
+ * deliberately non-fatal: callers wrap it in try/catch and log a warning — a
33
+ * failed handoff write must never abort or corrupt the session that produced
34
+ * it.
35
+ */
36
+
37
+ import * as fs from "node:fs"
38
+ import * as fsp from "node:fs/promises"
39
+ import * as path from "node:path"
40
+
41
+ /** The handoff summary file for a workspace: `<workspaceRoot>/.headlesscode/handoff-summary.md`. */
42
+ export function handoffSummaryPath(workspaceRoot: string): string {
43
+ return path.join(workspaceRoot, ".headlesscode", "handoff-summary.md")
44
+ }
45
+
46
+ /** Persist a session's handoff summary, overwriting any previous one for this worktree. */
47
+ export async function writeHandoffSummary(workspaceRoot: string, summaryText: string): Promise<string> {
48
+ const file = handoffSummaryPath(workspaceRoot)
49
+ await fsp.mkdir(path.dirname(file), { recursive: true })
50
+ await fsp.writeFile(file, summaryText, "utf-8")
51
+ return file
52
+ }
53
+
54
+ /** Synchronous read for the orchestrator's task-file builder (no existing async plumbing there). Returns undefined if absent/unreadable. */
55
+ export function readHandoffSummary(workspaceRoot: string): string | undefined {
56
+ const file = handoffSummaryPath(workspaceRoot)
57
+ try {
58
+ return fs.readFileSync(file, "utf-8")
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ /** Remove a consumed handoff summary so it never leaks into a later, unrelated continuation. Non-fatal. */
65
+ export function clearHandoffSummary(workspaceRoot: string): void {
66
+ try {
67
+ fs.unlinkSync(handoffSummaryPath(workspaceRoot))
68
+ } catch {
69
+ // Absent or unremovable — nothing to clean up, nothing to report.
70
+ }
71
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Lazy tool-catalog loading — opt-in (default OFF), gated to sessions that
3
+ * need it: a small local model's context window can be dominated by the
4
+ * FULL tool catalog before the task even starts (measured live 2026-08-19
5
+ * against Qwen3-Coder-30B-A3B on an RTX 5080: 21 schemas = ~19.5K prompt
6
+ * tokens against a ~24.5K real ceiling — under 5K tokens left for the
7
+ * actual task). Cloud sessions (OpenRouter/DeepSeek) keep today's static
8
+ * full catalog untouched: a stable prefix is what makes their prompt cache
9
+ * cheap, and changing it per-call would hurt cache-hit rate for no local
10
+ * benefit. This module only ever narrows what ONE session sends; it never
11
+ * changes what a tool DOES.
12
+ *
13
+ * Same discovery-then-fetch shape published for MCP/tool-heavy agentic
14
+ * workflows (dynamic tool gating + lazy schema loading): the model gets a
15
+ * cheap index up front (`list_tools`) and pulls a tool's real schema into
16
+ * its OWN next turn only once it decides it needs it (`request_tool`),
17
+ * instead of paying for every schema on every turn regardless of use.
18
+ */
19
+
20
+ import type { ChatTool } from "./types.js"
21
+
22
+ export const LAZY_TOOL_CATALOG_ENV = "HEADLESSCODE_LAZY_TOOL_CATALOG"
23
+
24
+ export function isLazyToolCatalogEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
25
+ const v = env[LAZY_TOOL_CATALOG_ENV]
26
+ return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false"
27
+ }
28
+
29
+ /**
30
+ * Tools sent on every turn regardless of lazy-loading: the minimum needed
31
+ * to read, edit, run commands, delegate, and finish a task. Everything else
32
+ * in a session's full tool list is offered lazily via `list_tools` /
33
+ * `request_tool` instead.
34
+ *
35
+ * `apply_diff` deliberately excluded (available lazily, not core) — its
36
+ * SEARCH/REPLACE block format requires escaping literal `=======`/`<<<<<<<`
37
+ * markers found in real file content, and a `:start_line:[line_number]`
38
+ * header where `[line_number]` is a template placeholder to substitute a
39
+ * real integer into. Verified live 2026-08-19 against Qwen2.5-Coder-14B:
40
+ * it copied `:start_line:[line_number]` into a real call verbatim (treating
41
+ * the placeholder as literal syntax) and separately failed to escape a
42
+ * literal `=======` divider inside a file's own content across 4 consecutive
43
+ * retries despite an explicit corrective error message each time. `edit_file`
44
+ * and `search_replace` need none of this — plain old_string/new_string with
45
+ * NO escaping rules and (edit_file) a fuzzy-match fallback — and stay core.
46
+ */
47
+ export const CORE_TOOL_NAMES = new Set([
48
+ "ask_followup_question",
49
+ "attempt_completion",
50
+ "execute_command",
51
+ "list_files",
52
+ "new_task",
53
+ "read_file",
54
+ // search_replace deliberately excluded — same reasoning as apply_diff
55
+ // above it in git history: search_replace requires an EXACT literal
56
+ // match (see searchReplaceHandler, src/tools/executor.ts) with no
57
+ // fallback, while edit_file has a 3-stage fallback chain (exact →
58
+ // whitespace-tolerant → token-based) specifically built to survive the
59
+ // whitespace/indentation drift a model's old_string commonly has.
60
+ // Verified live 2026-08-20: given both tools, a local model picked
61
+ // search_replace, hit exactly the whitespace-mismatch failure edit_file
62
+ // exists to prevent, then spiralled into an unrelated list_files loop
63
+ // instead of recovering — a harness/tool-catalog problem, not
64
+ // necessarily a model capability ceiling. Still reachable via
65
+ // request_tool for a session that genuinely needs it.
66
+ "edit_file",
67
+ // set_indentation deliberately core, not lazy (issue #141): it exists
68
+ // specifically to give local sessions — the same sessions lazy-loading
69
+ // targets — a way to fix a pure indentation mismatch without edit_file's
70
+ // two-near-identical-multi-line-strings shape. Live-verified 2026-08-21:
71
+ // gating it behind request_tool defeated the whole point — a model given
72
+ // an explicit task-level instruction to use it still defaulted back to
73
+ // (always-visible) edit_file 2/2 times, then on a 3rd trial DID try to
74
+ // call it correctly but only as narrated text, never having pulled its
75
+ // real schema in via request_tool first.
76
+ "set_indentation",
77
+ "switch_mode",
78
+ "update_todo_list",
79
+ "write_to_file",
80
+ ])
81
+
82
+ export const LIST_TOOLS_NAME = "list_tools"
83
+ export const REQUEST_TOOL_NAME = "request_tool"
84
+
85
+ export interface SplitTools {
86
+ /** Always sent: the core set plus list_tools/request_tool themselves. */
87
+ core: ChatTool[]
88
+ /** Available on request only, keyed by tool name. */
89
+ lazyByName: Map<string, ChatTool>
90
+ }
91
+
92
+ /** Split a session's full (already executor-gated) tool list into core + lazy. */
93
+ export function splitCoreAndLazyTools(allTools: ChatTool[]): SplitTools {
94
+ const core: ChatTool[] = []
95
+ const lazyByName = new Map<string, ChatTool>()
96
+ for (const tool of allTools) {
97
+ if (tool.type !== "function") {
98
+ core.push(tool)
99
+ continue
100
+ }
101
+ if (CORE_TOOL_NAMES.has(tool.function.name)) {
102
+ core.push(tool)
103
+ } else {
104
+ lazyByName.set(tool.function.name, tool)
105
+ }
106
+ }
107
+ return {
108
+ core: [...core, buildListToolsTool(), buildRequestToolTool()],
109
+ lazyByName,
110
+ }
111
+ }
112
+
113
+ export function buildListToolsTool(): ChatTool {
114
+ return {
115
+ type: "function",
116
+ function: {
117
+ name: LIST_TOOLS_NAME,
118
+ description:
119
+ "List additional tools available in this session beyond the core set " +
120
+ "(read_file, write_to_file, apply_diff, search_replace, edit_file, " +
121
+ "execute_command, list_files, attempt_completion, ask_followup_question, " +
122
+ "switch_mode, new_task, update_todo_list — always available, not listed " +
123
+ "here). Call request_tool with a name from this list to make that tool " +
124
+ "callable on your NEXT turn.",
125
+ parameters: { type: "object", properties: {}, required: [] },
126
+ },
127
+ }
128
+ }
129
+
130
+ export function buildRequestToolTool(): ChatTool {
131
+ return {
132
+ type: "function",
133
+ function: {
134
+ name: REQUEST_TOOL_NAME,
135
+ description:
136
+ "Make one additional tool (from list_tools' output) callable starting " +
137
+ "on your NEXT turn. Costs nothing to call again if already active.",
138
+ parameters: {
139
+ type: "object",
140
+ properties: { name: { type: "string", description: "Exact tool name from list_tools" } },
141
+ required: ["name"],
142
+ },
143
+ },
144
+ }
145
+ }
146
+
147
+ /** One line per lazy tool: name + its schema's own description, truncated. */
148
+ export function renderToolIndex(lazyByName: Map<string, ChatTool>): string {
149
+ if (lazyByName.size === 0) {
150
+ return "No additional tools are available in this session."
151
+ }
152
+ const lines = [...lazyByName.values()].map((tool) => {
153
+ if (tool.type !== "function") {
154
+ return `- ${tool.type}`
155
+ }
156
+ const description = (tool.function.description ?? "").split(/(?<=[.!?])\s/)[0] ?? ""
157
+ return `- ${tool.function.name}: ${description.slice(0, 120)}`
158
+ })
159
+ return lines.join("\n")
160
+ }