huddle-cli 0.1.1 → 0.1.5
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/CHANGELOG.md +66 -0
- package/QUICKSTART.md +63 -2
- package/README.md +15 -0
- package/dist/adapters/codex.js +3 -0
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +775 -0
- package/dist/app/qr.js +47 -0
- package/dist/app/server.js +433 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +46 -4
- package/dist/connectors/cli.js +121 -0
- package/dist/connectors/github.js +162 -0
- package/dist/connectors/hub.js +234 -0
- package/dist/connectors/types.js +3 -0
- package/dist/mcp/context-tools.js +2 -0
- package/dist/mcp/server.js +42 -3
- package/dist/orchestrator/brain.js +3 -1
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +11 -5
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +32 -5
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +105 -19
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +82 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +511 -69
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/dashboard.js +92 -21
- package/dist/ui/export.js +1194 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/story.js +118 -93
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +31 -0
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/undo.js +131 -0
- package/dist/ui/watch.js +4 -0
- package/dist/update.js +84 -0
- package/package.json +3 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// An agent's own session, as a readable transcript: what it said, what it
|
|
2
|
+
// thought (when the CLI shows it), every tool call with its arguments in short,
|
|
3
|
+
// and what came back — trimmed. `huddle export --level full` puts one of these
|
|
4
|
+
// under each task of a run.
|
|
5
|
+
//
|
|
6
|
+
// The logs are whatever the agent CLI printed (.huddle/logs/<agent>-<task>.log,
|
|
7
|
+
// src/orchestrator/launcher.ts):
|
|
8
|
+
// claude --output-format stream-json assistant/user messages, tool_use ↔ tool_result
|
|
9
|
+
// codex exec --json item.started / item.completed (agent_message,
|
|
10
|
+
// command_execution, mcp_tool_call, file_change, …)
|
|
11
|
+
// cursor --output-format stream-json assistant text, thinking deltas, tool_call
|
|
12
|
+
// started/completed (shellToolCall, readToolCall, …)
|
|
13
|
+
// amp and qwen speak Claude's shape. Anything else — another CLI, a log cut
|
|
14
|
+
// short, a format that changed — is kept as plain text, never dropped.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
/** One tool result keeps this much; the rest is cut with a note saying how much. */
|
|
17
|
+
export const OUTPUT_MAX = { chars: 2_000, lines: 40 };
|
|
18
|
+
/** Arguments, summarized, keep this much. */
|
|
19
|
+
const INPUT_MAX = 600;
|
|
20
|
+
/** The launch prompt (long and the same for every task) keeps this much. */
|
|
21
|
+
const PROMPT_MAX = 3_000;
|
|
22
|
+
/** A raw (unparsed) log keeps this much. */
|
|
23
|
+
export const RAW_MAX = 200_000;
|
|
24
|
+
/** Logs bigger than this are read from the start only. */
|
|
25
|
+
const READ_MAX = 32 * 1024 * 1024;
|
|
26
|
+
/** Cut text to `chars` and `lines`, saying what was cut. */
|
|
27
|
+
export function cap(text, max = OUTPUT_MAX) {
|
|
28
|
+
let t = text.replace(/\s+$/, "");
|
|
29
|
+
const lines = t.split("\n");
|
|
30
|
+
let cut = "";
|
|
31
|
+
if (lines.length > max.lines) {
|
|
32
|
+
t = lines.slice(0, max.lines).join("\n");
|
|
33
|
+
cut = `${lines.length - max.lines} more line${lines.length - max.lines === 1 ? "" : "s"}`;
|
|
34
|
+
}
|
|
35
|
+
if (t.length > max.chars) {
|
|
36
|
+
const more = text.length - max.chars;
|
|
37
|
+
t = t.slice(0, max.chars);
|
|
38
|
+
cut = `${more.toLocaleString("en-US")} more characters`;
|
|
39
|
+
}
|
|
40
|
+
return cut ? `${t}\n… [cut: ${cut} — the whole output is in the agent's log]` : t;
|
|
41
|
+
}
|
|
42
|
+
const one = (s, n = INPUT_MAX) => {
|
|
43
|
+
const l = s.replace(/\s+/g, " ").trim();
|
|
44
|
+
return l.length <= n ? l : l.slice(0, n - 1) + "…";
|
|
45
|
+
};
|
|
46
|
+
const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : JSON.stringify(v));
|
|
47
|
+
/**
|
|
48
|
+
* Absolute worktree paths → repo-relative ones. Every tool call names
|
|
49
|
+
* /…/repo/.huddle/worktrees/claude-aCN9HG/src/csv.js; a reader wants src/csv.js.
|
|
50
|
+
*/
|
|
51
|
+
export function relPaths(s) {
|
|
52
|
+
return s.replace(CD_WT, "").replace(WT_SLASH, "").replace(WT, ".");
|
|
53
|
+
}
|
|
54
|
+
const WT_SRC = String.raw `[^\s"'\`]*?\.huddle[\\/]worktrees[\\/][^\\/\s"'\`]+`;
|
|
55
|
+
const CD_WT = new RegExp(String.raw `cd\s+(["']?)` + WT_SRC + String.raw `\1\s*(?:&&|;|\n)\s*`, "g");
|
|
56
|
+
const WT_SLASH = new RegExp(WT_SRC + String.raw `[\\/]`, "g");
|
|
57
|
+
const WT = new RegExp(WT_SRC, "g");
|
|
58
|
+
const tidy = (s) => relPaths(s);
|
|
59
|
+
/** "mcp__huddle__huddle_send" / "huddle-huddle_send" → "huddle_send". */
|
|
60
|
+
const toolName = (n) => n.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, "").replace(/^huddle-/, "");
|
|
61
|
+
/** A tool's arguments in a line: the command, the path, the message — not the JSON. */
|
|
62
|
+
export function summarizeInput(tool, input) {
|
|
63
|
+
const a = (input && typeof input === "object" ? input : {});
|
|
64
|
+
const t = toolName(tool).toLowerCase();
|
|
65
|
+
if (typeof input === "string")
|
|
66
|
+
return tidy(one(input));
|
|
67
|
+
// A command keeps its lines (a heredoc is a file being written), capped like an output.
|
|
68
|
+
if (a.command !== undefined)
|
|
69
|
+
return tidy(cap(Array.isArray(a.command) ? a.command.join(" ") : str(a.command), { chars: 1_500, lines: 30 }));
|
|
70
|
+
const file = a.file_path ?? a.path ?? a.notebook_path ?? a.targetFile;
|
|
71
|
+
if (/^(write|create)/.test(t) && file)
|
|
72
|
+
return `${tidy(str(file))} (${str(a.content ?? a.contents ?? a.streamContent).split("\n").length} lines)`;
|
|
73
|
+
if (/edit/.test(t) && file)
|
|
74
|
+
return tidy(str(file));
|
|
75
|
+
if (file && Object.keys(a).length <= 3)
|
|
76
|
+
return tidy(`${str(file)}${a.offset || a.limit ? ` (from line ${a.offset ?? 1}${a.limit ? `, ${a.limit} lines` : ""})` : ""}`);
|
|
77
|
+
if (a.pattern !== undefined || a.globPattern !== undefined)
|
|
78
|
+
return tidy(one(`${str(a.pattern ?? a.globPattern)}${a.path || a.targetDirectory ? " in " + str(a.path ?? a.targetDirectory) : ""}`));
|
|
79
|
+
// Huddle's own tools: who it's to and what it says — the conversation, in the agent's words.
|
|
80
|
+
if (a.to !== undefined && (a.message !== undefined || a.body !== undefined))
|
|
81
|
+
return one(`to ${str(a.to)}: ${str(a.message ?? a.body)}`, 2_000);
|
|
82
|
+
if (a.question !== undefined)
|
|
83
|
+
return one(`${a.to ? "to " + str(a.to) + ": " : ""}${str(a.question)}`, 2_000);
|
|
84
|
+
const keys = Object.keys(a);
|
|
85
|
+
if (!keys.length)
|
|
86
|
+
return "";
|
|
87
|
+
return tidy(one(keys.map((k) => `${k}: ${typeof a[k] === "string" ? a[k] : JSON.stringify(a[k])}`).join(" · ")));
|
|
88
|
+
}
|
|
89
|
+
/** Text out of an MCP / Claude tool result: strings, [{type:"text", text}], {content:[…]}. */
|
|
90
|
+
function resultText(v) {
|
|
91
|
+
if (v === undefined || v === null)
|
|
92
|
+
return "";
|
|
93
|
+
if (typeof v === "string")
|
|
94
|
+
return v;
|
|
95
|
+
if (Array.isArray(v))
|
|
96
|
+
return v
|
|
97
|
+
.map((x) => (typeof x === "string" ? x : x?.type === "text" || typeof x?.text === "string" ? str(x.text) : typeof x?.text?.text === "string" ? x.text.text : x?.type === "tool_reference" ? `(loaded ${toolName(str(x.tool_name))})` : x?.type === "image" ? "(an image)" : JSON.stringify(x)))
|
|
98
|
+
.join("\n");
|
|
99
|
+
if (typeof v === "object") {
|
|
100
|
+
const o = v;
|
|
101
|
+
if (o.content !== undefined)
|
|
102
|
+
return resultText(o.content);
|
|
103
|
+
if (o.text !== undefined)
|
|
104
|
+
return resultText(o.text);
|
|
105
|
+
if (o.output !== undefined)
|
|
106
|
+
return resultText(o.output);
|
|
107
|
+
return JSON.stringify(o, null, 1);
|
|
108
|
+
}
|
|
109
|
+
return String(v);
|
|
110
|
+
}
|
|
111
|
+
function json(line) {
|
|
112
|
+
const l = line.trim();
|
|
113
|
+
if (!l.startsWith("{"))
|
|
114
|
+
return undefined;
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(l);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Which CLI wrote this log, from its lines (the agent's id is only a hint: custom ids exist). */
|
|
123
|
+
export function sniffFormat(lines, hint = "") {
|
|
124
|
+
let claude = 0;
|
|
125
|
+
let codex = 0;
|
|
126
|
+
let cursor = 0;
|
|
127
|
+
for (const l of lines.slice(0, 400)) {
|
|
128
|
+
const e = json(l);
|
|
129
|
+
if (!e || typeof e !== "object")
|
|
130
|
+
continue;
|
|
131
|
+
if (/^(thread\.started|turn\.(started|completed|failed)|item\.(started|updated|completed))$/.test(str(e.type)))
|
|
132
|
+
codex++;
|
|
133
|
+
else if (e.type === "tool_call" || (e.type === "thinking" && e.subtype) || e.model_call_id || e.timestamp_ms)
|
|
134
|
+
cursor++;
|
|
135
|
+
else if ((e.type === "assistant" || e.type === "user") && e.message && Array.isArray(e.message.content) && (e.message.id || e.parent_tool_use_id !== undefined || e.uuid))
|
|
136
|
+
claude++;
|
|
137
|
+
else if (e.type === "system" && e.subtype === "init" && Array.isArray(e.tools))
|
|
138
|
+
claude++;
|
|
139
|
+
else if (e.type === "assistant" || e.type === "user" || e.type === "result")
|
|
140
|
+
claude += 0.5;
|
|
141
|
+
}
|
|
142
|
+
const best = Math.max(claude, codex, cursor);
|
|
143
|
+
if (!best)
|
|
144
|
+
return "raw";
|
|
145
|
+
const h = hint.split("#")[0];
|
|
146
|
+
if (h === "cursor" && cursor === best)
|
|
147
|
+
return "cursor";
|
|
148
|
+
if (h === "codex" && codex === best)
|
|
149
|
+
return "codex";
|
|
150
|
+
return codex === best ? "codex" : cursor === best ? "cursor" : "claude";
|
|
151
|
+
}
|
|
152
|
+
/** Consecutive non-JSON lines (stderr, a banner) as one note. */
|
|
153
|
+
function notes(lines, into) {
|
|
154
|
+
const t = lines.join("\n").trim();
|
|
155
|
+
if (t)
|
|
156
|
+
into.push({ kind: "note", text: cap(tidy(t), { chars: 1_000, lines: 15 }) });
|
|
157
|
+
lines.length = 0;
|
|
158
|
+
}
|
|
159
|
+
function parseClaude(lines) {
|
|
160
|
+
const turns = [];
|
|
161
|
+
const calls = new Map();
|
|
162
|
+
const loose = [];
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const e = json(line);
|
|
165
|
+
if (!e) {
|
|
166
|
+
if (line.trim())
|
|
167
|
+
loose.push(line);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
notes(loose, turns);
|
|
171
|
+
if (e.type === "assistant" && Array.isArray(e.message?.content)) {
|
|
172
|
+
for (const c of e.message.content) {
|
|
173
|
+
if (c?.type === "text" && str(c.text).trim())
|
|
174
|
+
turns.push({ kind: "text", text: str(c.text).trim() });
|
|
175
|
+
else if (c?.type === "thinking" && str(c.thinking).trim())
|
|
176
|
+
turns.push({ kind: "thinking", text: str(c.thinking).trim() });
|
|
177
|
+
else if (c?.type === "tool_use") {
|
|
178
|
+
const name = toolName(str(c.name));
|
|
179
|
+
const t = { kind: "tool", tool: name, text: "", input: summarizeInput(str(c.name), c.input) };
|
|
180
|
+
// What it wrote: a Write's content, an Edit's change — trimmed like an output.
|
|
181
|
+
const i = c.input ?? {};
|
|
182
|
+
if (/^(Write|create_file)$/i.test(name) && typeof i.content === "string")
|
|
183
|
+
t.output = cap(i.content);
|
|
184
|
+
else if (/Edit$/i.test(name) && (typeof i.old_string === "string" || typeof i.new_string === "string"))
|
|
185
|
+
t.output = cap(`${str(i.old_string).split("\n").map((l) => "- " + l).join("\n")}\n${str(i.new_string).split("\n").map((l) => "+ " + l).join("\n")}`);
|
|
186
|
+
else if (/MultiEdit$/i.test(name) && Array.isArray(i.edits))
|
|
187
|
+
t.output = cap(`${i.edits.length} edit(s)`);
|
|
188
|
+
t.text = `${name}${t.input ? " " + t.input : ""}`;
|
|
189
|
+
if (typeof i.description === "string" && i.description.trim())
|
|
190
|
+
t.about = one(i.description, 200);
|
|
191
|
+
turns.push(t);
|
|
192
|
+
if (c.id)
|
|
193
|
+
calls.set(str(c.id), t);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (e.type === "user" && e.message) {
|
|
198
|
+
const content = Array.isArray(e.message.content) ? e.message.content : [{ type: "text", text: str(e.message.content) }];
|
|
199
|
+
for (const c of content) {
|
|
200
|
+
if (c?.type === "tool_result") {
|
|
201
|
+
const t = calls.get(str(c.tool_use_id));
|
|
202
|
+
const out = tidy(resultText(c.content));
|
|
203
|
+
const wrote = t?.output;
|
|
204
|
+
if (t) {
|
|
205
|
+
t.error = c.is_error === true || undefined;
|
|
206
|
+
// A Write's result ("File created successfully…") says less than what it wrote.
|
|
207
|
+
if (!wrote || t.error)
|
|
208
|
+
t.output = cap(out);
|
|
209
|
+
}
|
|
210
|
+
else
|
|
211
|
+
turns.push({ kind: "tool", tool: "?", text: "(result of an earlier call)", output: cap(out), ...(c.is_error ? { error: true } : {}) });
|
|
212
|
+
}
|
|
213
|
+
else if (c?.type === "text" && str(c.text).trim())
|
|
214
|
+
turns.push({ kind: turns.length ? "note" : "prompt", text: cap(tidy(str(c.text)), { chars: PROMPT_MAX, lines: 80 }) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (e.type === "result") {
|
|
218
|
+
const bits = [e.subtype && e.subtype !== "success" ? str(e.subtype) : "finished", e.duration_ms ? `${Math.round(Number(e.duration_ms) / 1000)}s` : "", e.num_turns ? `${e.num_turns} turns` : "", e.is_error ? "with an error" : ""];
|
|
219
|
+
turns.push({ kind: "end", text: bits.filter(Boolean).join(" · ") });
|
|
220
|
+
}
|
|
221
|
+
else if (e.type === "system" && e.subtype === "init") {
|
|
222
|
+
turns.push({ kind: "note", text: `session started${e.model ? " · " + str(e.model) : ""}` });
|
|
223
|
+
}
|
|
224
|
+
// hook_*, thinking_tokens, rate_limit_event, tool_progress: plumbing.
|
|
225
|
+
}
|
|
226
|
+
notes(loose, turns);
|
|
227
|
+
return turns;
|
|
228
|
+
}
|
|
229
|
+
function parseCodex(lines) {
|
|
230
|
+
const turns = [];
|
|
231
|
+
const loose = [];
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
const e = json(line);
|
|
234
|
+
if (!e) {
|
|
235
|
+
// "Reading additional input from stdin..." is codex reading its prompt: noise.
|
|
236
|
+
if (line.trim() && !/^Reading (additional )?(input|prompt) from stdin/i.test(line.trim()))
|
|
237
|
+
loose.push(line);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
notes(loose, turns);
|
|
241
|
+
if (e.type === "item.completed" && e.item) {
|
|
242
|
+
const it = e.item;
|
|
243
|
+
switch (it.type) {
|
|
244
|
+
case "agent_message":
|
|
245
|
+
case "assistant_message":
|
|
246
|
+
if (str(it.text).trim())
|
|
247
|
+
turns.push({ kind: "text", text: str(it.text).trim() });
|
|
248
|
+
break;
|
|
249
|
+
case "reasoning":
|
|
250
|
+
if (str(it.text ?? it.summary).trim())
|
|
251
|
+
turns.push({ kind: "thinking", text: resultText(it.text ?? it.summary).trim() });
|
|
252
|
+
break;
|
|
253
|
+
case "command_execution": {
|
|
254
|
+
const input = tidy(cap(str(it.command).replace(/^\/bin\/(?:ba|z)?sh -lc /, "").replace(/^(["'])([\s\S]*)\1$/, "$2"), { chars: 1_500, lines: 30 }));
|
|
255
|
+
const code = it.exit_code;
|
|
256
|
+
turns.push({ kind: "tool", tool: "shell", text: `shell ${input}`, input, output: cap(tidy(str(it.aggregated_output))) + (typeof code === "number" && code !== 0 ? `\n(exit ${code})` : ""), ...(typeof code === "number" && code !== 0 ? { error: true } : {}) });
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case "mcp_tool_call": {
|
|
260
|
+
const name = toolName(str(it.tool));
|
|
261
|
+
const input = summarizeInput(name, it.arguments);
|
|
262
|
+
const err = it.error ? resultText(it.error.message ?? it.error) : "";
|
|
263
|
+
turns.push({ kind: "tool", tool: name, text: `${name}${input ? " " + input : ""}`, input, output: cap(tidy(err || resultText(it.result))), ...(err || it.status === "failed" ? { error: true } : {}) });
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case "file_change": {
|
|
267
|
+
const ch = Array.isArray(it.changes) ? it.changes : [];
|
|
268
|
+
const input = ch.map((c) => `${str(c.kind) || "edit"} ${tidy(str(c.path))}`).join(", ");
|
|
269
|
+
turns.push({ kind: "tool", tool: "edit", text: `edit ${input}`, input, ...(it.status === "failed" ? { error: true } : {}) });
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "web_search":
|
|
273
|
+
turns.push({ kind: "tool", tool: "web_search", text: `web_search ${one(str(it.query))}`, input: one(str(it.query)) });
|
|
274
|
+
break;
|
|
275
|
+
case "todo_list":
|
|
276
|
+
turns.push({ kind: "note", text: `plan: ${(Array.isArray(it.items) ? it.items : []).map((x) => `${x.completed ? "✓" : "○"} ${str(x.text)}`).join(" · ")}` });
|
|
277
|
+
break;
|
|
278
|
+
case "error":
|
|
279
|
+
turns.push({ kind: "note", text: `error: ${str(it.message)}` });
|
|
280
|
+
break;
|
|
281
|
+
default:
|
|
282
|
+
turns.push({ kind: "note", text: `[${str(it.type)}] ${one(JSON.stringify(it), 400)}` });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (e.type === "turn.completed") {
|
|
286
|
+
const u = e.usage ?? {};
|
|
287
|
+
const tok = (Number(u.input_tokens) || 0) + (Number(u.output_tokens) || 0);
|
|
288
|
+
turns.push({ kind: "end", text: `turn finished${tok ? ` · ${tok.toLocaleString("en-US")} tokens` : ""}` });
|
|
289
|
+
}
|
|
290
|
+
else if (e.type === "turn.failed" || e.type === "error") {
|
|
291
|
+
turns.push({ kind: "note", text: `error: ${str(e.error?.message ?? e.message ?? e.error)}` });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
notes(loose, turns);
|
|
295
|
+
return turns;
|
|
296
|
+
}
|
|
297
|
+
/** cursor-agent's tool_call: { <name>ToolCall: { args, result: { success | failure } } }. */
|
|
298
|
+
function cursorTool(tc) {
|
|
299
|
+
const key = Object.keys(tc).find((k) => /ToolCall$/.test(k)) ?? Object.keys(tc)[0] ?? "tool";
|
|
300
|
+
const body = tc[key] ?? {};
|
|
301
|
+
const args = body.args ?? {};
|
|
302
|
+
let name = key.replace(/ToolCall$/, "");
|
|
303
|
+
let input;
|
|
304
|
+
if (name === "mcp") {
|
|
305
|
+
name = toolName(str(args.toolName ?? args.name));
|
|
306
|
+
input = summarizeInput(name, args.args);
|
|
307
|
+
}
|
|
308
|
+
else if (name === "shell")
|
|
309
|
+
input = tidy(cap(str(args.command), { chars: 1_500, lines: 30 }));
|
|
310
|
+
else if (name === "edit" || name === "write")
|
|
311
|
+
input = tidy(str(args.path));
|
|
312
|
+
else
|
|
313
|
+
input = summarizeInput(name, args);
|
|
314
|
+
const r = body.result ?? {};
|
|
315
|
+
const ok = r.success;
|
|
316
|
+
const bad = r.failure ?? r.error ?? r.rejected;
|
|
317
|
+
const res = ok ?? bad;
|
|
318
|
+
let output = "";
|
|
319
|
+
if (res && typeof res === "object") {
|
|
320
|
+
if (name === "shell")
|
|
321
|
+
output = [str(res.stdout), str(res.stderr)].filter((x) => x.trim()).join("\n") + (res.exitCode ? `\n(exit ${res.exitCode})` : "");
|
|
322
|
+
else if (name === "edit" || name === "write")
|
|
323
|
+
output = str(res.diffString) || str(res.message);
|
|
324
|
+
else if (name === "glob")
|
|
325
|
+
output = (Array.isArray(res.files) ? res.files : []).join("\n");
|
|
326
|
+
else if (name === "read")
|
|
327
|
+
output = str(res.content);
|
|
328
|
+
else
|
|
329
|
+
output = resultText(res);
|
|
330
|
+
}
|
|
331
|
+
else if (res !== undefined)
|
|
332
|
+
output = str(res);
|
|
333
|
+
const error = !!bad || (name === "shell" && Number(res?.exitCode) > 0) || ok?.isError === true;
|
|
334
|
+
return { kind: "tool", tool: name, text: `${name}${input ? " " + input : ""}`, input, output: cap(tidy(output)), ...(error ? { error: true } : {}) };
|
|
335
|
+
}
|
|
336
|
+
function parseCursor(lines) {
|
|
337
|
+
const turns = [];
|
|
338
|
+
const loose = [];
|
|
339
|
+
let thinking = "";
|
|
340
|
+
const flush = () => {
|
|
341
|
+
if (thinking.trim())
|
|
342
|
+
turns.push({ kind: "thinking", text: thinking.trim() });
|
|
343
|
+
thinking = "";
|
|
344
|
+
};
|
|
345
|
+
for (const line of lines) {
|
|
346
|
+
const e = json(line);
|
|
347
|
+
if (!e) {
|
|
348
|
+
if (line.trim())
|
|
349
|
+
loose.push(line);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
notes(loose, turns);
|
|
353
|
+
const ts = Number(e.timestamp_ms) || undefined;
|
|
354
|
+
if (e.type === "thinking") {
|
|
355
|
+
if (e.subtype === "delta")
|
|
356
|
+
thinking += str(e.text);
|
|
357
|
+
else
|
|
358
|
+
flush();
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
flush();
|
|
362
|
+
if (e.type === "assistant") {
|
|
363
|
+
const text = resultText(e.message?.content).trim();
|
|
364
|
+
if (text)
|
|
365
|
+
turns.push({ kind: "text", text, ...(ts ? { ts } : {}) });
|
|
366
|
+
}
|
|
367
|
+
else if (e.type === "user") {
|
|
368
|
+
const text = resultText(e.message?.content).trim();
|
|
369
|
+
if (text)
|
|
370
|
+
turns.push({ kind: turns.some((t) => t.kind === "prompt") ? "note" : "prompt", text: cap(tidy(text), { chars: PROMPT_MAX, lines: 80 }) });
|
|
371
|
+
}
|
|
372
|
+
else if (e.type === "tool_call" && e.subtype === "completed" && e.tool_call) {
|
|
373
|
+
turns.push({ ...cursorTool(e.tool_call), ...(ts ? { ts } : {}) });
|
|
374
|
+
}
|
|
375
|
+
else if (e.type === "result") {
|
|
376
|
+
turns.push({ kind: "end", text: [e.is_error ? "finished with an error" : "finished", e.duration_ms ? `${Math.round(Number(e.duration_ms) / 1000)}s` : ""].filter(Boolean).join(" · ") });
|
|
377
|
+
}
|
|
378
|
+
else if (e.type === "system" && e.subtype === "init") {
|
|
379
|
+
turns.push({ kind: "note", text: `session started${e.model ? " · " + str(e.model) : ""}` });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
flush();
|
|
383
|
+
notes(loose, turns);
|
|
384
|
+
return turns;
|
|
385
|
+
}
|
|
386
|
+
/** A log's text → turns. Unknown formats are kept as one raw block (capped). */
|
|
387
|
+
export function parseLog(text, agentHint = "") {
|
|
388
|
+
const lines = text.split(/\r?\n/);
|
|
389
|
+
const format = sniffFormat(lines, agentHint);
|
|
390
|
+
try {
|
|
391
|
+
const turns = format === "claude" ? parseClaude(lines) : format === "codex" ? parseCodex(lines) : format === "cursor" ? parseCursor(lines) : [];
|
|
392
|
+
if (format !== "raw" && turns.length)
|
|
393
|
+
return { format, turns };
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
/* a shape we didn't expect: fall back to the raw text below */
|
|
397
|
+
}
|
|
398
|
+
const raw = text.trim();
|
|
399
|
+
return { format: "raw", turns: raw ? [{ kind: "note", text: cap(tidy(raw), { chars: RAW_MAX, lines: 4_000 }) }] : [] };
|
|
400
|
+
}
|
|
401
|
+
/** Read and parse one log file; null when it isn't there. */
|
|
402
|
+
export function readLog(file, agentHint = "") {
|
|
403
|
+
let st;
|
|
404
|
+
try {
|
|
405
|
+
st = fs.statSync(file);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
if (!st.isFile())
|
|
411
|
+
return null;
|
|
412
|
+
let text;
|
|
413
|
+
let partial = false;
|
|
414
|
+
if (st.size > READ_MAX) {
|
|
415
|
+
const fd = fs.openSync(file, "r");
|
|
416
|
+
try {
|
|
417
|
+
const buf = Buffer.alloc(READ_MAX);
|
|
418
|
+
const n = fs.readSync(fd, buf, 0, READ_MAX, 0);
|
|
419
|
+
text = buf.subarray(0, n).toString("utf8");
|
|
420
|
+
text = text.slice(0, text.lastIndexOf("\n") + 1);
|
|
421
|
+
partial = true;
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
fs.closeSync(fd);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else
|
|
428
|
+
text = fs.readFileSync(file, "utf8");
|
|
429
|
+
const parsed = parseLog(text, agentHint);
|
|
430
|
+
if (partial)
|
|
431
|
+
parsed.turns.push({ kind: "note", text: `… the log is ${Math.round(st.size / 1024 / 1024)} MB; only its first ${READ_MAX / 1024 / 1024} MB are shown` });
|
|
432
|
+
return { ...parsed, bytes: st.size, ...(partial ? { partial } : {}) };
|
|
433
|
+
}
|
|
434
|
+
//# sourceMappingURL=transcript.js.map
|
package/dist/ui/undo.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// `huddle undo`: take back what a run merged. It adds one commit that reverts
|
|
2
|
+
// the run's commits (history stays; nothing is force-pushed), so an undo can
|
|
3
|
+
// itself be undone with git revert.
|
|
4
|
+
//
|
|
5
|
+
// huddle undo the last run (asks first; --yes doesn't)
|
|
6
|
+
// huddle undo --run 3 that run (numbers from `huddle runs`)
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import readline from "node:readline";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { C } from "./term.js";
|
|
11
|
+
import { pickRun, splitRuns } from "./story.js";
|
|
12
|
+
const git = (root, args) => {
|
|
13
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8" });
|
|
14
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
|
|
15
|
+
};
|
|
16
|
+
/** What undoing run `which` would revert, or why it can't. */
|
|
17
|
+
export function undoPlan(root, events, which) {
|
|
18
|
+
const runs = splitRuns(events);
|
|
19
|
+
const run = pickRun(runs, which);
|
|
20
|
+
if (!run)
|
|
21
|
+
return { ok: false, why: runs.length ? `no run ${which} (see huddle runs)` : "no runs here yet" };
|
|
22
|
+
const base = { index: run.index, goal: run.goal, id: run.id };
|
|
23
|
+
// An undo is recorded when it happens, so it lands in whichever run is the
|
|
24
|
+
// latest by then: look in every run for one that names this run.
|
|
25
|
+
const undone = runs.some((r) => r.events.some((e) => e.type === "git.undone" && (e.p.id ? e.p.id === run.id : Number(e.p.run) === run.index)));
|
|
26
|
+
if (undone)
|
|
27
|
+
return { ...base, ok: false, why: `run ${run.index} is already undone` };
|
|
28
|
+
const g = run.events.filter((e) => e.type === "git.range").at(-1);
|
|
29
|
+
if (!g)
|
|
30
|
+
return { ...base, ok: false, why: `run ${run.index} has no record of its commits (it ran before Huddle 0.1.4, or didn't finish)` };
|
|
31
|
+
const start = String(g.p.start ?? "");
|
|
32
|
+
const end = String(g.p.end ?? "");
|
|
33
|
+
if (!start || !end || start === end)
|
|
34
|
+
return { ...base, ok: false, why: `run ${run.index} didn't merge anything` };
|
|
35
|
+
if (!git(root, ["merge-base", "--is-ancestor", end, "HEAD"]).ok)
|
|
36
|
+
return { ...base, ok: false, why: `run ${run.index}'s commits aren't on this branch (${git(root, ["symbolic-ref", "--short", "HEAD"]).out || "detached HEAD"})` };
|
|
37
|
+
const commits = git(root, ["log", "--format=%h %s", `${start}..${end}`]).out.split("\n").filter(Boolean);
|
|
38
|
+
return { ...base, ok: true, start, end, commits };
|
|
39
|
+
}
|
|
40
|
+
/** The run's goal as one short line, for the revert commit and the screen. */
|
|
41
|
+
export const undoTitle = (plan) => String(plan.goal ?? "").split("\n")[0].slice(0, 80);
|
|
42
|
+
/**
|
|
43
|
+
* Carries out a plan from undoPlan: the revert commit, then the git.undone
|
|
44
|
+
* event that marks the run undone. `huddle undo` and the dashboard both go
|
|
45
|
+
* through here, so an undo is recorded the same way wherever you clicked it.
|
|
46
|
+
*/
|
|
47
|
+
export function undoRun(root, db, plan) {
|
|
48
|
+
if (!plan.ok)
|
|
49
|
+
return { ok: false, why: plan.why };
|
|
50
|
+
const goal = undoTitle(plan);
|
|
51
|
+
const r = revertRange(root, plan.start, plan.end, `huddle: undo run ${plan.index} (${goal})\n\nReverts ${plan.start.slice(0, 7)}..${plan.end.slice(0, 7)}.`);
|
|
52
|
+
if (!r.ok)
|
|
53
|
+
return { ok: false, why: r.why };
|
|
54
|
+
db.publish("git.undone", { payload: { run: plan.index, ...(plan.id ? { id: plan.id } : {}), commit: r.commit } });
|
|
55
|
+
const up = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
56
|
+
const pushed = up.ok && git(root, ["merge-base", "--is-ancestor", plan.end, up.out]).ok;
|
|
57
|
+
return { ok: true, commit: r.commit, ...(pushed ? { pushedTo: up.out } : {}) };
|
|
58
|
+
}
|
|
59
|
+
/** Reverts start..end as one commit. A conflict (later work built on it) leaves the tree as it was. */
|
|
60
|
+
export function revertRange(root, start, end, message) {
|
|
61
|
+
const dirty = git(root, ["status", "--porcelain", "--untracked-files=no"]).out;
|
|
62
|
+
if (dirty)
|
|
63
|
+
return { ok: false, why: "you have uncommitted changes — commit or stash them first" };
|
|
64
|
+
const r = git(root, ["revert", "--no-edit", "--no-commit", `${start}..${end}`]);
|
|
65
|
+
if (!r.ok) {
|
|
66
|
+
git(root, ["revert", "--abort"]);
|
|
67
|
+
git(root, ["reset", "--hard", "-q", "HEAD"]);
|
|
68
|
+
return { ok: false, why: "later commits build on this run's work, so it can't be taken back cleanly — revert by hand: git revert " + `${start.slice(0, 7)}..${end.slice(0, 7)}` };
|
|
69
|
+
}
|
|
70
|
+
if (!git(root, ["status", "--porcelain", "--untracked-files=no"]).out) {
|
|
71
|
+
git(root, ["revert", "--quit"]);
|
|
72
|
+
return { ok: false, why: "nothing to undo: the run's changes are already gone from this branch" };
|
|
73
|
+
}
|
|
74
|
+
const c = git(root, ["commit", "-q", "--no-verify", "-m", message]);
|
|
75
|
+
if (!c.ok) {
|
|
76
|
+
git(root, ["revert", "--abort"]);
|
|
77
|
+
return { ok: false, why: c.err.split("\n")[0] || "git commit failed" };
|
|
78
|
+
}
|
|
79
|
+
git(root, ["revert", "--quit"]);
|
|
80
|
+
return { ok: true, commit: git(root, ["rev-parse", "--short", "HEAD"]).out };
|
|
81
|
+
}
|
|
82
|
+
async function confirm(q) {
|
|
83
|
+
if (!process.stdin.isTTY)
|
|
84
|
+
return false;
|
|
85
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
86
|
+
const a = await new Promise((r) => rl.question(q, r));
|
|
87
|
+
rl.close();
|
|
88
|
+
return /^y(es)?$/i.test(a.trim());
|
|
89
|
+
}
|
|
90
|
+
export async function runUndo(args) {
|
|
91
|
+
const { dbPath, repoRoot } = await import("./app.js");
|
|
92
|
+
if (!fs.existsSync(dbPath())) {
|
|
93
|
+
console.log(C.faint(" no runs here yet — nothing to undo."));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
const i = args.indexOf("--run");
|
|
97
|
+
const which = i >= 0 ? args[i + 1] : args.find((a) => /^\d+$/.test(a));
|
|
98
|
+
const { RoomDB } = await import("../room/db.js");
|
|
99
|
+
const db = new RoomDB(dbPath());
|
|
100
|
+
try {
|
|
101
|
+
const root = repoRoot();
|
|
102
|
+
const plan = undoPlan(root, db.allEvents(), which);
|
|
103
|
+
if (!plan.ok) {
|
|
104
|
+
console.log(C.red("✗ ") + plan.why);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
console.log(`Undo run ${plan.index}: ${C.bold(undoTitle(plan))}`);
|
|
108
|
+
console.log(C.faint(` it reverts ${plan.commits.length} commit(s), as one new commit:`));
|
|
109
|
+
for (const c of plan.commits.slice(0, 12))
|
|
110
|
+
console.log(C.faint(` ${c}`));
|
|
111
|
+
if (plan.commits.length > 12)
|
|
112
|
+
console.log(C.faint(` … and ${plan.commits.length - 12} more`));
|
|
113
|
+
if (!args.includes("--yes") && !args.includes("-y") && !(await confirm(" Undo it? [y/N] "))) {
|
|
114
|
+
console.log(C.faint(process.stdin.isTTY ? " left as it is." : " not a terminal: add --yes to undo."));
|
|
115
|
+
return process.stdin.isTTY ? 0 : 1;
|
|
116
|
+
}
|
|
117
|
+
const r = undoRun(root, db, plan);
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
console.log(C.red("✗ ") + r.why);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
console.log(`${C.green("✓")} undone in ${r.commit}. ${C.faint(`Changed your mind? git revert ${r.commit}`)}`);
|
|
123
|
+
if (r.pushedTo)
|
|
124
|
+
console.log(C.faint(` the run was already pushed to ${r.pushedTo}: git push sends the undo there too`));
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
db.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=undo.js.map
|
package/dist/ui/watch.js
CHANGED
|
@@ -107,6 +107,10 @@ export function activityOf(e) {
|
|
|
107
107
|
agent: p.to ?? who,
|
|
108
108
|
text: `${C.violet("◎")} ${p.kind === "radar" ? "radar finding" : `${p.by}'s buddy note`} ${p.how === "ignored" ? C.yellow("ignored") : C.green(p.how === "replied" ? "answered" : "addressed in code")}: ${clip(p.body, 100)}`,
|
|
109
109
|
};
|
|
110
|
+
case "run.busy":
|
|
111
|
+
return { agent: who, text: `${C.faint("⧗ running a long command")} · quiet ${Math.round((Number(p.quietMs) || 0) / 60_000) || "<1"} min, not silent: ${clip(p.why, 80)}` };
|
|
112
|
+
case "run.budget":
|
|
113
|
+
return { text: `${C.yellow("! token budget")} ${p.level === "hard" ? "ran over — agents stopped" : "nearly spent — no new work"}${(p.left ?? []).length ? ` · left undone: ${clip((p.left ?? []).join(" · "), 80)}` : ""}` };
|
|
110
114
|
case "run.pair":
|
|
111
115
|
return { agent: who, text: `${C.violet("⇉")} pairs with ${p.with} on “${clip(p.title, 60)}”: ${clip(p.part, 80)}` };
|
|
112
116
|
case "qa.round":
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// `huddle update`: get the newest Huddle from npm. Found live (0.1.3): the user
|
|
2
|
+
// typed `huddle --update`, the way other CLIs work, and got "unknown command".
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { packageInfo } from "./preflight.js";
|
|
5
|
+
import { spawnToolSync } from "./platform.js";
|
|
6
|
+
import { C } from "./ui/term.js";
|
|
7
|
+
/** -1, 0 or 1, comparing x.y.z (a pre-release tag sorts before its release). */
|
|
8
|
+
export function compareVersions(a, b) {
|
|
9
|
+
const parse = (v) => {
|
|
10
|
+
const [core, pre] = v.trim().replace(/^v/, "").split("-", 2);
|
|
11
|
+
return { nums: core.split(".").map((n) => Number(n) || 0), pre: pre ?? "" };
|
|
12
|
+
};
|
|
13
|
+
const x = parse(a);
|
|
14
|
+
const y = parse(b);
|
|
15
|
+
for (let i = 0; i < 3; i++) {
|
|
16
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
|
|
17
|
+
if (d)
|
|
18
|
+
return Math.sign(d);
|
|
19
|
+
}
|
|
20
|
+
if (x.pre === y.pre)
|
|
21
|
+
return 0;
|
|
22
|
+
if (!x.pre)
|
|
23
|
+
return 1;
|
|
24
|
+
if (!y.pre)
|
|
25
|
+
return -1;
|
|
26
|
+
return x.pre < y.pre ? -1 : 1;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What `huddle update` should do. Only an npm install (a path through
|
|
30
|
+
* node_modules/huddle-cli) is replaced with npm; a source checkout is told how
|
|
31
|
+
* to update itself instead of getting a second, global copy.
|
|
32
|
+
*/
|
|
33
|
+
export function updatePlan(current, latest, cliPath) {
|
|
34
|
+
if (!latest)
|
|
35
|
+
return { kind: "offline" };
|
|
36
|
+
const c = compareVersions(current, latest);
|
|
37
|
+
if (c === 0)
|
|
38
|
+
return { kind: "latest", current };
|
|
39
|
+
if (c > 0)
|
|
40
|
+
return { kind: "ahead", current, latest };
|
|
41
|
+
if (!/[\\/]node_modules[\\/]huddle-cli[\\/]/.test(cliPath)) {
|
|
42
|
+
return { kind: "source", current, latest, dir: cliPath.replace(/[\\/]dist[\\/].*$/, "") };
|
|
43
|
+
}
|
|
44
|
+
return { kind: "install", current, latest };
|
|
45
|
+
}
|
|
46
|
+
/** The newest huddle-cli on npm, or null when npm can't be reached. */
|
|
47
|
+
export function latestOnNpm() {
|
|
48
|
+
const r = spawnToolSync(["npm", "view", "huddle-cli", "version", "--prefer-online"], { encoding: "utf8", timeout: 30_000 });
|
|
49
|
+
const v = r.status === 0 ? r.stdout.trim().split(/\s+/).pop() ?? "" : "";
|
|
50
|
+
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
51
|
+
}
|
|
52
|
+
export function runUpdate() {
|
|
53
|
+
const current = packageInfo().version;
|
|
54
|
+
console.log(C.faint(`huddle ${current} · checking npm…`));
|
|
55
|
+
const plan = updatePlan(current, latestOnNpm(), fileURLToPath(import.meta.url));
|
|
56
|
+
switch (plan.kind) {
|
|
57
|
+
case "offline":
|
|
58
|
+
console.log(C.red("✗ couldn't reach npm.") + " Check your connection, or run: npm install -g huddle-cli@latest");
|
|
59
|
+
return 1;
|
|
60
|
+
case "latest":
|
|
61
|
+
console.log(C.green("✓") + ` huddle ${plan.current} is the newest version.`);
|
|
62
|
+
return 0;
|
|
63
|
+
case "ahead":
|
|
64
|
+
console.log(C.green("✓") + ` huddle ${plan.current} is newer than npm's ${plan.latest} (a preview build). Nothing to do.`);
|
|
65
|
+
return 0;
|
|
66
|
+
case "source":
|
|
67
|
+
console.log(`huddle ${plan.latest} is out; this huddle runs from a source checkout (${plan.dir}).`);
|
|
68
|
+
console.log(C.faint(" update it there: git pull && npm install && npm run build"));
|
|
69
|
+
return 0;
|
|
70
|
+
case "install": {
|
|
71
|
+
console.log(`updating huddle ${plan.current} → ${C.violet(plan.latest)}`);
|
|
72
|
+
const r = spawnToolSync(["npm", "install", "-g", `huddle-cli@${plan.latest}`], { stdio: "inherit" });
|
|
73
|
+
if (r.status !== 0) {
|
|
74
|
+
console.log(C.red("✗ npm couldn't install it.") + ` Try it yourself: npm install -g huddle-cli@${plan.latest}`);
|
|
75
|
+
if (process.platform !== "win32")
|
|
76
|
+
console.log(C.faint(" a permissions error means npm's global folder needs sudo; nvm avoids that"));
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
console.log(C.green("✓") + ` huddle ${plan.latest} installed.` + C.faint(" Open a new terminal (or run hash -r) if `huddle --version` still shows the old one."));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=update.js.map
|