howtofixcli 0.7.13 → 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 (161) 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 -540
  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/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
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
- }