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 +234 -0
- package/dist/args.js +22 -0
- package/dist/brand.js +1 -1
- package/dist/main.js +12 -1
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/serve.js +50 -0
- package/dist/ui.js +37 -1
- package/package.json +1 -1
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
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(
|
|
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", "
|
|
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.");
|