howtofixcli 0.7.24 → 0.7.26

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 (167) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/acp.js +234 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +250 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +121 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +381 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/connect-prompt.js +22 -0
  24. package/dist/context-extension.js +17 -0
  25. package/dist/dcp/commands.js +172 -0
  26. package/dist/dcp/compress-state.js +139 -0
  27. package/dist/dcp/compress.js +329 -0
  28. package/dist/dcp/dcp-extension.js +295 -0
  29. package/dist/dcp/dedup.js +93 -0
  30. package/dist/dcp/ids.js +189 -0
  31. package/dist/dcp/index.js +25 -0
  32. package/dist/dcp/middle-prune.js +76 -0
  33. package/dist/dcp/prompts.js +68 -0
  34. package/dist/dcp/protected-content.js +221 -0
  35. package/dist/dcp/protected.js +161 -0
  36. package/dist/dcp/prune.js +418 -0
  37. package/dist/dcp/state.js +176 -0
  38. package/dist/errors.js +63 -0
  39. package/dist/extensions.js +48 -0
  40. package/dist/fatal.js +35 -0
  41. package/dist/feed.js +498 -0
  42. package/dist/fs-atomic.js +54 -0
  43. package/dist/gateway-errors.js +107 -0
  44. package/dist/git.js +230 -0
  45. package/dist/goal-extension.js +88 -0
  46. package/dist/goal.js +222 -0
  47. package/dist/hooks-extension.js +176 -0
  48. package/dist/hooks.js +443 -0
  49. package/dist/howtofix-account.js +834 -0
  50. package/dist/hud-line.js +158 -0
  51. package/dist/hwid.js +130 -0
  52. package/dist/interrupt.js +60 -0
  53. package/dist/json-events.js +50 -0
  54. package/dist/level-style.js +112 -0
  55. package/dist/loop-guard.js +95 -0
  56. package/dist/lsp-tools.js +32 -0
  57. package/dist/lsp.js +429 -0
  58. package/dist/main.js +573 -98
  59. package/dist/mcp-config.js +85 -0
  60. package/dist/mcp-lifecycle.js +147 -0
  61. package/dist/model-catalog.js +217 -0
  62. package/dist/model-facts.js +35 -0
  63. package/dist/model-resolve.js +65 -0
  64. package/dist/notes.js +78 -0
  65. package/dist/oauth-login.js +568 -0
  66. package/dist/paths.js +61 -0
  67. package/dist/permissions.js +298 -0
  68. package/dist/plan-mode.js +363 -0
  69. package/dist/preset-command.js +227 -0
  70. package/dist/preset-extension.js +35 -0
  71. package/dist/preset-mcp.js +292 -0
  72. package/dist/presets/mcp/rea/preset.json +28 -0
  73. package/dist/presets/mcp/rea/prompt.md +5 -0
  74. package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
  75. package/dist/presets/mcp/remotion/preset.json +27 -0
  76. package/dist/presets/mcp/remotion/prompt.md +7 -0
  77. package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
  78. package/dist/presets.js +727 -0
  79. package/dist/prompt-files.js +103 -0
  80. package/dist/prompt-text.js +41 -0
  81. package/dist/proxy.js +137 -0
  82. package/dist/quality/ast.js +196 -0
  83. package/dist/quality/cognitive.js +83 -0
  84. package/dist/quality/cyclomatic.js +30 -0
  85. package/dist/quality/halstead.js +83 -0
  86. package/dist/quality/index.js +18 -0
  87. package/dist/quality/length.js +3 -0
  88. package/dist/quality/maintainability.js +15 -0
  89. package/dist/quality/nesting.js +57 -0
  90. package/dist/quality/report.js +803 -0
  91. package/dist/quality/smells.js +67 -0
  92. package/dist/quality-tool.js +63 -0
  93. package/dist/read-only-commands.js +597 -0
  94. package/dist/repo-map.js +292 -0
  95. package/dist/roblox-connect.js +125 -0
  96. package/dist/rpc.js +91 -0
  97. package/dist/rtk.js +193 -0
  98. package/dist/sandbox.js +105 -0
  99. package/dist/schema.js +214 -0
  100. package/dist/serve.js +50 -0
  101. package/dist/session-lifecycle.js +19 -0
  102. package/dist/session-retention.js +111 -0
  103. package/dist/session.js +460 -0
  104. package/dist/sessions.js +100 -0
  105. package/dist/settings.js +148 -0
  106. package/dist/skills.js +35 -0
  107. package/dist/slop-tool.js +96 -0
  108. package/dist/spend-cap.js +43 -0
  109. package/dist/subagent.js +171 -0
  110. package/dist/tar.js +146 -0
  111. package/dist/theme.js +79 -0
  112. package/dist/thinking.js +45 -0
  113. package/dist/todo.js +114 -0
  114. package/dist/tool-loadout.js +102 -0
  115. package/dist/tool-render.js +769 -0
  116. package/dist/tools.js +176 -0
  117. package/dist/ui.js +4931 -0
  118. package/dist/update.js +551 -0
  119. package/dist/usage-segment.js +74 -0
  120. package/dist/web-tools.js +46 -0
  121. package/dist/web.js +265 -0
  122. package/dist/welcome.js +179 -0
  123. package/dist/workflow-engine.js +246 -0
  124. package/dist/workflow-progress.js +446 -0
  125. package/dist/workflow-view.js +195 -0
  126. package/dist/workflow.js +349 -0
  127. package/package.json +62 -15
  128. package/dist/chunk-27TRKD65.js +0 -2
  129. package/dist/chunk-3FYCFLNK.js +0 -1
  130. package/dist/chunk-3LZR7OEU.js +0 -1
  131. package/dist/chunk-56SEAGAQ.js +0 -1
  132. package/dist/chunk-5AWTL34S.js +0 -1
  133. package/dist/chunk-75P2SCOT.js +0 -1
  134. package/dist/chunk-7CHDNTH6.js +0 -1
  135. package/dist/chunk-7PHRNXLN.js +0 -34
  136. package/dist/chunk-AGGIYXN7.js +0 -2
  137. package/dist/chunk-AUDMRGWF.js +0 -189
  138. package/dist/chunk-C5E3SFOQ.js +0 -2
  139. package/dist/chunk-CMTKCUL5.js +0 -1
  140. package/dist/chunk-DAPXGSGD.js +0 -12
  141. package/dist/chunk-DOD7MUGX.js +0 -1
  142. package/dist/chunk-FPXVDP32.js +0 -8
  143. package/dist/chunk-GXB7RZHH.js +0 -1
  144. package/dist/chunk-I7KVLBMF.js +0 -2
  145. package/dist/chunk-ILDDDLRC.js +0 -1
  146. package/dist/chunk-JFYVRWJI.js +0 -2
  147. package/dist/chunk-LAHVHRCS.js +0 -2
  148. package/dist/chunk-LSJC73DG.js +0 -1
  149. package/dist/chunk-MNDKS6UH.js +0 -6
  150. package/dist/chunk-N345KWWK.js +0 -1
  151. package/dist/chunk-N3J2J7EQ.js +0 -5
  152. package/dist/chunk-ONZLNFQ2.js +0 -3
  153. package/dist/chunk-ORJ2QZBG.js +0 -3
  154. package/dist/chunk-PCALBZOO.js +0 -2
  155. package/dist/chunk-QPHSHL7H.js +0 -2
  156. package/dist/chunk-TKROS3BE.js +0 -5
  157. package/dist/chunk-TSEKPGK4.js +0 -1
  158. package/dist/chunk-UP7ENLEL.js +0 -117
  159. package/dist/chunk-UTYFLLSM.js +0 -1
  160. package/dist/chunk-V3R4FYTJ.js +0 -1
  161. package/dist/chunk-V656XVXV.js +0 -8
  162. package/dist/chunk-WKUJ7CFO.js +0 -1
  163. package/dist/chunk-WOT6VMZA.js +0 -1
  164. package/dist/chunk-WUPU6LTD.js +0 -4
  165. package/dist/chunk-WX3V5P7U.js +0 -5
  166. package/dist/chunk-XW6LMQGX.js +0 -1
  167. package/dist/chunk-YW7NORH3.js +0 -2
