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.
- package/dist/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4931 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- 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
|
+
}
|
package/dist/activity.js
ADDED
|
@@ -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
|
+
}
|