howtofixcli 0.7.7 → 0.7.9

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