@@ -0,0 +1,111 @@
1
+ import { readdir, rmdir, stat, unlink } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { RETENTION_DAYS } from "./checkpoint.js";
4
+ import { AGENT_DIR } from "./paths.js";
5
+ /**
6
+ * Unlink session files idle for longer than `RETENTION_DAYS`, across every
7
+ * project directory — the store's whole problem is that it is per-cwd and
8
+ * nothing ever looked outside the current one.
9
+ *
10
+ * The SDK writes `<agentDir>/sessions/--<encoded-cwd>--/<timestamp>_<id>.jsonl`
11
+ * and never removes one: `SessionManager` only appends, the TUI's delete is a
12
+ * single user-chosen session (and prefers `trash`), and the CLI had no sweep at
13
+ * all, so a machine that uses the product for a year keeps a transcript of every
14
+ * folder it was ever pointed at. Rewind's shadow store already sweeps at 30 days
15
+ * (checkpoint.ts, `RETENTION_DAYS`) — same window here on purpose, so "old" means
16
+ * one thing across both stores and a session whose snapshots are gone does not
17
+ * linger as a transcript.
18
+ *
19
+ * Age is the file's mtime, not the timestamp in its name: a session resumed last
20
+ * week was written last week and must survive, while its filename still says the
21
+ * day it was created. `keep` is the stronger rule on top of that — `--continue`
22
+ * and `/resume` exist to bring an old session back, so the file this process just
23
+ * opened is kept whatever its age.
24
+ *
25
+ * Housekeeping, so it never throws and never reports to the user: every step is
26
+ * per-file, an error only leaves the store larger than it needed to be. Files are
27
+ * unlinked rather than trashed — the TUI's `trash` path is a person deleting one
28
+ * session they chose, this is a bulk sweep they did not ask to watch.
29
+ */
30
+ export async function sweepStaleSessions(options = {}) {
31
+ const result = { removed: [], failed: [], removedDirs: [] };
32
+ const root = join(options.agentDir ?? AGENT_DIR, "sessions");
33
+ const cutoff = (options.now ?? Date.now()) - RETENTION_DAYS * 24 * 60 * 60 * 1000;
34
+ const keep = options.keep ? resolve(options.keep) : undefined;
35
+ let projects;
36
+ try {
37
+ projects = await readdir(root, { withFileTypes: true });
38
+ }
39
+ catch {
40
+ // No store, or an unreadable one: nothing to sweep and nothing to say.
41
+ return result;
42
+ }
43
+ for (const project of projects) {
44
+ if (!project.isDirectory())
45
+ continue;
46
+ const dir = join(root, project.name);
47
+ let files;
48
+ try {
49
+ files = await readdir(dir);
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ // What will still be in the directory when the sweep is done. Sidecars a
55
+ // stale session left behind (`plan.md`, `plan_mode.json`) count and keep the
56
+ // directory on purpose: they are project state a later session of the same
57
+ // folder still restores.
58
+ const surviving = files.filter((file) => !file.endsWith(".jsonl"));
59
+ for (const file of files) {
60
+ if (!file.endsWith(".jsonl"))
61
+ continue;
62
+ const path = join(dir, file);
63
+ let stale;
64
+ try {
65
+ if (keep && resolve(path) === keep)
66
+ stale = false;
67
+ else {
68
+ // mtime, not the timestamp in the name: a session resumed last week
69
+ // was written last week, whatever its filename says.
70
+ stale = (await stat(path)).mtimeMs < cutoff;
71
+ }
72
+ }
73
+ catch {
74
+ // Raced with another sweeper (two processes starting at once).
75
+ stale = false;
76
+ }
77
+ if (!stale) {
78
+ surviving.push(file);
79
+ continue;
80
+ }
81
+ try {
82
+ await unlink(path);
83
+ result.removed.push(path);
84
+ }
85
+ catch {
86
+ // A directory called `x.jsonl`, a permission wall, a file another
87
+ // process holds oddly: count it and keep going.
88
+ result.failed.push(path);
89
+ surviving.push(file);
90
+ }
91
+ }
92
+ // Drop the per-project directory when nothing is left in it, so the encoded
93
+ // cwd names stop piling up too.
94
+ if (surviving.length === 0) {
95
+ // Never drop the directory the live session writes into. On a fresh start
96
+ // the session file does not exist yet, so the directory looks empty and
97
+ // the sweep removed it — the next prompt then failed with ENOENT because
98
+ // the writer's parent had been deleted underneath it.
99
+ if (keep && dirname(keep) === dir)
100
+ continue;
101
+ try {
102
+ await rmdir(dir);
103
+ result.removedDirs.push(dir);
104
+ }
105
+ catch {
106
+ // Non-empty after all, or racing a writer: the directory stays.
107
+ }
108
+ }
109
+ }
110
+ return result;
111
+ }
@@ -0,0 +1,460 @@
1
+ import { join } from "node:path";
2
+ import { hostname } from "node:os";
3
+ import { createAgentSession, createCodemodeExtension, createMcpExtension, DefaultResourceLoader, ProjectTrustStore, SessionManager, SettingsManager } from "@netrodev/hxf-coding-agent";
4
+ import { APP_VERSION, SYSTEM_APPEND, scrubHarnessMessage } from "./brand.js";
5
+ import { promptAppendBlocks } from "./prompt-files.js";
6
+ import { createBrandExtension } from "./brand-extension.js";
7
+ import { createHooksExtension } from "./hooks-extension.js";
8
+ import { AGENT_DIR, additionalSkillPaths } from "./paths.js";
9
+ import { readCliCredential, renewCliCredentialIfNeeded } from "./cli-credentials.js";
10
+ import { createPermissionExtension } from "./permissions.js";
11
+ import { loadMcpConfiguration } from "./mcp-config.js";
12
+ /** Session entry type the running build stamps itself under. */
13
+ export const BUILD_ENTRY = "howtofix.build";
14
+ import { createRtkExtension } from "./rtk.js";
15
+ import { readWorkbenchSettings } from "./settings.js";
16
+ import { askTool, claimTool, sessionsTool, setClaimsContext, thinkTool } from "./tools.js";
17
+ import { repoMapTool, setRepoRoot } from "./repo-map.js";
18
+ import { SESSIONS_DIR_NAME, PRESENCE_TTL_MS, announce, withdraw } from "./sessions.js";
19
+ import { todoTool } from "./todo.js";
20
+ import { contextTool } from "./notes.js";
21
+ import { goalTool } from "./goal.js";
22
+ import { createPlanGateExtension, createPlanPromptExtension, enterPlanModeTool, exitPlanModeTool } from "./plan-mode.js";
23
+ import { createCompactionGuardExtension } from "./compaction-guard.js";
24
+ import { createGoalExtension } from "./goal-extension.js";
25
+ import { createContextExtension } from "./context-extension.js";
26
+ import { createPresetExtension } from "./preset-extension.js";
27
+ import { createDcpExtension, createCompressTool, getDcpState, activeDcpSession, snapshotFor } from "./dcp/index.js";
28
+ import { createWorkflowTool } from "./workflow.js";
29
+ import { createTaskTool } from "./subagent.js";
30
+ import { loadAgentDefs } from "./agent-defs.js";
31
+ import { agents } from "./agents.js";
32
+ import { defaultModelFor, resolveModel } from "./model-resolve.js";
33
+ import { HOWTOFIX_PROVIDERS } from "./howtofix-account.js";
34
+ import { activateExtraBuiltins, settleExtensionTools } from "./tool-loadout.js";
35
+ import { webFetchTool, webSearchTool } from "./web-tools.js";
36
+ import { lspTool } from "./lsp-tools.js";
37
+ import { qualityTool } from "./quality-tool.js";
38
+ import { slopTool } from "./slop-tool.js";
39
+ import { disposeAllLsp } from "./lsp.js";
40
+ import { disposeAgentSession } from "./session-lifecycle.js";
41
+ import { sweepStaleSessions } from "./session-retention.js";
42
+ /**
43
+ * Every tool this product offers, as documentation of the surface.
44
+ *
45
+ * The loadout is NOT applied from here: it is the exclusion in `toolLoadout()`
46
+ * plus the activation in `tool-loadout.ts`. An allowlist was tried and removed —
47
+ * it applies to *every* registered tool including extension ones, so it discarded
48
+ * `mcp__<server>__<tool>` names that cannot be known in advance and undid the MCP
49
+ * extension's `codemode` activation. See the decisions log.
50
+ *
51
+ * It is deliberately not exported or referenced: if it ever becomes the thing
52
+ * that decides the loadout again, the MCP regressions come back. The active set is
53
+ * asserted by `scripts/check-tool-loadout.mjs`.
54
+ */
55
+ export const MAIN_TOOLS = [
56
+ "read",
57
+ "repo_map",
58
+ "bash",
59
+ "edit",
60
+ "write",
61
+ "grep",
62
+ "find",
63
+ "ls",
64
+ "bash_output",
65
+ "kill_shell",
66
+ "task",
67
+ "think",
68
+ "ask",
69
+ "claim",
70
+ "sessions",
71
+ "todo",
72
+ "context",
73
+ "goal",
74
+ "enter_plan_mode",
75
+ "exit_plan_mode",
76
+ "workflow",
77
+ "web_search",
78
+ "web_fetch",
79
+ "lsp",
80
+ "quality",
81
+ "compress",
82
+ "slop",
83
+ ];
84
+ /**
85
+ * The tool loadout for this platform, expressed as an exclusion rather than an
86
+ * allowlist.
87
+ *
88
+ * `tools:` is an allowlist that filters *every* registered tool, extension ones
89
+ * included (`_refreshToolRegistry` → `_isAllowedTool`), so naming only our own
90
+ * tools silently drops any tool an extension registers later under a name we
91
+ * cannot know — `mcp__<server>__<tool>` above all. Measured: an MCP server
92
+ * connected, `/mcp` reported "14 tools", and the model could call none of them
93
+ * because the allowlist had already discarded every one.
94
+ *
95
+ * `codemode` was the same bug in miniature. The MCP extension activates it at
96
+ * `session_start` whenever a server's tools are only reachable from scripts (the
97
+ * default `exposure: "codemode"`), but the allowlist filter runs when the tool
98
+ * registry is rebuilt, after that activation, so a static list undid it — and the
99
+ * user got "MCP tools are only reachable from the codemode or tool_search tool,
100
+ * but neither is active" on a fully connected server.
101
+ *
102
+ * Without `tools:`, the SDK enables its default set plus everything extensions
103
+ * register, and activation sticks. The one piece of policy left — the SDK's
104
+ * `powershell` tool, which throws "only available on Windows" everywhere else,
105
+ * while `bash` resolves to Git Bash that Windows does not ship — becomes an
106
+ * exclusion.
107
+ */
108
+ const toolLoadout = () => ({
109
+ exclude: process.platform === "win32" ? [] : ["powershell"],
110
+ });
111
+ /**
112
+ * Every way the CLI can start — the workbench, `--print`, `-c`, `--session-path`
113
+ * — reaches `openSession`, so the session-file retention sweep hangs here rather
114
+ * than in the workbench alone: a scripted run is exactly the thing that used to
115
+ * leave a transcript behind. Once per process, because a `/new` or `/resume`
116
+ * swap opens another session in the same process and the store does not need a
117
+ * second walk to know it is stale.
118
+ */
119
+ let sessionsSwept = false;
120
+ export async function openSession(options) {
121
+ // Trust is a decision the user made about this directory, not a constant.
122
+ //
123
+ // This used to pass `projectTrusted: true` unconditionally, which loaded the
124
+ // project's own settings, `SYSTEM.md`, `APPEND_SYSTEM.md` and extensions from
125
+ // `.hxf/` in whatever directory the CLI was started in. Cloning a repository
126
+ // and running `howtofix` in it was therefore enough to replace the system
127
+ // prompt: a `.hxf/SYSTEM.md` saying "ignore all previous instructions" was
128
+ // read as trusted configuration. The store is what the harness consults, and
129
+ // the answer is only "yes" for a directory the user trusted.
130
+ const trustStore = new ProjectTrustStore(AGENT_DIR);
131
+ const settingsManager = SettingsManager.create(options.cwd, AGENT_DIR, {
132
+ projectTrusted: trustStore.get(options.cwd) === true,
133
+ });
134
+ // Read once at open and refreshed by the slash command. The extension consults this
135
+ // variable per tool result, so a change applies immediately without reopening the
136
+ // session — which is what a toggle should do.
137
+ let rtkEnabled = readWorkbenchSettings(AGENT_DIR).compressToolOutput;
138
+ const sessionManager = options.sessionPath ? SessionManager.open(options.sessionPath) : options.noSession ? SessionManager.inMemory(options.cwd) : options.continueSession ? SessionManager.continueRecent(options.cwd) : SessionManager.create(options.cwd);
139
+ // A session written by a different build is a compatibility question the user
140
+ // should not have to guess at. The file header records the *format* version
141
+ // (the SDK's), not the product's, so a session from another release loads
142
+ // silently and any behaviour that changed between them reads as a bug. Read
143
+ // the stamp the previous build left and say when it differs.
144
+ // Read the stamp off the session that was opened, whatever route opened it:
145
+ // `--continue` and `/resume` reach the same file as `--session`, and keying
146
+ // this on `sessionPath` alone meant the warning never fired for the two ways
147
+ // a user actually returns to an old session.
148
+ const buildNote = (() => {
149
+ try {
150
+ const previous = sessionManager.getEntries().find((entry) => entry.type === "custom" && entry.customType === BUILD_ENTRY);
151
+ const was = previous?.data?.version;
152
+ if (!was || was === APP_VERSION)
153
+ return undefined;
154
+ return `this session was written by HowToFix ${was}; you are running ${APP_VERSION}`;
155
+ }
156
+ catch {
157
+ // An unreadable or older file is not a reason to refuse to open it.
158
+ return undefined;
159
+ }
160
+ })();
161
+ const resourceLoader = new DefaultResourceLoader({
162
+ cwd: options.cwd,
163
+ agentDir: AGENT_DIR,
164
+ settingsManager,
165
+ appendSystemPrompt: promptAppendBlocks(SYSTEM_APPEND, options.interactive ?? true),
166
+ // The SDK discovers `<agentDir>/skills` itself through the package manager,
167
+ // and the project dir is its own `.hxf`, so both the user's
168
+ // `.howtofix/skills` and the bundled suite are handed over here. The user's
169
+ // dir comes first (first-wins on a name collision), and the bundled suite
170
+ // drops out of the list entirely when the antislop switch resolves off.
171
+ additionalSkillPaths: additionalSkillPaths(options.cwd),
172
+ extensionFactories: [
173
+ // The plan gate must come first: the SDK returns on the first `block`, so
174
+ // registering it ahead of the approval gate is what makes plan mode hold in
175
+ // every approval mode, `full-auto` included.
176
+ createPlanGateExtension(),
177
+ createPermissionExtension(),
178
+ createBrandExtension(),
179
+ createHooksExtension(),
180
+ // DCP sits between brand and the notes, and both neighbours matter.
181
+ // Before brand and the id tags it writes into the prompt are scrubbed
182
+ // back out; after context-extension and the shared notes become keep-set
183
+ // candidates and get pruned. `check-dcp.mjs` asserts the order with three
184
+ // handlers that overwrite each other — acceptance covers it, but if you
185
+ // move this line, that check fails and it is right to fail.
186
+ createDcpExtension(),
187
+ createContextExtension(),
188
+ // After the notes so a preset's rules are appended last, and after brand
189
+ // so the harness scrub has already run over the sections it owns.
190
+ createPresetExtension(options.cwd),
191
+ createGoalExtension(),
192
+ createPlanPromptExtension(),
193
+ createCompactionGuardExtension(),
194
+ createCodemodeExtension(),
195
+ // Reads the setting per tool result, so `/rtk on` takes effect on the next tool
196
+ // call rather than on the next process.
197
+ createRtkExtension({ enabled: () => rtkEnabled }),
198
+ // A loader that re-reads `mcp.json` each time the extension asks, so a
199
+ // server removed from the preset menu is seen as gone and its transport is
200
+ // closed. The SDK reads the file once on `session_start` otherwise, which
201
+ // left the removed server's process running until the CLI exited.
202
+ createMcpExtension({
203
+ loadConfig: (ctx) => loadMcpConfiguration({
204
+ cwd: ctx.cwd,
205
+ projectTrusted: ctx.isProjectTrusted?.() ?? false,
206
+ }),
207
+ }),
208
+ ],
209
+ });
210
+ await resourceLoader.reload();
211
+ // Subagent definitions the user wrote, read before the session is built so the
212
+ // `task` tool's description can name them: a definition the model is never told
213
+ // about is a definition that does not exist. Problems are reported as notes
214
+ // rather than thrown — a typo in one file must not stop the CLI from opening.
215
+ const agentDefs = loadAgentDefs(options.cwd, agents);
216
+ // Built after the definitions are in the map, so the description names them.
217
+ const taskTool = createTaskTool();
218
+ // Claims are held per session and written under the session's workspace. The
219
+ // holder id has to be stable for the whole session — a claim that cannot be
220
+ // renewed or released by the agent that took it is worse than no claim — so it
221
+ // is derived from the session the manager opened, not from the process.
222
+ const sessionId = sessionManager.getSessionId();
223
+ setRepoRoot(() => options.cwd);
224
+ const presenceDir = join(AGENT_DIR, SESSIONS_DIR_NAME);
225
+ setClaimsContext({
226
+ root: () => options.cwd,
227
+ holder: () => `session:${sessionId}`,
228
+ presenceDir: () => presenceDir,
229
+ selfId: () => sessionId,
230
+ });
231
+ // Announce this session so another one in the same workspace can see it and ask
232
+ // for a file it holds. Heartbeat, not a one-shot write: liveness is decided by the
233
+ // freshness of the file, so a session that is killed drops out on its own without
234
+ // anything having to reap it.
235
+ const beat = () => {
236
+ try {
237
+ announce({ dir: presenceDir }, { id: sessionId, cwd: options.cwd, host: hostname(), pid: process.pid, at: Date.now() });
238
+ }
239
+ catch {
240
+ // A session that cannot write its presence still works; it is simply invisible
241
+ // to the others. Failing the session over this would be the worse trade.
242
+ }
243
+ };
244
+ beat();
245
+ const heartbeat = setInterval(beat, Math.max(5_000, Math.floor(PRESENCE_TTL_MS / 3)));
246
+ // A background interval must not hold the process open on its own.
247
+ heartbeat.unref?.();
248
+ const result = await createAgentSession({
249
+ cwd: options.cwd,
250
+ agentDir: AGENT_DIR,
251
+ settingsManager,
252
+ sessionManager,
253
+ resourceLoader,
254
+ excludeTools: toolLoadout().exclude,
255
+ customTools: [
256
+ taskTool,
257
+ repoMapTool,
258
+ thinkTool,
259
+ askTool,
260
+ claimTool,
261
+ sessionsTool,
262
+ todoTool,
263
+ contextTool,
264
+ goalTool,
265
+ enterPlanModeTool,
266
+ exitPlanModeTool,
267
+ createWorkflowTool(),
268
+ webSearchTool,
269
+ webFetchTool,
270
+ lspTool,
271
+ qualityTool,
272
+ slopTool,
273
+ // The compress tool resolves its boundaries against the ref snapshot the
274
+ // handler published on the last prompt pass. Registering it here rather
275
+ // than inside the extension is the pattern this file uses for every
276
+ // custom tool, and the SDK enables a custom tool only when it is named in
277
+ // `tools` too - which is why `compress` also appears in MAIN_TOOLS.
278
+ createCompressTool({
279
+ state: () => getDcpState(activeDcpSession() ?? "dcp-sessionless"),
280
+ messages: () => snapshotFor(activeDcpSession() ?? "dcp-sessionless"),
281
+ }),
282
+ ],
283
+ thinkingLevel: options.thinking,
284
+ });
285
+ // Activate the built-in search tools the product relies on.
286
+ //
287
+ // The harness now enables `grep`, `find` and `ls` by default, so this is a
288
+ // belt-and-braces pass rather than the only thing making them callable: the
289
+ // product names its active set explicitly, and a harness default that changes
290
+ // again must not silently cost the model tools that `permissions.ts` carries
291
+ // approval tiers for and `agents.ts` tells every subagent to use. Before the
292
+ // harness default existed, the model reported `read, bash, edit, write, task, …`
293
+ // and answered "NO GREP TOOL" while the implementations sat in the registry.
294
+ //
295
+ // This has to happen here, through the public session API, and NOT in an
296
+ // extension. An extension's `session_start` handler runs inside
297
+ // `bindExtensions()`, which the SDK follows with `_refreshToolRegistry()` — so
298
+ // anything `setActiveTools` did there is silently overwritten before the first
299
+ // turn. Measured: the extension ran, `setActiveTools` was called, and the
300
+ // active set was still `bash, edit, read, write`.
301
+ //
302
+ // Bind the extensions here, once, for every way the CLI can be started.
303
+ //
304
+ // `bindExtensions()` is what emits `session_start`, and `session_start` is where
305
+ // extensions do their work: the MCP extension connects its servers and turns on
306
+ // `codemode`, the hooks extension installs its handlers. It used to be called
307
+ // only from the workbench's `bindUI`, so a `--print` run (`-p`, which is what
308
+ // scripts and CI use) never emitted `session_start` at all: MCP servers were
309
+ // never connected, hooks never ran, and `codemode` was inactive.
310
+ //
311
+ // The mode is `"print"` so an extension asks through text instead of opening an
312
+ // overlay the workbench cannot host. `uiContext` is passed when a caller has
313
+ // one (the workbench does, after this returns, via `bindUI`); here it is the
314
+ // dialog-free context the SDK expects for non-interactive runs.
315
+ //
316
+ // This runs BEFORE `activateExtraBuiltins`: `bindExtensions` ends in the SDK's
317
+ // `_refreshToolRegistry()`, which resets the active set, so activating first
318
+ // would be undone.
319
+ await result.session.bindExtensions({ mode: "print", onError: () => { } });
320
+ // A short-lived access token has to be renewed before it is used, and this is
321
+ // the one place every way of starting the CLI goes through — the workbench
322
+ // renews again before each prompt, but `--print` never touches the workbench,
323
+ // so without this a scripted run on an expired token fails on its first call
324
+ // with nothing to explain it.
325
+ //
326
+ // A terminal refusal is reported as a note rather than thrown: the stored key
327
+ // may still work (the panel could be briefly unreachable, or the local clock
328
+ // could be ahead), and refusing to start would turn a recoverable state into a
329
+ // dead CLI.
330
+ const storedCredential = readCliCredential(AGENT_DIR);
331
+ const credentialNotes = [];
332
+ if (storedCredential) {
333
+ const renewed = await renewCliCredentialIfNeeded(AGENT_DIR, storedCredential);
334
+ if (renewed.renewed) {
335
+ await result.session.modelRuntime.setRuntimeApiKey("howtofix", renewed.credential.accessToken);
336
+ }
337
+ else if (renewed.terminal && renewed.message) {
338
+ credentialNotes.push(renewed.message);
339
+ }
340
+ else if (renewed.message) {
341
+ credentialNotes.push(renewed.message);
342
+ }
343
+ }
344
+ // `session_start` handlers are async and the runner does not await them, so
345
+ // give the ones that register tools (MCP's `codemode`) a moment to land before
346
+ // the first turn reads the loadout. This is bounded and only waits while the
347
+ // work is still in flight.
348
+ await settleExtensionTools(result.session);
349
+ // Only now, on the settled set, add the built-ins the SDK leaves inactive.
350
+ activateExtraBuiltins(result.session);
351
+ let model = result.session.model;
352
+ if (options.model) {
353
+ const available = await result.session.modelRuntime.getAvailable();
354
+ const requested = resolveModel(available, options.model);
355
+ if (!requested) {
356
+ await disposeAgentSession(result.session);
357
+ throw new Error(`unknown model '${options.model}'`);
358
+ }
359
+ if (model?.id !== requested.id || model.provider !== requested.provider) {
360
+ await result.session.setModel(requested);
361
+ model = result.session.model;
362
+ }
363
+ }
364
+ else {
365
+ // Nothing was asked for, so the runtime's own default stands — and that
366
+ // default is the catalogue's, not this account's. The runtime ships a
367
+ // built-in provider catalogue, so a fresh session can start on
368
+ // `opencode/kimi-k2.6`, a provider with no credential here: the first prompt
369
+ // then goes nowhere and comes back as an empty assistant message, with
370
+ // nothing in the transcript to say why it looks hung. Start on a model the
371
+ // account can actually reach instead, preferring HowToFix's own providers.
372
+ //
373
+ // Available is not the same as reachable — the catalogue lists every provider
374
+ // it knows — so each provider is asked for its credential, once.
375
+ const available = await result.session.modelRuntime.getAvailable();
376
+ const withKey = new Map();
377
+ const reachable = [];
378
+ for (const entry of available) {
379
+ let ok = withKey.get(entry.provider);
380
+ if (ok === undefined) {
381
+ ok = Boolean((await result.session.modelRuntime.getAuth(entry.provider))?.auth?.apiKey);
382
+ withKey.set(entry.provider, ok);
383
+ }
384
+ if (ok)
385
+ reachable.push(entry);
386
+ }
387
+ // A credential in the runtime is not proof the provider answers: the built-in
388
+ // catalogue carries providers whose key is absent at request time, which is
389
+ // how a session ends up on `opencode/kimi-k2.6` and gets a 401 with nothing
390
+ // in the transcript. So the account's own provider wins whenever it has any
391
+ // model at all — an install signed in to HowToFix should run HowToFix models.
392
+ const ownProviders = HOWTOFIX_PROVIDERS;
393
+ const startedOnOwn = ownProviders.includes(model?.provider ?? "");
394
+ if (!startedOnOwn) {
395
+ const chosen = defaultModelFor(reachable.filter((entry) => ownProviders.includes(entry.provider)), ownProviders);
396
+ if (chosen) {
397
+ await result.session.setModel(chosen);
398
+ model = result.session.model;
399
+ }
400
+ }
401
+ }
402
+ // Stamp this build into a session it is allowed to write. A read-only open
403
+ // (`--print`, a resumed file) is left untouched: the stamp belongs to the
404
+ // build that owns the session, not to whoever glanced at it.
405
+ if (!options.noSession && sessionManager.getEntries().every((entry) => entry.type !== "custom" || entry.customType !== BUILD_ENTRY)) {
406
+ try {
407
+ sessionManager.appendCustomEntry(BUILD_ENTRY, { version: APP_VERSION });
408
+ }
409
+ catch {
410
+ // A session that cannot be written (read-only file, full disk) still works.
411
+ }
412
+ }
413
+ // Retention sweep, once per process and off the critical path: session files
414
+ // under `~/.howtofix/agent/sessions/` only ever grow — the SDK appends and
415
+ // never prunes, and the TUI's delete is one session a person picked — so files
416
+ // idle for longer than RETENTION_DAYS are unlinked for *every* project, not
417
+ // just this one. The file just opened is kept whatever its age: `--continue`
418
+ // and `/resume` exist to bring an old session back, and a sweep that ate its
419
+ // own argument would break them. Not awaited — startup must not pay for a walk
420
+ // of every session directory, and a failed sweep leaves the store merely
421
+ // larger. See session-retention.ts.
422
+ if (!sessionsSwept) {
423
+ sessionsSwept = true;
424
+ void sweepStaleSessions({ keep: result.session.sessionFile }).catch(() => {
425
+ // Housekeeping: the store is larger, not wrong.
426
+ });
427
+ }
428
+ const modelLabel = model ? `${model.provider}/${model.id}` : "no model";
429
+ // A model with no declared rates reports $0 forever, which is what makes a
430
+ // budget unenforceable on it. See `priced` on `OpenedSession`.
431
+ const priced = Boolean(model && model.cost && (model.cost.input > 0 || model.cost.output > 0));
432
+ const notes = [scrubHarnessMessage(result.modelFallbackMessage), ...credentialNotes, ...agentDefs.notes, ...agentDefs.problems].filter((note) => Boolean(note));
433
+ if (!model) {
434
+ notes.push(`no model configured. run \`howtofix\` and use /login, or edit ${join(AGENT_DIR, "models.json")}`);
435
+ }
436
+ return {
437
+ session: result.session,
438
+ dispose: async () => {
439
+ // The lsp client pool is process-wide and shared with subagents; the main
440
+ // session is the owner that tears it down (process `exit` is the backstop).
441
+ disposeAllLsp();
442
+ // Await the extension shutdown so MCP servers stop before the session
443
+ // that hosts their tools goes away.
444
+ await disposeAgentSession(result.session);
445
+ // Take this session out of the registry on a clean exit. A crash is covered
446
+ // by the heartbeat going stale, so this only makes the common case immediate.
447
+ clearInterval(heartbeat);
448
+ withdraw({ dir: presenceDir }, sessionId);
449
+ },
450
+ modelLabel,
451
+ priced,
452
+ // Exposed so the `/rtk` command can flip the flag the extension reads. Passing a
453
+ // setter rather than the variable keeps the two in one place: there is no way to
454
+ // change the setting without changing what the extension sees.
455
+ setCompressToolOutput: (on) => {
456
+ rtkEnabled = on;
457
+ },
458
+ note: [buildNote, ...notes].filter(Boolean).join("\n") || undefined,
459
+ };
460
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A registry of other HowToFix CLI sessions on this machine.
3
+ *
4
+ * One process can only see the agents it started. Two sessions open on the same
5
+ * project — the user in one terminal and a long run in another — are invisible to each
6
+ * other, so neither can see what the other is holding, and a file claim taken by one
7
+ * cannot be explained to the other. This module is the smallest thing that closes that
8
+ * gap: each session writes one file describing itself, and any session can read them
9
+ * all.
10
+ *
11
+ * File-based for the same reason claims are: the sessions are separate processes, so
12
+ * there is no shared object to read and a broker would need something running. A file
13
+ * per session is what all of them can agree on without a daemon.
14
+ *
15
+ * A session that crashed must not appear as live. Liveness is the freshness of the
16
+ * heartbeat, not the presence of the file, so a killed process drops out on its own
17
+ * after {@link PRESENCE_TTL_MS} without anything having to clean up.
18
+ */
19
+ import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ /** How long a session stays "live" without refreshing its file. */
22
+ export const PRESENCE_TTL_MS = 3 * 60 * 1000;
23
+ /**
24
+ * Directory under the agent dir holding one file per live session.
25
+ *
26
+ * Deliberately not `sessions`: `session-retention.ts` owns that one and sweeps it,
27
+ * so a presence file written there would sit among per-project session files and — far
28
+ * worse — be a candidate for its sweep. A live session whose presence is deleted looks
29
+ * to every other session like one that died.
30
+ */
31
+ export const SESSIONS_DIR_NAME = "presence";
32
+ function file(options, id) {
33
+ // The id comes from the session, not from user input, but a path separator in it
34
+ // would still write the file somewhere else, so it is stripped rather than trusted.
35
+ return join(options.dir, `${id.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
36
+ }
37
+ /**
38
+ * Read every session's file, dropping the ones that are stale or unreadable.
39
+ *
40
+ * Unreadable means "not there": a half-written file from a session that is starting up
41
+ * must not make a reader fail, and a reader that cannot list sessions is worse than one
42
+ * that lists fewer.
43
+ */
44
+ export function listSessions(options, now = Date.now()) {
45
+ let names;
46
+ try {
47
+ names = readdirSync(options.dir);
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ const out = [];
53
+ for (const name of names) {
54
+ if (!name.endsWith(".json"))
55
+ continue;
56
+ try {
57
+ const raw = JSON.parse(readFileSync(join(options.dir, name), "utf8"));
58
+ if (typeof raw?.id !== "string" || typeof raw?.cwd !== "string" || typeof raw?.at !== "number")
59
+ continue;
60
+ if (now - raw.at >= PRESENCE_TTL_MS)
61
+ continue;
62
+ out.push({
63
+ id: raw.id,
64
+ cwd: raw.cwd,
65
+ host: typeof raw.host === "string" ? raw.host : "",
66
+ pid: typeof raw.pid === "number" ? raw.pid : 0,
67
+ at: raw.at,
68
+ ...(typeof raw.activity === "string" && raw.activity ? { activity: raw.activity } : {}),
69
+ });
70
+ }
71
+ catch {
72
+ // Starting up, or dying. Either way there is nothing to report yet.
73
+ }
74
+ }
75
+ // Newest first: a caller showing the list wants the busy sessions at the top.
76
+ return out.sort((a, b) => b.at - a.at);
77
+ }
78
+ /** Write or refresh one session's presence, replacing the file atomically. */
79
+ export function announce(options, presence) {
80
+ mkdirSync(options.dir, { recursive: true });
81
+ const target = file(options, presence.id);
82
+ const tmp = `${target}.${process.pid}.tmp`;
83
+ writeFileSync(tmp, JSON.stringify(presence, null, 2));
84
+ // A reader can be listing while this runs; rename means it sees either the old
85
+ // file or the new one, never a partial write it would have to skip.
86
+ renameSync(tmp, target);
87
+ }
88
+ /** Remove a session's file, on a clean exit. */
89
+ export function withdraw(options, id) {
90
+ try {
91
+ unlinkSync(file(options, id));
92
+ }
93
+ catch {
94
+ // Already gone, or never written. Nothing to undo.
95
+ }
96
+ }
97
+ /** The sessions working in one workspace, excluding this one if asked. */
98
+ export function sessionsIn(options, cwd, exceptId, now = Date.now()) {
99
+ return listSessions(options, now).filter((s) => s.cwd === cwd && s.id !== exceptId);
100
+ }