howtofixcli 0.7.24 → 0.7.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/acp.js +234 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +250 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +121 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +381 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/connect-prompt.js +22 -0
  24. package/dist/context-extension.js +17 -0
  25. package/dist/dcp/commands.js +172 -0
  26. package/dist/dcp/compress-state.js +139 -0
  27. package/dist/dcp/compress.js +329 -0
  28. package/dist/dcp/dcp-extension.js +295 -0
  29. package/dist/dcp/dedup.js +93 -0
  30. package/dist/dcp/ids.js +189 -0
  31. package/dist/dcp/index.js +25 -0
  32. package/dist/dcp/middle-prune.js +76 -0
  33. package/dist/dcp/prompts.js +68 -0
  34. package/dist/dcp/protected-content.js +221 -0
  35. package/dist/dcp/protected.js +161 -0
  36. package/dist/dcp/prune.js +418 -0
  37. package/dist/dcp/state.js +176 -0
  38. package/dist/errors.js +63 -0
  39. package/dist/extensions.js +48 -0
  40. package/dist/fatal.js +35 -0
  41. package/dist/feed.js +498 -0
  42. package/dist/fs-atomic.js +54 -0
  43. package/dist/gateway-errors.js +107 -0
  44. package/dist/git.js +230 -0
  45. package/dist/goal-extension.js +88 -0
  46. package/dist/goal.js +222 -0
  47. package/dist/hooks-extension.js +176 -0
  48. package/dist/hooks.js +443 -0
  49. package/dist/howtofix-account.js +834 -0
  50. package/dist/hud-line.js +158 -0
  51. package/dist/hwid.js +130 -0
  52. package/dist/interrupt.js +60 -0
  53. package/dist/json-events.js +50 -0
  54. package/dist/level-style.js +112 -0
  55. package/dist/loop-guard.js +95 -0
  56. package/dist/lsp-tools.js +32 -0
  57. package/dist/lsp.js +429 -0
  58. package/dist/main.js +573 -98
  59. package/dist/mcp-config.js +85 -0
  60. package/dist/mcp-lifecycle.js +147 -0
  61. package/dist/model-catalog.js +217 -0
  62. package/dist/model-facts.js +35 -0
  63. package/dist/model-resolve.js +65 -0
  64. package/dist/notes.js +78 -0
  65. package/dist/oauth-login.js +568 -0
  66. package/dist/paths.js +61 -0
  67. package/dist/permissions.js +298 -0
  68. package/dist/plan-mode.js +363 -0
  69. package/dist/preset-command.js +227 -0
  70. package/dist/preset-extension.js +35 -0
  71. package/dist/preset-mcp.js +292 -0
  72. package/dist/presets/mcp/rea/preset.json +28 -0
  73. package/dist/presets/mcp/rea/prompt.md +5 -0
  74. package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
  75. package/dist/presets/mcp/remotion/preset.json +27 -0
  76. package/dist/presets/mcp/remotion/prompt.md +7 -0
  77. package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
  78. package/dist/presets.js +727 -0
  79. package/dist/prompt-files.js +103 -0
  80. package/dist/prompt-text.js +41 -0
  81. package/dist/proxy.js +137 -0
  82. package/dist/quality/ast.js +196 -0
  83. package/dist/quality/cognitive.js +83 -0
  84. package/dist/quality/cyclomatic.js +30 -0
  85. package/dist/quality/halstead.js +83 -0
  86. package/dist/quality/index.js +18 -0
  87. package/dist/quality/length.js +3 -0
  88. package/dist/quality/maintainability.js +15 -0
  89. package/dist/quality/nesting.js +57 -0
  90. package/dist/quality/report.js +803 -0
  91. package/dist/quality/smells.js +67 -0
  92. package/dist/quality-tool.js +63 -0
  93. package/dist/read-only-commands.js +597 -0
  94. package/dist/repo-map.js +292 -0
  95. package/dist/roblox-connect.js +125 -0
  96. package/dist/rpc.js +91 -0
  97. package/dist/rtk.js +193 -0
  98. package/dist/sandbox.js +105 -0
  99. package/dist/schema.js +214 -0
  100. package/dist/serve.js +50 -0
  101. package/dist/session-lifecycle.js +19 -0
  102. package/dist/session-retention.js +111 -0
  103. package/dist/session.js +460 -0
  104. package/dist/sessions.js +100 -0
  105. package/dist/settings.js +148 -0
  106. package/dist/skills.js +35 -0
  107. package/dist/slop-tool.js +96 -0
  108. package/dist/spend-cap.js +43 -0
  109. package/dist/subagent.js +171 -0
  110. package/dist/tar.js +146 -0
  111. package/dist/theme.js +79 -0
  112. package/dist/thinking.js +45 -0
  113. package/dist/todo.js +114 -0
  114. package/dist/tool-loadout.js +102 -0
  115. package/dist/tool-render.js +769 -0
  116. package/dist/tools.js +176 -0
  117. package/dist/ui.js +4931 -0
  118. package/dist/update.js +551 -0
  119. package/dist/usage-segment.js +74 -0
  120. package/dist/web-tools.js +46 -0
  121. package/dist/web.js +265 -0
  122. package/dist/welcome.js +179 -0
  123. package/dist/workflow-engine.js +246 -0
  124. package/dist/workflow-progress.js +446 -0
  125. package/dist/workflow-view.js +195 -0
  126. package/dist/workflow.js +349 -0
  127. package/package.json +62 -15
  128. package/dist/chunk-27TRKD65.js +0 -2
  129. package/dist/chunk-3FYCFLNK.js +0 -1
  130. package/dist/chunk-3LZR7OEU.js +0 -1
  131. package/dist/chunk-56SEAGAQ.js +0 -1
  132. package/dist/chunk-5AWTL34S.js +0 -1
  133. package/dist/chunk-75P2SCOT.js +0 -1
  134. package/dist/chunk-7CHDNTH6.js +0 -1
  135. package/dist/chunk-7PHRNXLN.js +0 -34
  136. package/dist/chunk-AGGIYXN7.js +0 -2
  137. package/dist/chunk-AUDMRGWF.js +0 -189
  138. package/dist/chunk-C5E3SFOQ.js +0 -2
  139. package/dist/chunk-CMTKCUL5.js +0 -1
  140. package/dist/chunk-DAPXGSGD.js +0 -12
  141. package/dist/chunk-DOD7MUGX.js +0 -1
  142. package/dist/chunk-FPXVDP32.js +0 -8
  143. package/dist/chunk-GXB7RZHH.js +0 -1
  144. package/dist/chunk-I7KVLBMF.js +0 -2
  145. package/dist/chunk-ILDDDLRC.js +0 -1
  146. package/dist/chunk-JFYVRWJI.js +0 -2
  147. package/dist/chunk-LAHVHRCS.js +0 -2
  148. package/dist/chunk-LSJC73DG.js +0 -1
  149. package/dist/chunk-MNDKS6UH.js +0 -6
  150. package/dist/chunk-N345KWWK.js +0 -1
  151. package/dist/chunk-N3J2J7EQ.js +0 -5
  152. package/dist/chunk-ONZLNFQ2.js +0 -3
  153. package/dist/chunk-ORJ2QZBG.js +0 -3
  154. package/dist/chunk-PCALBZOO.js +0 -2
  155. package/dist/chunk-QPHSHL7H.js +0 -2
  156. package/dist/chunk-TKROS3BE.js +0 -5
  157. package/dist/chunk-TSEKPGK4.js +0 -1
  158. package/dist/chunk-UP7ENLEL.js +0 -117
  159. package/dist/chunk-UTYFLLSM.js +0 -1
  160. package/dist/chunk-V3R4FYTJ.js +0 -1
  161. package/dist/chunk-V656XVXV.js +0 -8
  162. package/dist/chunk-WKUJ7CFO.js +0 -1
  163. package/dist/chunk-WOT6VMZA.js +0 -1
  164. package/dist/chunk-WUPU6LTD.js +0 -4
  165. package/dist/chunk-WX3V5P7U.js +0 -5
  166. package/dist/chunk-XW6LMQGX.js +0 -1
  167. package/dist/chunk-YW7NORH3.js +0 -2
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Signing in with the account itself, not with a key made for the occasion.
3
+ *
4
+ * ## The problem this replaces
5
+ *
6
+ * `/connect` ended in a browser flow whose approval minted a fresh `sk-` key
7
+ * every time. A user who reconnected a few times found a panel full of
8
+ * near-identical rows, could not tell which one the CLI was using, and had to
9
+ * revoke them one by one. The account was already the thing being proven; the
10
+ * key was only a side effect of proving it.
11
+ *
12
+ * ## What happens instead
13
+ *
14
+ * The credential — `email:password`, base64-encoded — is stored once in the agent
15
+ * directory, mode 600, and sent to `POST /api/auth/cli/credential`. The panel
16
+ * verifies it and answers with the CLI key this account already owns; only an
17
+ * account with no CLI key gets a new one. A headless install therefore needs no
18
+ * second device and no pasted key.
19
+ *
20
+ * ## Why base64
21
+ *
22
+ * It is what people paste: one opaque string, no shell quoting, no `@` or `:` to
23
+ * trip over. It is not encryption and is not presented as such — the file is
24
+ * protected by its permissions, and the value is never printed.
25
+ */
26
+ import { chmodSync, existsSync, readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { writeFileAtomicSync } from "./fs-atomic.js";
29
+ /** Where the stored credential lives. */
30
+ export function accountCredentialPath(agentDir) {
31
+ return join(agentDir, "account-credential.json");
32
+ }
33
+ /** base64 of `username:password`. */
34
+ export function encodeCredential(username, password) {
35
+ return Buffer.from(`${username}:${password}`, "utf8").toString("base64");
36
+ }
37
+ /**
38
+ * Read either a base64 blob or a plain `user:pass` line.
39
+ *
40
+ * A pasted credential is almost always one of the two, and refusing the plain
41
+ * form only makes the user encode it by hand to satisfy the parser.
42
+ */
43
+ export function parseCredentialInput(raw) {
44
+ const trimmed = raw.trim();
45
+ if (!trimmed)
46
+ return null;
47
+ const fromPlain = (value) => {
48
+ const at = value.indexOf(":");
49
+ if (at <= 0 || at === value.length - 1)
50
+ return null;
51
+ return { username: value.slice(0, at), password: value.slice(at + 1) };
52
+ };
53
+ const plain = fromPlain(trimmed);
54
+ if (plain)
55
+ return plain;
56
+ // Not `user:pass`, so try base64 and re-read the decoded text.
57
+ try {
58
+ const decoded = Buffer.from(trimmed, "base64").toString("utf8");
59
+ if (!decoded.includes(":"))
60
+ return null;
61
+ return fromPlain(decoded);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /** Store the credential so a later `/connect` needs nothing typed. */
68
+ export function saveAccountCredential(agentDir, username, password) {
69
+ try {
70
+ const payload = {
71
+ credential: encodeCredential(username, password),
72
+ username,
73
+ savedAt: Math.floor(Date.now() / 1000),
74
+ };
75
+ const target = accountCredentialPath(agentDir);
76
+ writeFileAtomicSync(target, JSON.stringify(payload, null, 2));
77
+ chmodSync(target, 0o600);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** The stored credential, if one was saved and is still readable. */
85
+ export function loadAccountCredential(agentDir) {
86
+ try {
87
+ const target = accountCredentialPath(agentDir);
88
+ if (!existsSync(target))
89
+ return null;
90
+ const parsed = JSON.parse(readFileSync(target, "utf8"));
91
+ if (!parsed?.credential || !parsed?.username)
92
+ return null;
93
+ return parsed;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /** A JSON POST against the panel. */
100
+ async function panelPost(url, body) {
101
+ const response = await fetch(url, {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify(body),
105
+ signal: AbortSignal.timeout(20_000),
106
+ });
107
+ const text = await response.text();
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(text);
111
+ }
112
+ catch {
113
+ throw new Error(`the panel answered ${response.status} with something that is not JSON`);
114
+ }
115
+ const envelope = parsed;
116
+ if (!response.ok || envelope.success === false) {
117
+ throw new Error(envelope.message || `the panel answered ${response.status}`);
118
+ }
119
+ return envelope.data;
120
+ }
121
+ /**
122
+ * Exchange the account credential for the CLI key this account owns.
123
+ *
124
+ * The panel decides whether that key already existed; the CLI never creates a
125
+ * token of its own, so a reconnect cannot add a row to the panel.
126
+ */
127
+ export async function loginWithCredential(panelUrl, username, password, clientInfo) {
128
+ const data = await panelPost(`${panelUrl}/api/auth/cli/credential`, {
129
+ credential: encodeCredential(username, password),
130
+ client_info: clientInfo,
131
+ });
132
+ if (!data?.token) {
133
+ throw new Error("the panel accepted the credential but returned no key");
134
+ }
135
+ return { token: data.token };
136
+ }
package/dist/acp.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * `howtofix acp`: the Agent Client Protocol over stdio.
3
+ *
4
+ * ACP is how an editor (Zed and others) or any other host drives a coding agent:
5
+ * JSON-RPC 2.0, one message per line, the host as client and this process as the
6
+ * agent. Speaking it means the CLI plugs into those hosts without each one
7
+ * needing its own adapter — and it is also the seam the hosted Roblox workspace
8
+ * uses to steer a local session.
9
+ *
10
+ * What is implemented is the part a host needs to hold a conversation:
11
+ * `initialize`, `session/new`, `session/prompt` (with streamed `session/update`
12
+ * notifications for text, thinking and tool calls) and `session/cancel`. Anything
13
+ * else is answered `-32601` rather than ignored, so a host finds out a capability
14
+ * is missing instead of waiting on a reply that never comes.
15
+ *
16
+ * ## Per-turn context
17
+ *
18
+ * A host can attach `_meta.howtofix.context` to a prompt: the Studio place, the
19
+ * selection, what is connected. It is rendered into a `<session_context>` block
20
+ * ahead of the user's words *each turn*, not once at session start, because the
21
+ * selection the user had when they typed is the one that matters, and a context
22
+ * frozen at `session/new` would describe a place that has since changed.
23
+ *
24
+ * The agent is reached through `AcpAgent` so this file does not import the SDK:
25
+ * the protocol layer is testable with a fake, and `main.ts` supplies the real one.
26
+ */
27
+ import { createInterface } from "node:readline";
28
+ export const ACP_PROTOCOL_VERSION = 1;
29
+ /**
30
+ * Render per-turn context as text the model reads before the prompt.
31
+ *
32
+ * Returns an empty string for no context, so a plain prompt is passed through
33
+ * untouched. Values are JSON-encoded rather than interpolated: a place named
34
+ * `</session_context>` must not be able to close the block and speak as the user.
35
+ */
36
+ export function renderSessionContext(context) {
37
+ if (!context || Object.keys(context).length === 0)
38
+ return "";
39
+ // JSON quoting alone does not stop `</session_context>` inside a string from
40
+ // reading as the closing tag, so `<` is escaped too. `\u003c` is still valid
41
+ // JSON and decodes to the same text; it just cannot be mistaken for markup.
42
+ const encode = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
43
+ const lines = [];
44
+ if (context.place)
45
+ lines.push(`place: ${encode(context.place)}`);
46
+ if (context.selection && context.selection.length > 0)
47
+ lines.push(`selection: ${encode(context.selection)}`);
48
+ if (context.connected)
49
+ lines.push(`connected: ${encode(context.connected)}`);
50
+ for (const [key, value] of Object.entries(context)) {
51
+ if (key === "place" || key === "selection" || key === "connected")
52
+ continue;
53
+ lines.push(`${key.replace(/[^\w.-]/g, "_")}: ${encode(value)}`);
54
+ }
55
+ return `<session_context>\n${lines.join("\n")}\n</session_context>\n\n`;
56
+ }
57
+ /** A tool's kind for the host's icon, from its name. */
58
+ function toolKind(name) {
59
+ if (/^(get_|list_|read_|snapshot|diff_|audit|datastore_get|datastore_list|roblox_status)/.test(name))
60
+ return "read";
61
+ if (/^(search|grep|find)/.test(name))
62
+ return "search";
63
+ if (/(execute|run_|luau)/.test(name))
64
+ return "execute";
65
+ if (/^(set_|create_|delete_|clone_|reparent_|edit_|insert_|send_|publish_|upload_|generate_|datastore_set|datastore_delete)/.test(name))
66
+ return "edit";
67
+ return "other";
68
+ }
69
+ function resultText(result) {
70
+ if (typeof result === "string")
71
+ return result;
72
+ const content = result?.content;
73
+ if (Array.isArray(content))
74
+ return content.map((block) => (block.type === "text" ? (block.text ?? "") : `[${block.type ?? "?"}]`)).join("\n");
75
+ return JSON.stringify(result ?? null);
76
+ }
77
+ /** Run the protocol until the input closes. Resolves when it has. */
78
+ export function runAcp(options) {
79
+ const sessions = new Map();
80
+ const running = new Map();
81
+ let counter = 0;
82
+ const write = (message) => {
83
+ options.output.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
84
+ };
85
+ const reply = (id, result) => write({ id, result });
86
+ const fail = (id, code, message) => write({ id, error: { code, message } });
87
+ const update = (sessionId, body) => write({ method: "session/update", params: { sessionId, update: body } });
88
+ async function handle(request) {
89
+ const params = request.params ?? {};
90
+ const isNotification = request.id === undefined;
91
+ switch (request.method) {
92
+ case "initialize":
93
+ return reply(request.id, {
94
+ protocolVersion: ACP_PROTOCOL_VERSION,
95
+ agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: true } },
96
+ authMethods: [],
97
+ agentInfo: { name: options.name ?? "howtofix", title: "HowToFix", version: options.version },
98
+ });
99
+ case "session/new": {
100
+ const cwd = typeof params.cwd === "string" ? params.cwd : process.cwd();
101
+ try {
102
+ const agent = await options.createAgent(cwd);
103
+ counter += 1;
104
+ const sessionId = `sess_${Date.now().toString(36)}_${counter}`;
105
+ sessions.set(sessionId, agent);
106
+ return reply(request.id, { sessionId });
107
+ }
108
+ catch (error) {
109
+ return fail(request.id, -32000, error instanceof Error ? error.message : String(error));
110
+ }
111
+ }
112
+ case "session/prompt": {
113
+ const sessionId = String(params.sessionId ?? "");
114
+ const agent = sessions.get(sessionId);
115
+ if (!agent)
116
+ return fail(request.id, -32602, `unknown session ${sessionId}`);
117
+ if (running.has(sessionId))
118
+ return fail(request.id, -32000, "a prompt is already running in this session");
119
+ const blocks = Array.isArray(params.prompt) ? params.prompt : [];
120
+ const text = blocks
121
+ .map((block) => {
122
+ if (block.type === "text")
123
+ return block.text ?? "";
124
+ if (block.type === "resource" && block.resource?.text !== undefined)
125
+ return `<file uri=${JSON.stringify(block.resource.uri ?? "")}>\n${block.resource.text}\n</file>`;
126
+ return "";
127
+ })
128
+ .filter((part) => part !== "")
129
+ .join("\n\n");
130
+ if (text === "")
131
+ return fail(request.id, -32602, "the prompt has no text");
132
+ const meta = params._meta;
133
+ const full = renderSessionContext(meta?.howtofix?.context) + text;
134
+ let cancelled = false;
135
+ const unsubscribe = agent.subscribe((event) => {
136
+ if (event.type === "message_update") {
137
+ const inner = event.assistantMessageEvent;
138
+ if (inner?.type === "text_delta" && inner.delta) {
139
+ update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: inner.delta } });
140
+ }
141
+ else if (inner?.type === "thinking_delta" && inner.delta) {
142
+ update(sessionId, { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: inner.delta } });
143
+ }
144
+ }
145
+ else if (event.type === "tool_execution_start") {
146
+ const e = event;
147
+ update(sessionId, {
148
+ sessionUpdate: "tool_call",
149
+ toolCallId: e.toolCallId,
150
+ title: e.toolName,
151
+ kind: toolKind(e.toolName),
152
+ status: "in_progress",
153
+ rawInput: e.args ?? {},
154
+ });
155
+ }
156
+ else if (event.type === "tool_execution_end") {
157
+ const e = event;
158
+ update(sessionId, {
159
+ sessionUpdate: "tool_call_update",
160
+ toolCallId: e.toolCallId,
161
+ status: e.isError ? "failed" : "completed",
162
+ content: [{ type: "content", content: { type: "text", text: resultText(e.result) } }],
163
+ });
164
+ }
165
+ });
166
+ const turn = agent
167
+ .prompt(full)
168
+ .then(() => {
169
+ const stop = agent.lastStopReason?.();
170
+ const stopReason = cancelled || stop === "aborted" ? "cancelled" : stop === "length" ? "max_tokens" : "end_turn";
171
+ reply(request.id, { stopReason });
172
+ })
173
+ .catch((error) => {
174
+ if (cancelled)
175
+ reply(request.id, { stopReason: "cancelled" });
176
+ else
177
+ fail(request.id, -32000, error instanceof Error ? error.message : String(error));
178
+ })
179
+ .finally(() => {
180
+ unsubscribe();
181
+ running.delete(sessionId);
182
+ });
183
+ running.set(sessionId, turn);
184
+ // `cancel` needs to reach this turn's flag, so it is looked up by session.
185
+ cancels.set(sessionId, () => {
186
+ cancelled = true;
187
+ });
188
+ return;
189
+ }
190
+ case "session/cancel": {
191
+ const sessionId = String(params.sessionId ?? "");
192
+ cancels.get(sessionId)?.();
193
+ await sessions.get(sessionId)?.abort();
194
+ return;
195
+ }
196
+ default:
197
+ // A notification has no reply to carry an error, so it is dropped.
198
+ if (!isNotification)
199
+ fail(request.id, -32601, `method not found: ${request.method}`);
200
+ }
201
+ }
202
+ const cancels = new Map();
203
+ return new Promise((resolve) => {
204
+ const lines = createInterface({ input: options.input });
205
+ const pending = new Set();
206
+ lines.on("line", (line) => {
207
+ if (line.trim() === "")
208
+ return;
209
+ let request;
210
+ try {
211
+ request = JSON.parse(line);
212
+ }
213
+ catch {
214
+ fail(null, -32700, "parse error");
215
+ return;
216
+ }
217
+ const task = handle(request)
218
+ .catch((error) => {
219
+ if (request.id !== undefined)
220
+ fail(request.id, -32603, error instanceof Error ? error.message : String(error));
221
+ })
222
+ .finally(() => pending.delete(task));
223
+ pending.add(task);
224
+ });
225
+ lines.on("close", () => {
226
+ // Let in-flight turns finish writing before sessions are torn down: closing
227
+ // stdin after sending a prompt is how a one-shot host says "and then stop".
228
+ void Promise.allSettled([...pending, ...running.values()]).then(async () => {
229
+ await Promise.allSettled([...sessions.values()].map((agent) => agent.dispose()));
230
+ resolve();
231
+ });
232
+ });
233
+ });
234
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The one line under the input box, made consistent.
3
+ *
4
+ * That line used to be a single `hud.activity` string written from twenty-three
5
+ * places, each with its own idea of what it looked like:
6
+ *
7
+ * `⠋ thinking 12s` — spinner and a word
8
+ * `✓ Read ×1 | Workflow ×1` — a tool tally
9
+ * `* shell 4096 bytes` — a bullet
10
+ * `API key for nrimo. Paste it, then press enter.` — a sentence
11
+ * `""` — blank, from eight different places
12
+ *
13
+ * The result flickered between shapes and erased itself whenever an unrelated
14
+ * event landed, which is what "the text keeps changing and is not consistent"
15
+ * describes. The cause is not the number of writers; it is that each writer
16
+ * owned the *whole* line.
17
+ *
18
+ * So writers now own a **layer**, and the line renders the highest-priority
19
+ * non-empty one:
20
+ *
21
+ * prompt > retry > note > run > tally
22
+ *
23
+ * A prompt waiting for you outranks a transient note, a note outranks the live
24
+ * run, and the tool tally is a floor that survives the run ending. Clearing a
25
+ * layer only clears that layer: a finished run no longer wipes a message that
26
+ * was waiting for the user.
27
+ *
28
+ * The retry sits above the note because the two describe the same silence — a
29
+ * provider that did not answer — and the retry is the one that says what is
30
+ * being done about it. A note that is still pinned while a retry counts down is
31
+ * the older, vaguer half of the same story.
32
+ *
33
+ * Time-varying text is produced here, not stored, so the label stays put while
34
+ * only the elapsed seconds tick.
35
+ */
36
+ export function emptyActivity() {
37
+ return {};
38
+ }
39
+ /** Set or clear one layer. Returns a new state; the caller assigns it. */
40
+ export function withLayer(state, layer, text, now = Date.now()) {
41
+ const next = { ...state };
42
+ if (layer === "run") {
43
+ if (text === undefined || text === "")
44
+ delete next.run;
45
+ // Keep the original start when only the label changes, so a run that moves
46
+ // from "thinking" to "running agents" does not reset its own clock.
47
+ else
48
+ next.run = { label: text, since: state.run?.since ?? now };
49
+ return next;
50
+ }
51
+ if (text === undefined || text === "")
52
+ delete next[layer];
53
+ else
54
+ next[layer] = text;
55
+ return next;
56
+ }
57
+ /**
58
+ * Set or clear the retry layer.
59
+ *
60
+ * Separate from `withLayer` because the value is a deadline rather than a
61
+ * string: the caller hands over when the next attempt fires and what went
62
+ * wrong, and the text is derived from the clock on every draw.
63
+ */
64
+ export function withRetry(state, retry) {
65
+ const next = { ...state };
66
+ if (retry === undefined)
67
+ delete next.retry;
68
+ else
69
+ next.retry = retry;
70
+ return next;
71
+ }
72
+ /** `42s` / `1m 05s`, the same shape the rest of the UI uses. */
73
+ function formatElapsed(ms) {
74
+ const seconds = Math.max(0, Math.round(ms / 1000));
75
+ const minutes = Math.floor(seconds / 60);
76
+ return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, "0")}s` : `${seconds}s`;
77
+ }
78
+ /**
79
+ * The provider's message, cut to what one line can hold.
80
+ *
81
+ * A failed request reports itself in a paragraph, sometimes several — a status
82
+ * line, a JSON body, a stack. The activity line is one row wide, so the first
83
+ * non-empty line is the part that says what happened.
84
+ */
85
+ export function firstLine(text, limit = 72) {
86
+ const line = text.split("\n").map((part) => part.trim()).find((part) => part !== "") ?? "";
87
+ return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
88
+ }
89
+ /**
90
+ * `retry 1/3 in 2s · upstream 502` — the wait counts down as the clock moves.
91
+ *
92
+ * The countdown is the point: a bare `retry 1/3` says something is happening
93
+ * but not whether to wait or to reach for ctrl+c, and a retry that has already
94
+ * fired must not keep reading like a countdown. Past the deadline the text says
95
+ * so and stays there until the attempt reports back.
96
+ */
97
+ function retryText(retry, now) {
98
+ const left = Math.max(0, Math.ceil((retry.until - now) / 1000));
99
+ const when = left > 0 ? `in ${left}s` : "now";
100
+ const head = `retry ${retry.attempt}/${retry.maxAttempts} ${when}`;
101
+ const reason = firstLine(retry.error);
102
+ return reason === "" ? head : `${head} · ${reason}`;
103
+ }
104
+ /**
105
+ * The line to draw, or `""` when nothing is happening.
106
+ *
107
+ * One marker per layer, one separator (` · `), and the text never carries its
108
+ * own prefix — so two different states can never look like two different tools.
109
+ */
110
+ export function activityText(state, spinner, now = Date.now()) {
111
+ if (state.prompt)
112
+ return `? ${state.prompt}`;
113
+ if (state.retry)
114
+ return `* ${retryText(state.retry, now)}`;
115
+ if (state.note)
116
+ return `* ${state.note}`;
117
+ if (state.run)
118
+ return `${spinner} ${state.run.label} · ${formatElapsed(now - state.run.since)}`;
119
+ // No marker of its own: the tally is already a list of per-tool markers
120
+ // (`✓ Read ×1 | ! Workflow ×1`), so prefixing it drew `✓ ✓ Read ×1`.
121
+ if (state.tally)
122
+ return state.tally;
123
+ return "";
124
+ }