howtofixcli 0.7.13 → 0.7.14

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.
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
+ }
package/dist/args.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { APP_TITLE, APP_VERSION } from "./brand.js";
2
+ const SERVER_MODES = { rpc: true, acp: true };
2
3
  const THINKING = { off: true, minimal: true, low: true, medium: true, high: true, xhigh: true, max: true };
3
4
  const APPROVAL = { suggest: true, "auto-edit": true, "full-auto": true };
4
5
  /**
@@ -84,6 +85,15 @@ export function parseArgs(argv) {
84
85
  else if (token === "--max-budget-usd" || token.startsWith("--max-budget-usd=")) {
85
86
  args.maxBudgetUsd = parseBudget(takeValue(tokens, i, "--max-budget-usd"));
86
87
  }
88
+ else if (token === "--mode" || token.startsWith("--mode=")) {
89
+ const mode = takeValue(tokens, i, "--mode");
90
+ if (!SERVER_MODES[mode])
91
+ throw new Error(`--mode must be one of: ${Object.keys(SERVER_MODES).join(", ")}`);
92
+ args.mode = mode;
93
+ }
94
+ else if (token === "--acp") {
95
+ args.mode = "acp";
96
+ }
87
97
  else if (token === "--dangerously-skip-permissions") {
88
98
  args.dangerouslySkipPermissions = true;
89
99
  }
@@ -135,6 +145,12 @@ export function parseArgs(argv) {
135
145
  if (args.print && (args.continueSession || args.sessionPath)) {
136
146
  throw new Error("--print runs one prompt in a throwaway session; it cannot be combined with --continue or --session");
137
147
  }
148
+ if (args.mode && (args.print || args.prompt)) {
149
+ throw new Error(`--mode ${args.mode} is a protocol server: prompts arrive over the protocol, not the command line`);
150
+ }
151
+ if (args.mode && (args.continueSession || args.sessionPath)) {
152
+ throw new Error(`--mode ${args.mode} opens its own sessions; it cannot be combined with --continue or --session`);
153
+ }
138
154
  if (args.sessionPath && !args.sessionPath.endsWith(".jsonl")) {
139
155
  throw new Error(`--session expects a session file, got '${args.sessionPath}'`);
140
156
  }
@@ -147,6 +163,8 @@ coding agent. layout is a workbench, not a chat log.
147
163
  usage:
148
164
  howtofix [options] [prompt]
149
165
  howtofix -p "explain this repo"
166
+ howtofix acp serve the Agent Client Protocol on stdio
167
+ howtofix --mode rpc serve JSON-line commands on stdio
150
168
  howtofix preset new|check|pack author a preset
151
169
  howtofix doctor|models|sessions inspect the installation
152
170
  howtofix sessions --search <text> find a session by what was said in it
@@ -165,6 +183,8 @@ options:
165
183
  text for a person; json writes the final result only;
166
184
  stream-json writes every event as it happens
167
185
  --max-budget-usd <n> stop the run once it has spent n dollars
186
+ --mode <m> rpc | acp: serve a protocol on stdin/stdout, no terminal
187
+ --acp same as --mode acp
168
188
  --sandbox run the whole session inside a filesystem sandbox:
169
189
  the project is writable, the rest is read-only.
170
190
  needs bubblewrap (Linux) or sandbox-exec (macOS)
@@ -198,6 +218,8 @@ commands:
198
218
  /dcp /context /decompress /sweep
199
219
  /update /usage /sync
200
220
  /skills /preset /settings /proxy /roblox
221
+ /roblox login|logout|status|tunnel manages the Roblox connection: sign in with Roblox
222
+ (no API key), see what is connected, and link this machine to the hosted workspace.
201
223
 
202
224
  /stats shows session tokens, cost, and the prompt-cache hit rate; a high read
203
225
  total alone hides re-billed context, so the rate is what tells you the truth.
package/dist/brand.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const APP_TITLE = "HowToFix";
2
- export const APP_VERSION = "0.7.13";
2
+ export const APP_VERSION = "0.7.14";
3
3
  export const SYSTEM_APPEND = [
4
4
  // House style: RFC 2119 keywords in caps, one claim per line, the
5
5
  // highest-stakes rules at
package/dist/main.js CHANGED
@@ -121,9 +121,12 @@ async function main() {
121
121
  // Arguments are read and checked before the start-up repairs. A typo must not
122
122
  // rewrite settings.json, print a hint about a file the user never mentioned,
123
123
  // or reach the network: the answer to "you forgot the prompt" is one line.
124
+ // `howtofix acp` is the subcommand spelling of `--acp`, dispatched here rather
125
+ // than in `parseArgs` so the word is not mistaken for the start of a prompt.
126
+ const argvForParse = subArgv[0] === "acp" ? ["--acp", ...subArgv.slice(1)] : subArgv;
124
127
  let earlyArgs;
125
128
  try {
126
- earlyArgs = parseArgs(process.argv.slice(2));
129
+ earlyArgs = parseArgs(argvForParse);
127
130
  }
128
131
  catch (error) {
129
132
  const message = error instanceof Error ? error.message : String(error);
@@ -300,6 +303,14 @@ async function main() {
300
303
  `fork bombs, host shutdown) and the repeated-call guard are NOT enforced for this run. ` +
301
304
  `Turn it off with /settings, or by removing skipPermissions from settings.json.`);
302
305
  }
306
+ // A protocol server owns stdin and stdout from here on. It opens its own
307
+ // sessions (ACP opens one per `session/new`), so it returns before the one
308
+ // below is created.
309
+ if (args.mode) {
310
+ const { serveProtocol } = await import("./serve.js");
311
+ await serveProtocol(args.mode, { cwd, model: args.model, thinking: args.thinking });
312
+ return;
313
+ }
303
314
  const open = (options) => openSession({
304
315
  cwd,
305
316
  model: args.model,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `/roblox login | logout | status | tunnel` — the CLI's side of the Roblox
3
+ * connection.
4
+ *
5
+ * The bridge (`@netrodev/howtofix-roblox-bridge`) runs beside the agent and owns
6
+ * the Roblox sign-in, the virtual place and the tunnel. This module is a thin
7
+ * client for its loopback REST surface: it finds the bridge through the state file
8
+ * the bridge writes, asks it to do the thing, and reports what happened. Nothing
9
+ * Roblox-specific lives here, so the CLI stays small and the logic stays where the
10
+ * tests are.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { openInBrowser } from "./browser.js";
15
+ /** Where the bridge recorded its port and token, or undefined when none runs. */
16
+ export function readBridgeEndpoint(stateDir) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(join(stateDir, "roblox-bridge.json"), "utf8"));
19
+ if (typeof parsed.port === "number" && typeof parsed.token === "string")
20
+ return { port: parsed.port, token: parsed.token };
21
+ }
22
+ catch {
23
+ /* no bridge has started here */
24
+ }
25
+ return undefined;
26
+ }
27
+ const NO_BRIDGE = "the Roblox bridge is not running. Install RoToolbox with /preset, restart, and it starts with the agent.";
28
+ export class RobloxConnect {
29
+ endpoint;
30
+ fetcher;
31
+ open;
32
+ constructor(endpoint, fetcher = fetch, open = (url) => openInBrowser(url)) {
33
+ this.endpoint = endpoint;
34
+ this.fetcher = fetcher;
35
+ this.open = open;
36
+ }
37
+ base() {
38
+ if (!this.endpoint)
39
+ throw new Error(NO_BRIDGE);
40
+ return `http://127.0.0.1:${this.endpoint.port}`;
41
+ }
42
+ async get(path) {
43
+ const response = await this.fetcher(`${this.base()}${path}`);
44
+ const body = (await response.json().catch(() => ({})));
45
+ if (!response.ok)
46
+ throw new Error(body.error ?? `the bridge answered ${response.status}`);
47
+ return body;
48
+ }
49
+ async status() {
50
+ return this.get("/roblox/status");
51
+ }
52
+ /**
53
+ * Open Roblox's consent page and wait until the bridge reports a sign-in.
54
+ *
55
+ * The URL is always returned too: a machine with no browser (SSH, a container)
56
+ * cannot open it, and the person has to be able to paste it somewhere that can.
57
+ */
58
+ async login(options = {}) {
59
+ const status = await this.status();
60
+ if (!status.oauth.configured) {
61
+ throw new Error("no Roblox OAuth app is configured. Set HOWTOFIX_ROBLOX_CLIENT_ID (or use ROBLOX_API_KEY for an API key).");
62
+ }
63
+ const { url } = await this.get("/oauth/start");
64
+ options.onUrl?.(url);
65
+ const opened = await this.open(url);
66
+ const deadline = Date.now() + (options.timeoutMs ?? 5 * 60_000);
67
+ while (Date.now() < deadline) {
68
+ await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? 1000));
69
+ const now = await this.status();
70
+ if (now.oauth.signedIn) {
71
+ return { ok: true, message: `signed in${now.oauth.userName ? ` as ${now.oauth.userName}` : ""}`, url };
72
+ }
73
+ }
74
+ return {
75
+ ok: false,
76
+ message: opened ? "timed out waiting for the Roblox consent screen" : "could not open a browser; open the URL yourself, then run /roblox status",
77
+ url,
78
+ };
79
+ }
80
+ async logout() {
81
+ if (!this.endpoint)
82
+ throw new Error(NO_BRIDGE);
83
+ await this.get(`/oauth/logout?token=${encodeURIComponent(this.endpoint.token)}`);
84
+ }
85
+ async tunnel(action) {
86
+ if (!this.endpoint)
87
+ throw new Error(NO_BRIDGE);
88
+ const response = await this.fetcher(`${this.base()}/tunnel`, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json", "x-howtofix-token": this.endpoint.token },
91
+ body: JSON.stringify({ action }),
92
+ });
93
+ const body = (await response.json().catch(() => ({})));
94
+ if (!response.ok)
95
+ throw new Error(body.error ?? `the bridge answered ${response.status}`);
96
+ return body;
97
+ }
98
+ }
99
+ /** The sentence(s) `/roblox status` shows. */
100
+ export function describeStatus(status, tunnel) {
101
+ const lines = [];
102
+ if (status.oauth.signedIn) {
103
+ lines.push(`Roblox: signed in${status.oauth.userName ? ` as ${status.oauth.userName}` : ""}`);
104
+ lines.push(status.oauth.universeIds.length > 0
105
+ ? `experiences: ${status.oauth.universeIds.join(", ")}`
106
+ : "experiences: none granted yet (sign in again and tick one)");
107
+ }
108
+ else if (status.openCloud) {
109
+ lines.push("Roblox: using an API key (ROBLOX_API_KEY)");
110
+ }
111
+ else {
112
+ lines.push(`Roblox: not signed in${status.oauth.configured ? " — run /roblox login" : " — set HOWTOFIX_ROBLOX_CLIENT_ID to enable sign-in"}`);
113
+ }
114
+ lines.push(`virtual places: ${status.places.join(", ") || "none"} (answer tools when Studio is closed)`);
115
+ if (tunnel)
116
+ lines.push(`workspace tunnel: ${tunnel.open ? "connected" : tunnel.running ? "connecting" : "off"}`);
117
+ return lines.join("\n");
118
+ }
119
+ /** Parse `/roblox <sub> [args]`; undefined means the legacy RoToolbox status. */
120
+ export function parseRobloxSubcommand(arg) {
121
+ const [first = "", ...rest] = arg.trim().split(/\s+/);
122
+ if (first === "login" || first === "logout" || first === "status" || first === "tunnel")
123
+ return { sub: first, rest: rest.join(" ") };
124
+ return undefined;
125
+ }
package/dist/rpc.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `howtofix --mode rpc`: drive a session from another process, one JSON object
3
+ * per line.
4
+ *
5
+ * The same contract the harness's own RPC mode speaks for the commands that make
6
+ * a session useful from outside — `prompt`, `abort`, `get_state`,
7
+ * `get_last_assistant_text` — with every session event streamed on stdout in the
8
+ * `--output-format stream-json` shape. A host that already parses that stream
9
+ * needs nothing new, and a host that wants a conversation gets one without a
10
+ * terminal.
11
+ *
12
+ * The CLI opens sessions through `createAgentSession`, which the harness's
13
+ * `runRpcMode` does not accept (it wants a full runtime host with session
14
+ * switching and forking). Rather than rebuild the CLI around a different
15
+ * constructor, this drives the session the CLI already has, so every extension it
16
+ * loads — MCP, code mode, the Roblox bridge — is present in RPC mode too.
17
+ *
18
+ * stdout carries only protocol lines. Anything a person should read goes to
19
+ * stderr, because one stray line would corrupt the stream for the host.
20
+ */
21
+ import { createInterface } from "node:readline";
22
+ /** Run until the input closes. Resolves when in-flight work has finished. */
23
+ export function runRpc(options) {
24
+ const { agent } = options;
25
+ const write = (message) => options.output.write(`${JSON.stringify(message)}\n`);
26
+ const respond = (id, command, success, extra = {}) => write({ ...(id !== undefined ? { id } : {}), type: "response", command, success, ...extra });
27
+ const unsubscribe = agent.subscribe((event) => {
28
+ const encoded = options.encodeEvent ? options.encodeEvent(event) : `${JSON.stringify(event)}\n`;
29
+ if (encoded !== undefined)
30
+ options.output.write(encoded);
31
+ });
32
+ let running = null;
33
+ return new Promise((resolve) => {
34
+ const lines = createInterface({ input: options.input });
35
+ lines.on("line", (line) => {
36
+ if (line.trim() === "")
37
+ return;
38
+ let command;
39
+ try {
40
+ command = JSON.parse(line);
41
+ }
42
+ catch {
43
+ respond(undefined, "parse", false, { error: "a command must be one JSON object per line" });
44
+ return;
45
+ }
46
+ const { id, type } = command;
47
+ if (type === "prompt") {
48
+ if (typeof command.message !== "string" || command.message.trim() === "") {
49
+ respond(id, "prompt", false, { error: "prompt needs a message" });
50
+ return;
51
+ }
52
+ if (running) {
53
+ respond(id, "prompt", false, { error: "a prompt is already running; abort it or wait" });
54
+ return;
55
+ }
56
+ // Acknowledged before it runs: the host learns the command was accepted
57
+ // immediately and follows the work through the event stream.
58
+ respond(id, "prompt", true);
59
+ running = agent
60
+ .prompt(command.message)
61
+ .catch((error) => {
62
+ write({ type: "error", message: error instanceof Error ? error.message : String(error) });
63
+ })
64
+ .finally(() => {
65
+ running = null;
66
+ });
67
+ return;
68
+ }
69
+ if (type === "abort") {
70
+ void agent.abort().then(() => respond(id, "abort", true));
71
+ return;
72
+ }
73
+ if (type === "get_state") {
74
+ respond(id, "get_state", true, { data: { running: running !== null, ...(options.state?.() ?? {}) } });
75
+ return;
76
+ }
77
+ if (type === "get_last_assistant_text") {
78
+ respond(id, "get_last_assistant_text", true, { data: { text: agent.getLastAssistantText() ?? null } });
79
+ return;
80
+ }
81
+ respond(id, String(type ?? "unknown"), false, { error: `unknown command ${String(type)}` });
82
+ });
83
+ lines.on("close", () => {
84
+ void (running ?? Promise.resolve()).then(async () => {
85
+ unsubscribe();
86
+ await agent.dispose();
87
+ resolve();
88
+ });
89
+ });
90
+ });
91
+ }
package/dist/serve.js ADDED
@@ -0,0 +1,50 @@
1
+ import { APP_VERSION } from "./brand.js";
2
+ /** Adapt an opened session to the slice of it the protocol layers use. */
3
+ export function agentFromSession(opened) {
4
+ return {
5
+ prompt: (text) => opened.session.prompt(text),
6
+ abort: () => opened.session.abort(),
7
+ subscribe: (listener) => opened.session.subscribe((event) => listener(event)),
8
+ getLastAssistantText: () => opened.session.getLastAssistantText(),
9
+ lastStopReason: () => {
10
+ const last = opened.session.state.messages.at(-1);
11
+ return last?.role === "assistant" ? last.stopReason : undefined;
12
+ },
13
+ dispose: () => opened.dispose(),
14
+ };
15
+ }
16
+ /** Run the chosen protocol on stdin/stdout until stdin closes. */
17
+ export async function serveProtocol(mode, options) {
18
+ const { openSession } = await import("./session.js");
19
+ const open = (cwd) => openSession({
20
+ cwd,
21
+ model: options.model,
22
+ thinking: options.thinking,
23
+ continueSession: false,
24
+ noSession: false,
25
+ sessionPath: undefined,
26
+ // No terminal: a skill that asks a question must say so instead of waiting
27
+ // on a person who is not there.
28
+ interactive: false,
29
+ });
30
+ if (mode === "acp") {
31
+ const { runAcp } = await import("./acp.js");
32
+ await runAcp({
33
+ input: process.stdin,
34
+ output: process.stdout,
35
+ version: APP_VERSION,
36
+ createAgent: async (cwd) => agentFromSession(await open(cwd)),
37
+ });
38
+ return;
39
+ }
40
+ const { runRpc } = await import("./rpc.js");
41
+ const { jsonLine } = await import("./json-events.js");
42
+ const opened = await open(options.cwd);
43
+ await runRpc({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ agent: agentFromSession(opened),
47
+ encodeEvent: (event) => jsonLine(event),
48
+ state: () => ({ sessionId: opened.session.sessionId, model: opened.modelLabel, cwd: options.cwd }),
49
+ });
50
+ }
package/dist/ui.js CHANGED
@@ -502,7 +502,7 @@ export function slashCommands(session) {
502
502
  ["tools", "list active tools"],
503
503
  ["skills", "list loaded skills"],
504
504
  ["preset", "browse, install and remove preset skill bundles"],
505
- ["roblox", "show RoToolbox activation, MCP and manager status"],
505
+ ["roblox", "login | logout | status | tunnel on/off — Roblox sign-in and workspace link (alone: RoToolbox status)"],
506
506
  ["proxy", "route requests through a proxy, or show the current one"],
507
507
  ["export", "write the transcript to a markdown file"],
508
508
  ["copy", "copy the last answer to the clipboard"],
@@ -4673,6 +4673,42 @@ export async function runWorkbench(options) {
4673
4673
  return;
4674
4674
  }
4675
4675
  if (name === "roblox") {
4676
+ const { RobloxConnect, readBridgeEndpoint, describeStatus, parseRobloxSubcommand } = await import("./roblox-connect.js");
4677
+ const subcommand = parseRobloxSubcommand(arg);
4678
+ if (subcommand) {
4679
+ const connect = new RobloxConnect(readBridgeEndpoint(AGENT_DIR));
4680
+ try {
4681
+ if (subcommand.sub === "status") {
4682
+ const tunnel = await connect.tunnel("status").catch(() => undefined);
4683
+ note("roblox", describeStatus(await connect.status(), tunnel));
4684
+ }
4685
+ else if (subcommand.sub === "login") {
4686
+ note("roblox", "opening Roblox in your browser to sign in…");
4687
+ const result = await connect.login({
4688
+ onUrl: (url) => note("roblox", `if the browser did not open, go to:\n${url}`),
4689
+ });
4690
+ note("roblox", result.message);
4691
+ }
4692
+ else if (subcommand.sub === "logout") {
4693
+ await connect.logout();
4694
+ note("roblox", "signed out of Roblox");
4695
+ }
4696
+ else {
4697
+ const action = subcommand.rest === "off" ? "stop" : subcommand.rest === "on" || subcommand.rest === "" ? "start" : undefined;
4698
+ if (!action) {
4699
+ note("roblox", "usage: /roblox tunnel on | off");
4700
+ }
4701
+ else {
4702
+ const result = await connect.tunnel(action);
4703
+ note("roblox", result.message ?? "done");
4704
+ }
4705
+ }
4706
+ }
4707
+ catch (cause) {
4708
+ note("roblox", cause instanceof Error ? cause.message : String(cause));
4709
+ }
4710
+ return;
4711
+ }
4676
4712
  const entry = listCatalog(options.cwd).entries.find((candidate) => candidate.manifest.name === "rotoolbox");
4677
4713
  if (!entry || entry.source !== "installed") {
4678
4714
  note("roblox", "RoToolbox is not installed. Run /preset and install RoToolbox first.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "howtofixcli",
3
- "version": "0.7.13",
3
+ "version": "0.7.14",
4
4
  "description": "HowToFix coding agent. Workbench TUI, sessions, background agents.",
5
5
  "type": "module",
6
6
  "bin": {