howtofixcli 0.7.14 → 0.7.15

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 (165) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -551
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/acp.js +0 -234
  48. package/dist/activity.js +0 -124
  49. package/dist/agent-defs.js +0 -237
  50. package/dist/agent-limiter.js +0 -68
  51. package/dist/agents.js +0 -554
  52. package/dist/anim.js +0 -361
  53. package/dist/args.js +0 -250
  54. package/dist/at-file.js +0 -28
  55. package/dist/brand-extension.js +0 -87
  56. package/dist/brand.js +0 -76
  57. package/dist/browser.js +0 -58
  58. package/dist/cache-report.js +0 -139
  59. package/dist/checkpoint.js +0 -437
  60. package/dist/claims.js +0 -167
  61. package/dist/cli-auth.js +0 -142
  62. package/dist/cli-checkin.js +0 -120
  63. package/dist/cli-credentials.js +0 -254
  64. package/dist/clipboard.js +0 -149
  65. package/dist/collapse.js +0 -129
  66. package/dist/commands.js +0 -294
  67. package/dist/compaction-guard.js +0 -81
  68. package/dist/context-extension.js +0 -17
  69. package/dist/dcp/commands.js +0 -172
  70. package/dist/dcp/compress-state.js +0 -139
  71. package/dist/dcp/compress.js +0 -329
  72. package/dist/dcp/dcp-extension.js +0 -295
  73. package/dist/dcp/dedup.js +0 -93
  74. package/dist/dcp/ids.js +0 -189
  75. package/dist/dcp/index.js +0 -25
  76. package/dist/dcp/middle-prune.js +0 -76
  77. package/dist/dcp/prompts.js +0 -68
  78. package/dist/dcp/protected-content.js +0 -221
  79. package/dist/dcp/protected.js +0 -161
  80. package/dist/dcp/prune.js +0 -418
  81. package/dist/dcp/state.js +0 -176
  82. package/dist/device-id.js +0 -90
  83. package/dist/device-id.test.js +0 -23
  84. package/dist/errors.js +0 -63
  85. package/dist/extensions.js +0 -48
  86. package/dist/fatal.js +0 -35
  87. package/dist/feed.js +0 -498
  88. package/dist/fs-atomic.js +0 -54
  89. package/dist/gateway-errors.js +0 -90
  90. package/dist/git.js +0 -230
  91. package/dist/goal-extension.js +0 -88
  92. package/dist/goal.js +0 -222
  93. package/dist/hooks-extension.js +0 -176
  94. package/dist/hooks.js +0 -443
  95. package/dist/howtofix-account.js +0 -735
  96. package/dist/hud-line.js +0 -153
  97. package/dist/hwid.js +0 -130
  98. package/dist/interrupt.js +0 -60
  99. package/dist/json-events.js +0 -50
  100. package/dist/level-style.js +0 -112
  101. package/dist/loop-guard.js +0 -95
  102. package/dist/lsp-tools.js +0 -32
  103. package/dist/lsp.js +0 -429
  104. package/dist/mcp-config.js +0 -85
  105. package/dist/mcp-lifecycle.js +0 -147
  106. package/dist/model-catalog.js +0 -217
  107. package/dist/model-facts.js +0 -35
  108. package/dist/model-resolve.js +0 -65
  109. package/dist/notes.js +0 -78
  110. package/dist/oauth-login.js +0 -568
  111. package/dist/paths.js +0 -61
  112. package/dist/permissions.js +0 -298
  113. package/dist/plan-mode.js +0 -363
  114. package/dist/preset-command.js +0 -227
  115. package/dist/preset-extension.js +0 -35
  116. package/dist/preset-mcp.js +0 -292
  117. package/dist/presets.js +0 -727
  118. package/dist/prompt-files.js +0 -103
  119. package/dist/prompt-text.js +0 -41
  120. package/dist/proxy.js +0 -137
  121. package/dist/quality/ast.js +0 -196
  122. package/dist/quality/cognitive.js +0 -83
  123. package/dist/quality/cyclomatic.js +0 -30
  124. package/dist/quality/halstead.js +0 -83
  125. package/dist/quality/index.js +0 -18
  126. package/dist/quality/length.js +0 -3
  127. package/dist/quality/maintainability.js +0 -15
  128. package/dist/quality/nesting.js +0 -57
  129. package/dist/quality/report.js +0 -803
  130. package/dist/quality/smells.js +0 -67
  131. package/dist/quality-tool.js +0 -63
  132. package/dist/read-only-commands.js +0 -597
  133. package/dist/repo-map.js +0 -292
  134. package/dist/roblox-connect.js +0 -125
  135. package/dist/rpc.js +0 -91
  136. package/dist/rtk.js +0 -193
  137. package/dist/sandbox.js +0 -105
  138. package/dist/schema.js +0 -214
  139. package/dist/serve.js +0 -50
  140. package/dist/session-lifecycle.js +0 -19
  141. package/dist/session-retention.js +0 -111
  142. package/dist/session.js +0 -447
  143. package/dist/sessions.js +0 -100
  144. package/dist/settings.js +0 -148
  145. package/dist/skills.js +0 -35
  146. package/dist/slop-tool.js +0 -96
  147. package/dist/spend-cap.js +0 -43
  148. package/dist/subagent.js +0 -171
  149. package/dist/tar.js +0 -146
  150. package/dist/theme.js +0 -79
  151. package/dist/thinking.js +0 -45
  152. package/dist/todo.js +0 -114
  153. package/dist/tool-loadout.js +0 -97
  154. package/dist/tool-render.js +0 -769
  155. package/dist/tools.js +0 -176
  156. package/dist/ui.js +0 -4814
  157. package/dist/update.js +0 -551
  158. package/dist/usage-segment.js +0 -69
  159. package/dist/web-tools.js +0 -46
  160. package/dist/web.js +0 -265
  161. package/dist/welcome.js +0 -179
  162. package/dist/workflow-engine.js +0 -246
  163. package/dist/workflow-progress.js +0 -446
  164. package/dist/workflow-view.js +0 -195
  165. package/dist/workflow.js +0 -349
@@ -1,111 +0,0 @@
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
- }
package/dist/session.js DELETED
@@ -1,447 +0,0 @@
1
- import { join } from "node:path";
2
- import { hostname } from "node:os";
3
- import { createAgentSession, createCodemodeExtension, createMcpExtension, DefaultResourceLoader, 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
- const settingsManager = SettingsManager.create(options.cwd, AGENT_DIR, { projectTrusted: true });
122
- // Read once at open and refreshed by the slash command. The extension consults this
123
- // variable per tool result, so a change applies immediately without reopening the
124
- // session — which is what a toggle should do.
125
- let rtkEnabled = readWorkbenchSettings(AGENT_DIR).compressToolOutput;
126
- const sessionManager = options.sessionPath ? SessionManager.open(options.sessionPath) : options.noSession ? SessionManager.inMemory(options.cwd) : options.continueSession ? SessionManager.continueRecent(options.cwd) : SessionManager.create(options.cwd);
127
- // A session written by a different build is a compatibility question the user
128
- // should not have to guess at. The file header records the *format* version
129
- // (the SDK's), not the product's, so a session from another release loads
130
- // silently and any behaviour that changed between them reads as a bug. Read
131
- // the stamp the previous build left and say when it differs.
132
- // Read the stamp off the session that was opened, whatever route opened it:
133
- // `--continue` and `/resume` reach the same file as `--session`, and keying
134
- // this on `sessionPath` alone meant the warning never fired for the two ways
135
- // a user actually returns to an old session.
136
- const buildNote = (() => {
137
- try {
138
- const previous = sessionManager.getEntries().find((entry) => entry.type === "custom" && entry.customType === BUILD_ENTRY);
139
- const was = previous?.data?.version;
140
- if (!was || was === APP_VERSION)
141
- return undefined;
142
- return `this session was written by HowToFix ${was}; you are running ${APP_VERSION}`;
143
- }
144
- catch {
145
- // An unreadable or older file is not a reason to refuse to open it.
146
- return undefined;
147
- }
148
- })();
149
- const resourceLoader = new DefaultResourceLoader({
150
- cwd: options.cwd,
151
- agentDir: AGENT_DIR,
152
- settingsManager,
153
- appendSystemPrompt: promptAppendBlocks(SYSTEM_APPEND, options.interactive ?? true),
154
- // The SDK discovers `<agentDir>/skills` itself through the package manager,
155
- // and the project dir is its own `.hxf`, so both the user's
156
- // `.howtofix/skills` and the bundled suite are handed over here. The user's
157
- // dir comes first (first-wins on a name collision), and the bundled suite
158
- // drops out of the list entirely when the antislop switch resolves off.
159
- additionalSkillPaths: additionalSkillPaths(options.cwd),
160
- extensionFactories: [
161
- // The plan gate must come first: the SDK returns on the first `block`, so
162
- // registering it ahead of the approval gate is what makes plan mode hold in
163
- // every approval mode, `full-auto` included.
164
- createPlanGateExtension(),
165
- createPermissionExtension(),
166
- createBrandExtension(),
167
- createHooksExtension(),
168
- // DCP sits between brand and the notes, and both neighbours matter.
169
- // Before brand and the id tags it writes into the prompt are scrubbed
170
- // back out; after context-extension and the shared notes become keep-set
171
- // candidates and get pruned. `check-dcp.mjs` asserts the order with three
172
- // handlers that overwrite each other — acceptance covers it, but if you
173
- // move this line, that check fails and it is right to fail.
174
- createDcpExtension(),
175
- createContextExtension(),
176
- // After the notes so a preset's rules are appended last, and after brand
177
- // so the harness scrub has already run over the sections it owns.
178
- createPresetExtension(options.cwd),
179
- createGoalExtension(),
180
- createPlanPromptExtension(),
181
- createCompactionGuardExtension(),
182
- createCodemodeExtension(),
183
- // Reads the setting per tool result, so `/rtk on` takes effect on the next tool
184
- // call rather than on the next process.
185
- createRtkExtension({ enabled: () => rtkEnabled }),
186
- // A loader that re-reads `mcp.json` each time the extension asks, so a
187
- // server removed from the preset menu is seen as gone and its transport is
188
- // closed. The SDK reads the file once on `session_start` otherwise, which
189
- // left the removed server's process running until the CLI exited.
190
- createMcpExtension({
191
- loadConfig: (ctx) => loadMcpConfiguration({
192
- cwd: ctx.cwd,
193
- projectTrusted: ctx.isProjectTrusted?.() ?? false,
194
- }),
195
- }),
196
- ],
197
- });
198
- await resourceLoader.reload();
199
- // Subagent definitions the user wrote, read before the session is built so the
200
- // `task` tool's description can name them: a definition the model is never told
201
- // about is a definition that does not exist. Problems are reported as notes
202
- // rather than thrown — a typo in one file must not stop the CLI from opening.
203
- const agentDefs = loadAgentDefs(options.cwd, agents);
204
- // Built after the definitions are in the map, so the description names them.
205
- const taskTool = createTaskTool();
206
- // Claims are held per session and written under the session's workspace. The
207
- // holder id has to be stable for the whole session — a claim that cannot be
208
- // renewed or released by the agent that took it is worse than no claim — so it
209
- // is derived from the session the manager opened, not from the process.
210
- const sessionId = sessionManager.getSessionId();
211
- setRepoRoot(() => options.cwd);
212
- const presenceDir = join(AGENT_DIR, SESSIONS_DIR_NAME);
213
- setClaimsContext({
214
- root: () => options.cwd,
215
- holder: () => `session:${sessionId}`,
216
- presenceDir: () => presenceDir,
217
- selfId: () => sessionId,
218
- });
219
- // Announce this session so another one in the same workspace can see it and ask
220
- // for a file it holds. Heartbeat, not a one-shot write: liveness is decided by the
221
- // freshness of the file, so a session that is killed drops out on its own without
222
- // anything having to reap it.
223
- const beat = () => {
224
- try {
225
- announce({ dir: presenceDir }, { id: sessionId, cwd: options.cwd, host: hostname(), pid: process.pid, at: Date.now() });
226
- }
227
- catch {
228
- // A session that cannot write its presence still works; it is simply invisible
229
- // to the others. Failing the session over this would be the worse trade.
230
- }
231
- };
232
- beat();
233
- const heartbeat = setInterval(beat, Math.max(5_000, Math.floor(PRESENCE_TTL_MS / 3)));
234
- // A background interval must not hold the process open on its own.
235
- heartbeat.unref?.();
236
- const result = await createAgentSession({
237
- cwd: options.cwd,
238
- agentDir: AGENT_DIR,
239
- settingsManager,
240
- sessionManager,
241
- resourceLoader,
242
- excludeTools: toolLoadout().exclude,
243
- customTools: [
244
- taskTool,
245
- repoMapTool,
246
- thinkTool,
247
- askTool,
248
- claimTool,
249
- sessionsTool,
250
- todoTool,
251
- contextTool,
252
- goalTool,
253
- enterPlanModeTool,
254
- exitPlanModeTool,
255
- createWorkflowTool(),
256
- webSearchTool,
257
- webFetchTool,
258
- lspTool,
259
- qualityTool,
260
- slopTool,
261
- // The compress tool resolves its boundaries against the ref snapshot the
262
- // handler published on the last prompt pass. Registering it here rather
263
- // than inside the extension is the pattern this file uses for every
264
- // custom tool, and the SDK enables a custom tool only when it is named in
265
- // `tools` too - which is why `compress` also appears in MAIN_TOOLS.
266
- createCompressTool({
267
- state: () => getDcpState(activeDcpSession() ?? "dcp-sessionless"),
268
- messages: () => snapshotFor(activeDcpSession() ?? "dcp-sessionless"),
269
- }),
270
- ],
271
- thinkingLevel: options.thinking,
272
- });
273
- // Activate the built-in search tools the product relies on.
274
- //
275
- // `grep`, `find` and `ls` are registered by the SDK but are not in its default
276
- // active set (`read, bash, edit, write`), so without this they are dead weight:
277
- // `permissions.ts` carries approval tiers for them and `agents.ts` tells every
278
- // subagent to use them, while the model cannot call them. Measured before the
279
- // fix: the model reported `read, bash, edit, write, task, …` and answered
280
- // "NO GREP TOOL".
281
- //
282
- // This has to happen here, through the public session API, and NOT in an
283
- // extension. An extension's `session_start` handler runs inside
284
- // `bindExtensions()`, which the SDK follows with `_refreshToolRegistry()` — so
285
- // anything `setActiveTools` did there is silently overwritten before the first
286
- // turn. Measured: the extension ran, `setActiveTools` was called, and the
287
- // active set was still `bash, edit, read, write`.
288
- //
289
- // Bind the extensions here, once, for every way the CLI can be started.
290
- //
291
- // `bindExtensions()` is what emits `session_start`, and `session_start` is where
292
- // extensions do their work: the MCP extension connects its servers and turns on
293
- // `codemode`, the hooks extension installs its handlers. It used to be called
294
- // only from the workbench's `bindUI`, so a `--print` run (`-p`, which is what
295
- // scripts and CI use) never emitted `session_start` at all: MCP servers were
296
- // never connected, hooks never ran, and `codemode` was inactive.
297
- //
298
- // The mode is `"print"` so an extension asks through text instead of opening an
299
- // overlay the workbench cannot host. `uiContext` is passed when a caller has
300
- // one (the workbench does, after this returns, via `bindUI`); here it is the
301
- // dialog-free context the SDK expects for non-interactive runs.
302
- //
303
- // This runs BEFORE `activateExtraBuiltins`: `bindExtensions` ends in the SDK's
304
- // `_refreshToolRegistry()`, which resets the active set, so activating first
305
- // would be undone.
306
- await result.session.bindExtensions({ mode: "print", onError: () => { } });
307
- // A short-lived access token has to be renewed before it is used, and this is
308
- // the one place every way of starting the CLI goes through — the workbench
309
- // renews again before each prompt, but `--print` never touches the workbench,
310
- // so without this a scripted run on an expired token fails on its first call
311
- // with nothing to explain it.
312
- //
313
- // A terminal refusal is reported as a note rather than thrown: the stored key
314
- // may still work (the panel could be briefly unreachable, or the local clock
315
- // could be ahead), and refusing to start would turn a recoverable state into a
316
- // dead CLI.
317
- const storedCredential = readCliCredential(AGENT_DIR);
318
- const credentialNotes = [];
319
- if (storedCredential) {
320
- const renewed = await renewCliCredentialIfNeeded(AGENT_DIR, storedCredential);
321
- if (renewed.renewed) {
322
- await result.session.modelRuntime.setRuntimeApiKey("howtofix", renewed.credential.accessToken);
323
- }
324
- else if (renewed.terminal && renewed.message) {
325
- credentialNotes.push(renewed.message);
326
- }
327
- else if (renewed.message) {
328
- credentialNotes.push(renewed.message);
329
- }
330
- }
331
- // `session_start` handlers are async and the runner does not await them, so
332
- // give the ones that register tools (MCP's `codemode`) a moment to land before
333
- // the first turn reads the loadout. This is bounded and only waits while the
334
- // work is still in flight.
335
- await settleExtensionTools(result.session);
336
- // Only now, on the settled set, add the built-ins the SDK leaves inactive.
337
- activateExtraBuiltins(result.session);
338
- let model = result.session.model;
339
- if (options.model) {
340
- const available = await result.session.modelRuntime.getAvailable();
341
- const requested = resolveModel(available, options.model);
342
- if (!requested) {
343
- await disposeAgentSession(result.session);
344
- throw new Error(`unknown model '${options.model}'`);
345
- }
346
- if (model?.id !== requested.id || model.provider !== requested.provider) {
347
- await result.session.setModel(requested);
348
- model = result.session.model;
349
- }
350
- }
351
- else {
352
- // Nothing was asked for, so the runtime's own default stands — and that
353
- // default is the catalogue's, not this account's. The runtime ships a
354
- // built-in provider catalogue, so a fresh session can start on
355
- // `opencode/kimi-k2.6`, a provider with no credential here: the first prompt
356
- // then goes nowhere and comes back as an empty assistant message, with
357
- // nothing in the transcript to say why it looks hung. Start on a model the
358
- // account can actually reach instead, preferring HowToFix's own providers.
359
- //
360
- // Available is not the same as reachable — the catalogue lists every provider
361
- // it knows — so each provider is asked for its credential, once.
362
- const available = await result.session.modelRuntime.getAvailable();
363
- const withKey = new Map();
364
- const reachable = [];
365
- for (const entry of available) {
366
- let ok = withKey.get(entry.provider);
367
- if (ok === undefined) {
368
- ok = Boolean((await result.session.modelRuntime.getAuth(entry.provider))?.auth?.apiKey);
369
- withKey.set(entry.provider, ok);
370
- }
371
- if (ok)
372
- reachable.push(entry);
373
- }
374
- // A credential in the runtime is not proof the provider answers: the built-in
375
- // catalogue carries providers whose key is absent at request time, which is
376
- // how a session ends up on `opencode/kimi-k2.6` and gets a 401 with nothing
377
- // in the transcript. So the account's own provider wins whenever it has any
378
- // model at all — an install signed in to HowToFix should run HowToFix models.
379
- const ownProviders = HOWTOFIX_PROVIDERS;
380
- const startedOnOwn = ownProviders.includes(model?.provider ?? "");
381
- if (!startedOnOwn) {
382
- const chosen = defaultModelFor(reachable.filter((entry) => ownProviders.includes(entry.provider)), ownProviders);
383
- if (chosen) {
384
- await result.session.setModel(chosen);
385
- model = result.session.model;
386
- }
387
- }
388
- }
389
- // Stamp this build into a session it is allowed to write. A read-only open
390
- // (`--print`, a resumed file) is left untouched: the stamp belongs to the
391
- // build that owns the session, not to whoever glanced at it.
392
- if (!options.noSession && sessionManager.getEntries().every((entry) => entry.type !== "custom" || entry.customType !== BUILD_ENTRY)) {
393
- try {
394
- sessionManager.appendCustomEntry(BUILD_ENTRY, { version: APP_VERSION });
395
- }
396
- catch {
397
- // A session that cannot be written (read-only file, full disk) still works.
398
- }
399
- }
400
- // Retention sweep, once per process and off the critical path: session files
401
- // under `~/.howtofix/agent/sessions/` only ever grow — the SDK appends and
402
- // never prunes, and the TUI's delete is one session a person picked — so files
403
- // idle for longer than RETENTION_DAYS are unlinked for *every* project, not
404
- // just this one. The file just opened is kept whatever its age: `--continue`
405
- // and `/resume` exist to bring an old session back, and a sweep that ate its
406
- // own argument would break them. Not awaited — startup must not pay for a walk
407
- // of every session directory, and a failed sweep leaves the store merely
408
- // larger. See session-retention.ts.
409
- if (!sessionsSwept) {
410
- sessionsSwept = true;
411
- void sweepStaleSessions({ keep: result.session.sessionFile }).catch(() => {
412
- // Housekeeping: the store is larger, not wrong.
413
- });
414
- }
415
- const modelLabel = model ? `${model.provider}/${model.id}` : "no model";
416
- // A model with no declared rates reports $0 forever, which is what makes a
417
- // budget unenforceable on it. See `priced` on `OpenedSession`.
418
- const priced = Boolean(model && model.cost && (model.cost.input > 0 || model.cost.output > 0));
419
- const notes = [scrubHarnessMessage(result.modelFallbackMessage), ...credentialNotes, ...agentDefs.notes, ...agentDefs.problems].filter((note) => Boolean(note));
420
- if (!model) {
421
- notes.push(`no model configured. run \`howtofix\` and use /login, or edit ${join(AGENT_DIR, "models.json")}`);
422
- }
423
- return {
424
- session: result.session,
425
- dispose: async () => {
426
- // The lsp client pool is process-wide and shared with subagents; the main
427
- // session is the owner that tears it down (process `exit` is the backstop).
428
- disposeAllLsp();
429
- // Await the extension shutdown so MCP servers stop before the session
430
- // that hosts their tools goes away.
431
- await disposeAgentSession(result.session);
432
- // Take this session out of the registry on a clean exit. A crash is covered
433
- // by the heartbeat going stale, so this only makes the common case immediate.
434
- clearInterval(heartbeat);
435
- withdraw({ dir: presenceDir }, sessionId);
436
- },
437
- modelLabel,
438
- priced,
439
- // Exposed so the `/rtk` command can flip the flag the extension reads. Passing a
440
- // setter rather than the variable keeps the two in one place: there is no way to
441
- // change the setting without changing what the extension sees.
442
- setCompressToolOutput: (on) => {
443
- rtkEnabled = on;
444
- },
445
- note: [buildNote, ...notes].filter(Boolean).join("\n") || undefined,
446
- };
447
- }
package/dist/sessions.js DELETED
@@ -1,100 +0,0 @@
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
- }