huddle-cli 0.1.0 → 0.1.1
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 +31 -0
- package/QUICKSTART.md +102 -0
- package/README.md +23 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +6 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/attach/connect.js +4 -1
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +14 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +108 -40
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +38 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +13 -3
- package/dist/orchestrator/council.js +427 -86
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +29 -13
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +25 -7
- package/dist/orchestrator/qa.js +62 -14
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +498 -39
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +273 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +169 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +554 -133
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +194 -51
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +666 -69
- package/dist/ui/term.js +151 -16
- package/dist/ui/watch.js +145 -48
- package/package.json +6 -3
package/dist/ui/paste.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// Pasting at the `huddle ›` prompt, and the commands that put things on the
|
|
2
|
+
// shared screen (src/room/context.ts).
|
|
3
|
+
//
|
|
4
|
+
// readline fires one "line" per pasted line, so a pasted stack trace used to
|
|
5
|
+
// run as 200 goals. Here the paste is caught BEFORE readline sees it:
|
|
6
|
+
//
|
|
7
|
+
// bracketed paste the terminal wraps a paste in ESC[200~ … ESC[201~ (we turn
|
|
8
|
+
// that on with ESC[?2004h) — everything inside is one input
|
|
9
|
+
// fallback a terminal without it: text that arrives in one burst
|
|
10
|
+
// (a newline plus more, chunks < 30 ms apart) is one paste
|
|
11
|
+
//
|
|
12
|
+
// A short paste (≤ 3 lines, ≤ 500 chars) goes back to the line as typed, on
|
|
13
|
+
// one line. A long one becomes a screen item; what you type next is the goal.
|
|
14
|
+
import { StringDecoder } from "node:string_decoder";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { RoomDB } from "../room/db.js";
|
|
18
|
+
import { ContextStore, brainSummary, droppedPaths, expandGlob, fmtBytes, isGlob, looksText as looksTextBuf, redactSecrets, splitPathTokens, } from "../room/context.js";
|
|
19
|
+
import { C } from "./term.js";
|
|
20
|
+
export const PASTE_START = "\x1b[200~";
|
|
21
|
+
export const PASTE_END = "\x1b[201~";
|
|
22
|
+
/** Ask the terminal to mark pastes (or stop, before handing it to another program). */
|
|
23
|
+
export function bracketedPaste(on, out = process.stdout) {
|
|
24
|
+
if (out.isTTY)
|
|
25
|
+
out.write(on ? "\x1b[?2004h" : "\x1b[?2004l");
|
|
26
|
+
}
|
|
27
|
+
/** Long enough to be a screen item rather than part of the line. */
|
|
28
|
+
export function isLongPaste(text) {
|
|
29
|
+
const t = text.replace(/\r\n?/g, "\n").replace(/\n+$/, "");
|
|
30
|
+
return t.split("\n").length > 3 || t.length > 500;
|
|
31
|
+
}
|
|
32
|
+
/** A short multi-line paste, as one typed line. */
|
|
33
|
+
export const flatten = (text) => text.replace(/\r\n?|\n/g, " ").replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "").replace(/\s+$/, "");
|
|
34
|
+
/** How many chars at the end of `s` could be the start of `marker`. */
|
|
35
|
+
function partialTail(s, marker) {
|
|
36
|
+
for (let k = Math.min(marker.length - 1, s.length); k > 0; k--)
|
|
37
|
+
if (s.endsWith(marker.slice(0, k)))
|
|
38
|
+
return k;
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Splits terminal input into typed text (passed through to readline) and
|
|
43
|
+
* pastes (handed to onPaste). Pure apart from its timers, so tests feed it
|
|
44
|
+
* chunks directly.
|
|
45
|
+
*/
|
|
46
|
+
export class PasteCatcher {
|
|
47
|
+
o;
|
|
48
|
+
inPaste = false;
|
|
49
|
+
buf = "";
|
|
50
|
+
tail = "";
|
|
51
|
+
burst = "";
|
|
52
|
+
burstTimer = null;
|
|
53
|
+
tailTimer = null;
|
|
54
|
+
constructor(o) {
|
|
55
|
+
this.o = o;
|
|
56
|
+
}
|
|
57
|
+
feed(chunk) {
|
|
58
|
+
if (this.o.enabled && !this.o.enabled() && !this.inPaste) {
|
|
59
|
+
this.flushBurst();
|
|
60
|
+
return this.o.pass(this.takeTail() + chunk);
|
|
61
|
+
}
|
|
62
|
+
let s = this.takeTail() + chunk;
|
|
63
|
+
while (s) {
|
|
64
|
+
if (this.inPaste) {
|
|
65
|
+
const j = s.indexOf(PASTE_END);
|
|
66
|
+
if (j < 0) {
|
|
67
|
+
const k = partialTail(s, PASTE_END);
|
|
68
|
+
this.buf += s.slice(0, s.length - k);
|
|
69
|
+
this.holdTail(s.slice(s.length - k));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.buf += s.slice(0, j);
|
|
73
|
+
s = s.slice(j + PASTE_END.length);
|
|
74
|
+
this.inPaste = false;
|
|
75
|
+
const text = this.buf;
|
|
76
|
+
this.buf = "";
|
|
77
|
+
if (text)
|
|
78
|
+
this.o.onPaste(text);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const i = s.indexOf(PASTE_START);
|
|
82
|
+
if (i < 0) {
|
|
83
|
+
const k = partialTail(s, PASTE_START);
|
|
84
|
+
if (s.length - k)
|
|
85
|
+
this.typed(s.slice(0, s.length - k));
|
|
86
|
+
this.holdTail(s.slice(s.length - k));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (i > 0)
|
|
90
|
+
this.typed(s.slice(0, i));
|
|
91
|
+
this.flushBurst();
|
|
92
|
+
s = s.slice(i + PASTE_START.length);
|
|
93
|
+
this.inPaste = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
takeTail() {
|
|
97
|
+
const t = this.tail;
|
|
98
|
+
this.tail = "";
|
|
99
|
+
if (this.tailTimer)
|
|
100
|
+
clearTimeout(this.tailTimer);
|
|
101
|
+
this.tailTimer = null;
|
|
102
|
+
return t;
|
|
103
|
+
}
|
|
104
|
+
/** A lone ESC (the escape key) may be a marker's start: wait a moment, then let it through. */
|
|
105
|
+
holdTail(t) {
|
|
106
|
+
this.tail = t;
|
|
107
|
+
if (!t)
|
|
108
|
+
return;
|
|
109
|
+
this.tailTimer = setTimeout(() => {
|
|
110
|
+
this.tailTimer = null;
|
|
111
|
+
const held = this.tail;
|
|
112
|
+
this.tail = "";
|
|
113
|
+
if (!held)
|
|
114
|
+
return;
|
|
115
|
+
if (this.inPaste)
|
|
116
|
+
this.buf += held;
|
|
117
|
+
else
|
|
118
|
+
this.typed(held);
|
|
119
|
+
}, 25);
|
|
120
|
+
}
|
|
121
|
+
/** The fallback: a newline plus more in one chunk — or chunks right behind it — is a paste. */
|
|
122
|
+
typed(t) {
|
|
123
|
+
if (this.burstTimer) {
|
|
124
|
+
this.burst += t;
|
|
125
|
+
this.armBurst();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (t.length > 1 && /[\r\n]/.test(t)) {
|
|
129
|
+
this.burst = t;
|
|
130
|
+
this.armBurst();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.o.pass(t);
|
|
134
|
+
}
|
|
135
|
+
armBurst() {
|
|
136
|
+
if (this.burstTimer)
|
|
137
|
+
clearTimeout(this.burstTimer);
|
|
138
|
+
this.burstTimer = setTimeout(() => this.flushBurst(), this.o.windowMs ?? 30);
|
|
139
|
+
}
|
|
140
|
+
flushBurst() {
|
|
141
|
+
if (this.burstTimer)
|
|
142
|
+
clearTimeout(this.burstTimer);
|
|
143
|
+
this.burstTimer = null;
|
|
144
|
+
const b = this.burst;
|
|
145
|
+
this.burst = "";
|
|
146
|
+
if (!b)
|
|
147
|
+
return;
|
|
148
|
+
// One line + Enter that arrived in one chunk (a laggy terminal) is still typing.
|
|
149
|
+
const body = b.replace(/[\r\n]+$/, "");
|
|
150
|
+
if (/[\r\n]/.test(body))
|
|
151
|
+
this.o.onPaste(body);
|
|
152
|
+
else
|
|
153
|
+
this.o.pass(b);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Put a catcher in front of stdin: every "data" chunk goes through it before
|
|
158
|
+
* readline (and keypress listeners) see anything. Returns the undo.
|
|
159
|
+
*/
|
|
160
|
+
export function interceptStdin(stdin, make) {
|
|
161
|
+
const orig = stdin.emit;
|
|
162
|
+
const decoder = new StringDecoder("utf8");
|
|
163
|
+
const pass = (s) => void orig.call(stdin, "data", Buffer.from(s, "utf8"));
|
|
164
|
+
const catcher = make(pass);
|
|
165
|
+
stdin.emit = function (ev, ...args) {
|
|
166
|
+
if (ev !== "data")
|
|
167
|
+
return orig.apply(this, [ev, ...args]);
|
|
168
|
+
const c = args[0];
|
|
169
|
+
catcher.feed(typeof c === "string" ? c : decoder.write(c));
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
return () => {
|
|
173
|
+
stdin.emit = orig;
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const q = (s) => `'${s.length > 40 ? s.slice(0, 39) + "…" : s}'`;
|
|
177
|
+
const sizeWord = (i) => (i.lines && (i.kind === "text" || i.kind === "code") ? `${i.lines} line${i.lines === 1 ? "" : "s"}` : fmtBytes(i.bytes));
|
|
178
|
+
/** Secrets found → ask (or, with nobody to ask, redact) → save. */
|
|
179
|
+
async function save(store, p, ui) {
|
|
180
|
+
let redact = true;
|
|
181
|
+
if (p.secrets.length) {
|
|
182
|
+
const kinds = [...new Set(p.secrets.map((h) => h.kind))].join(", ");
|
|
183
|
+
const { agents, companies } = ui.audience();
|
|
184
|
+
const who = agents.length ? `${agents.join(", ")} (${companies} compan${companies === 1 ? "y" : "ies"})` : "every agent in the room";
|
|
185
|
+
ui.print(C.yellow(` ⚠ ${p.secrets.length} likely secret${p.secrets.length > 1 ? "s" : ""} in ${q(p.title)}: ${kinds} (line ${p.secrets.slice(0, 3).map((h) => h.line).join(", ")})`));
|
|
186
|
+
if (ui.interactive)
|
|
187
|
+
redact = await ui.yes(` this will be shown to ${who} — redact?`, true);
|
|
188
|
+
else
|
|
189
|
+
ui.print(C.faint(` redacted before it goes to ${who}`));
|
|
190
|
+
}
|
|
191
|
+
const res = store.commit(p, { by: "you", redact, announce: ui.announce });
|
|
192
|
+
if (res.ok && !res.duplicate && res.item.lines > 200 && ui.brain) {
|
|
193
|
+
// A one-line brain summary for the index — in the background, never blocking the prompt.
|
|
194
|
+
void brainSummary(store, res.item, ui.brain(), ui.root?.() ?? process.cwd()).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
return res;
|
|
197
|
+
}
|
|
198
|
+
/** Print what happened to an add; true if the item is on the screen. */
|
|
199
|
+
function report(res, ui, verb) {
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
ui.print(C.red(` ✗ ${res.reason}`));
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const i = res.item;
|
|
205
|
+
if (res.duplicate) {
|
|
206
|
+
ui.print(` 📎 already on the screen: ${i.id} ${q(i.title)}`);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
// "📎 pasted 214 lines (ctx-3 'TypeError…')" · "📎 added 'shot.png' (ctx-4 · image · 412 KB · 1280×720)"
|
|
210
|
+
if (verb === "added")
|
|
211
|
+
ui.print(` 📎 added ${q(i.title)} ${C.faint(`(${i.id} · ${i.kind} · ${i.lines ? sizeWord(i) + ", " : ""}${fmtBytes(i.bytes)}${i.summary ? " · " + i.summary.slice(0, 60) : ""})`)}`);
|
|
212
|
+
else
|
|
213
|
+
ui.print(` 📎 ${verb} ${sizeWord(i)} ${C.faint(`(${i.id} ${q(i.title)})`)}`);
|
|
214
|
+
if (res.redacted)
|
|
215
|
+
ui.print(C.faint(` ${res.redacted} secret${res.redacted > 1 ? "s" : ""} redacted`));
|
|
216
|
+
for (const w of res.warnings ?? [])
|
|
217
|
+
ui.print(C.faint(` ${w}`));
|
|
218
|
+
if (res.announced?.length)
|
|
219
|
+
ui.print(C.faint(` told ${res.announced.join(", ")} (working now)`));
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** A long paste → a screen item. Returns false if it should go back to the line instead. */
|
|
223
|
+
export async function pasteToScreen(text, ui) {
|
|
224
|
+
const db = await ui.db();
|
|
225
|
+
if (!db) {
|
|
226
|
+
ui.print(C.yellow(" ! pastes go on a repo's shared screen — cd into a git repo first"));
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
const store = ContextStore.open(db);
|
|
230
|
+
const p = store.prepareText(text);
|
|
231
|
+
if ("ok" in p) {
|
|
232
|
+
ui.print(C.red(` ✗ ${p.reason}`));
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
const res = await save(store, p, ui);
|
|
236
|
+
report(res, ui, "pasted");
|
|
237
|
+
if (res.ok && !res.duplicate)
|
|
238
|
+
ui.print(C.faint(" type your goal — every agent sees it on the shared screen, not in N copies"));
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
/** A dropped file path (or several): the whole line is existing absolute paths. */
|
|
242
|
+
export function asDrop(text) {
|
|
243
|
+
return droppedPaths(text.replace(/\r?\n/g, " "));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* `add a login page` is a goal; `add ./log.txt`, `add *.png`, `add ~/shot.png`,
|
|
247
|
+
* `add notes.md` (when it exists) add files. A bare missing name ("add
|
|
248
|
+
* README.md") is a goal: the user wants it written.
|
|
249
|
+
*/
|
|
250
|
+
export function looksLikePaths(rest, cwd = process.cwd()) {
|
|
251
|
+
const toks = splitPathTokens(rest);
|
|
252
|
+
if (!toks.length)
|
|
253
|
+
return false;
|
|
254
|
+
return toks.every((t) => isGlob(t) || /[\\/~]|^\./.test(t) || (/\.\w{1,8}$/.test(t) && fs.existsSync(path.resolve(cwd, t))));
|
|
255
|
+
}
|
|
256
|
+
/** A Room event as a line for the prompt and/or the live view (null = not a screen event). */
|
|
257
|
+
export function screenEvent(e) {
|
|
258
|
+
if (!e.type.startsWith("context."))
|
|
259
|
+
return null;
|
|
260
|
+
let p = {};
|
|
261
|
+
try {
|
|
262
|
+
p = JSON.parse(e.payload || "{}");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const t = q(String(p.title ?? ""));
|
|
268
|
+
if (e.type === "context.note") {
|
|
269
|
+
const text = `📌 ${C.bold(p.by)} on ${p.id} ${C.faint(t)}: ${p.body}`;
|
|
270
|
+
return { print: p.by === "you" ? undefined : ` ${text}`, dash: { text } };
|
|
271
|
+
}
|
|
272
|
+
if (e.type === "context.added" && p.by !== "you") {
|
|
273
|
+
const text = `📎 ${C.bold(p.by)} put ${p.id} on the screen: ${t}`;
|
|
274
|
+
return { print: ` ${text}`, dash: { text } };
|
|
275
|
+
}
|
|
276
|
+
if (e.type === "context.read" && e.agent_id)
|
|
277
|
+
return { dash: { agent: e.agent_id, text: `📖 read ${p.id} ${t}${p.from ? ` lines ${p.from}-${p.to}` : ""}` } };
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* `add <path…|glob>`: files, folders (their text files, as one item) and
|
|
282
|
+
* globs (more than 5 matches → one item). Returns how many failed.
|
|
283
|
+
*/
|
|
284
|
+
export async function addPaths(args, ui, cwd = process.cwd()) {
|
|
285
|
+
const db = await ui.db();
|
|
286
|
+
if (!db) {
|
|
287
|
+
ui.print(C.red(" ✗ open a git repo first") + C.faint(" — the shared screen belongs to a project"));
|
|
288
|
+
return args.length || 1;
|
|
289
|
+
}
|
|
290
|
+
const store = ContextStore.open(db);
|
|
291
|
+
if (!args.length)
|
|
292
|
+
ui.print(C.faint(" usage: add <file|folder|glob> … — or just drag a file into this window"));
|
|
293
|
+
let failed = 0;
|
|
294
|
+
for (const a of args) {
|
|
295
|
+
// "shot [1].png" that exists is a file, not a pattern.
|
|
296
|
+
if (isGlob(a) && !fs.existsSync(path.resolve(cwd, a))) {
|
|
297
|
+
const hits = expandGlob(a, cwd);
|
|
298
|
+
if (!hits.length) {
|
|
299
|
+
ui.print(C.red(` ✗ nothing matches ${a}`));
|
|
300
|
+
failed++;
|
|
301
|
+
}
|
|
302
|
+
else if (hits.length > 5) {
|
|
303
|
+
const p = store.prepareBundle(a, cwd, hits);
|
|
304
|
+
if ("ok" in p) {
|
|
305
|
+
ui.print(C.red(` ✗ ${p.reason}`));
|
|
306
|
+
failed++;
|
|
307
|
+
}
|
|
308
|
+
else if (!report(await save(store, p, ui), ui, "added"))
|
|
309
|
+
failed++;
|
|
310
|
+
}
|
|
311
|
+
else
|
|
312
|
+
for (const h of hits)
|
|
313
|
+
if (!(await addOne(store, h, ui, cwd)))
|
|
314
|
+
failed++;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!(await addOne(store, a, ui, cwd)))
|
|
318
|
+
failed++;
|
|
319
|
+
}
|
|
320
|
+
return failed;
|
|
321
|
+
}
|
|
322
|
+
async function addOne(store, p, ui, cwd) {
|
|
323
|
+
const prep = store.preparePath(p, cwd);
|
|
324
|
+
if ("ok" in prep) {
|
|
325
|
+
ui.print(C.red(` ✗ ${prep.reason}`));
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
return report(await save(store, prep, ui), ui, "added");
|
|
329
|
+
}
|
|
330
|
+
/** `ctx` / `context`: list · show <id> · rm <id…> · note <id> <text> · add <path…> · clear */
|
|
331
|
+
export async function ctxCommand(arg, ui) {
|
|
332
|
+
const db = await ui.db();
|
|
333
|
+
if (!db)
|
|
334
|
+
return ui.print(C.red(" ✗ open a git repo first") + C.faint(" — the shared screen belongs to a project"));
|
|
335
|
+
const store = ContextStore.open(db);
|
|
336
|
+
const [sub, ...rest] = arg.trim().split(/\s+/).filter(Boolean);
|
|
337
|
+
if (!sub || sub === "ls" || sub === "list") {
|
|
338
|
+
const items = store.list();
|
|
339
|
+
if (!items.length)
|
|
340
|
+
return ui.print(C.faint(" nothing on the shared screen — paste something long, drag a file in, or `add <file>`"));
|
|
341
|
+
ui.print(`\n ${C.violet("SCREEN")} ${C.faint(`· ${items.length} item${items.length > 1 ? "s" : ""} · ${fmtBytes(store.totalBytes())} · agents read these on demand`)}`);
|
|
342
|
+
for (const i of items) {
|
|
343
|
+
ui.print(` ${C.bold(i.id.padEnd(6))} ${i.kind.padEnd(5)} ${q(i.title)} ${C.faint(`· ${sizeWord(i)} · by ${i.added_by}${i.notes.length ? ` · ${i.notes.length} note${i.notes.length > 1 ? "s" : ""}` : ""}`)}`);
|
|
344
|
+
if (i.brain_summary || i.summary)
|
|
345
|
+
ui.print(C.faint(` ${(i.brain_summary || i.summary).slice(0, 110)}`));
|
|
346
|
+
for (const n of i.notes.slice(-2))
|
|
347
|
+
ui.print(` 📌 ${C.bold(n.by)} ${n.body.slice(0, 110)}`);
|
|
348
|
+
}
|
|
349
|
+
return ui.print(C.faint("\n ctx show <id> · ctx rm <id> · ctx note <id> <text> · add <file>"));
|
|
350
|
+
}
|
|
351
|
+
if (sub === "add")
|
|
352
|
+
return void (await addPaths(splitPathTokens(rest.join(" ")), ui));
|
|
353
|
+
if (sub === "clear") {
|
|
354
|
+
const items = store.list();
|
|
355
|
+
if (!items.length)
|
|
356
|
+
return ui.print(C.faint(" the screen is already empty"));
|
|
357
|
+
if (ui.interactive && !(await ui.yes(` take all ${items.length} items off the screen?`, false)))
|
|
358
|
+
return;
|
|
359
|
+
for (const i of items)
|
|
360
|
+
store.remove(i.id);
|
|
361
|
+
return ui.print(` ${C.green("✓")} screen cleared`);
|
|
362
|
+
}
|
|
363
|
+
if (sub === "rm" || sub === "remove" || sub === "del") {
|
|
364
|
+
if (!rest.length)
|
|
365
|
+
return ui.print(C.faint(" usage: ctx rm <id>"));
|
|
366
|
+
for (const id of rest)
|
|
367
|
+
ui.print(store.remove(id) ? ` ${C.green("✓")} ${id} is off the screen` : C.red(` ✗ no item ${id}`));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (sub === "note") {
|
|
371
|
+
const [id, ...words] = rest;
|
|
372
|
+
if (!id || !words.length)
|
|
373
|
+
return ui.print(C.faint(" usage: ctx note <id> <what everyone should know about it>"));
|
|
374
|
+
const n = store.note(id, words.join(" "), "you");
|
|
375
|
+
return ui.print("error" in n ? C.red(` ✗ ${n.error}`) : ` 📌 pinned on ${id}`);
|
|
376
|
+
}
|
|
377
|
+
const id = sub === "show" || sub === "cat" ? rest[0] : sub;
|
|
378
|
+
const it = id ? store.get(id) : undefined;
|
|
379
|
+
if (!it)
|
|
380
|
+
return ui.print(C.red(` ✗ no item ${id ?? ""}`) + C.faint(" — `ctx` lists them"));
|
|
381
|
+
ui.print(`\n ${C.bold(it.id)} ${it.kind} ${q(it.title)} ${C.faint(`· ${sizeWord(it)} · ${fmtBytes(it.bytes)} · by ${it.added_by}`)}`);
|
|
382
|
+
if (it.summary)
|
|
383
|
+
ui.print(C.faint(` ${it.summary}`));
|
|
384
|
+
if (it.sections.length)
|
|
385
|
+
ui.print(C.faint(` sections: ${it.sections.slice(0, 8).map((s, k) => `${k + 1}. ${s.title} (${s.from}-${s.to})`).join(" · ")}${it.sections.length > 8 ? " …" : ""}`));
|
|
386
|
+
const text = store.textOf(it);
|
|
387
|
+
if (text === null)
|
|
388
|
+
ui.print(C.faint(` file: ${store.pathOf(it)}`));
|
|
389
|
+
else {
|
|
390
|
+
const lines = text.split("\n");
|
|
391
|
+
for (const l of lines.slice(0, 30))
|
|
392
|
+
ui.print(C.faint(" │ ") + l.slice(0, 200));
|
|
393
|
+
if (lines.length > 30)
|
|
394
|
+
ui.print(C.faint(` │ … ${lines.length - 30} more lines`));
|
|
395
|
+
}
|
|
396
|
+
for (const n of it.notes)
|
|
397
|
+
ui.print(` 📌 ${C.bold(n.by)} ${n.body}`);
|
|
398
|
+
}
|
|
399
|
+
// ---- one-shot runs: huddle join "<goal>" --with f --with g, and stdin -----------
|
|
400
|
+
/** `--with a --with=b` → ["a", "b"], and the args without them. */
|
|
401
|
+
export function takeWith(args) {
|
|
402
|
+
const files = [];
|
|
403
|
+
const rest = [];
|
|
404
|
+
for (let i = 0; i < args.length; i++) {
|
|
405
|
+
const a = args[i];
|
|
406
|
+
if (a === "--with" && i + 1 < args.length)
|
|
407
|
+
files.push(args[++i]);
|
|
408
|
+
else if (a.startsWith("--with="))
|
|
409
|
+
files.push(a.slice(7));
|
|
410
|
+
else
|
|
411
|
+
rest.push(a);
|
|
412
|
+
}
|
|
413
|
+
return { files, rest };
|
|
414
|
+
}
|
|
415
|
+
/** Piped stdin (`cat log.txt | huddle join "fix this"`). Waits briefly for a first chunk, so an idle pipe never hangs a run. */
|
|
416
|
+
export function readPipedStdin(stdin = process.stdin, firstMs = 300) {
|
|
417
|
+
if (stdin.isTTY)
|
|
418
|
+
return Promise.resolve("");
|
|
419
|
+
return new Promise((resolve) => {
|
|
420
|
+
let data = "";
|
|
421
|
+
let started = false;
|
|
422
|
+
const done = () => {
|
|
423
|
+
clearTimeout(idle);
|
|
424
|
+
stdin.off("data", onData);
|
|
425
|
+
stdin.off("end", done);
|
|
426
|
+
stdin.off("error", done);
|
|
427
|
+
stdin.pause();
|
|
428
|
+
resolve(data);
|
|
429
|
+
};
|
|
430
|
+
const onData = (c) => {
|
|
431
|
+
started = true;
|
|
432
|
+
data += c.toString();
|
|
433
|
+
};
|
|
434
|
+
const idle = setTimeout(() => !started && done(), firstMs);
|
|
435
|
+
stdin.on("data", onData);
|
|
436
|
+
stdin.once("end", done);
|
|
437
|
+
stdin.once("error", done);
|
|
438
|
+
stdin.resume();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/** Text from a pipe (`cat err.log | huddle add -`) as one item. True if it's on the screen. */
|
|
442
|
+
export async function addPiped(store, text, ui, opts = {}) {
|
|
443
|
+
const p = store.prepareText(text, { source: "stdin", title: opts.title });
|
|
444
|
+
if ("ok" in p) {
|
|
445
|
+
ui.print(C.red(` ✗ stdin: ${p.reason}`));
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
return report(await save(store, p, ui), ui, "piped in");
|
|
449
|
+
}
|
|
450
|
+
/** Put a one-shot run's --with files and stdin on the screen before planning. */
|
|
451
|
+
export async function addForRun(dbFile, files, piped, print, cwd = process.cwd()) {
|
|
452
|
+
if (!files.length && !piped.trim())
|
|
453
|
+
return 0;
|
|
454
|
+
const db = new RoomDB(dbFile);
|
|
455
|
+
const ui = { print, yes: async () => true, db: async () => db, audience: () => ({ agents: [], companies: 0 }), interactive: false };
|
|
456
|
+
let failed = 0;
|
|
457
|
+
try {
|
|
458
|
+
const store = ContextStore.open(db);
|
|
459
|
+
if (piped.trim() && !(await addPiped(store, piped, ui)))
|
|
460
|
+
failed++;
|
|
461
|
+
failed += files.length ? await addPaths(files, ui, cwd) : 0;
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
db.close();
|
|
465
|
+
}
|
|
466
|
+
return failed;
|
|
467
|
+
}
|
|
468
|
+
// ---- a teammate's side (`huddle join <address> <CODE>`) ----------------------------
|
|
469
|
+
/**
|
|
470
|
+
* `add <file>` / `ctx` typed by a teammate: text files go to the host's screen
|
|
471
|
+
* over the shared API (the body cap there is 256 KB); `ctx` lists the screen.
|
|
472
|
+
*/
|
|
473
|
+
export async function remoteScreen(request, line, say) {
|
|
474
|
+
const [cmd] = line.split(/\s+/);
|
|
475
|
+
if (cmd === "ctx" || cmd === "context") {
|
|
476
|
+
const r = await request("GET", "/context");
|
|
477
|
+
if (r.status !== 200)
|
|
478
|
+
return say(C.red(` ✗ ${r.body?.error ?? "couldn't read the screen"}`));
|
|
479
|
+
const items = r.body.items ?? [];
|
|
480
|
+
if (!items.length)
|
|
481
|
+
return say(C.faint(" nothing on the shared screen yet — `add <file>` to put a text file up"));
|
|
482
|
+
for (const i of items) {
|
|
483
|
+
say(` ${C.bold(String(i.id).padEnd(6))} ${String(i.kind).padEnd(5)} ${q(String(i.title))} ${C.faint(`· ${i.size} · by ${i.added_by}`)}`);
|
|
484
|
+
for (const n of (i.notes ?? []).slice(-2))
|
|
485
|
+
say(` 📌 ${n}`);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
for (const f of splitPathTokens(line.slice(cmd.length))) {
|
|
490
|
+
let text;
|
|
491
|
+
try {
|
|
492
|
+
const buf = fs.readFileSync(f);
|
|
493
|
+
if (buf.length > 200 * 1024) {
|
|
494
|
+
say(C.red(` ✗ ${path.basename(f)} is ${fmtBytes(buf.length)} — teammates can send text up to 200 KB; ask the host to add it`));
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!looksTextBuf(buf)) {
|
|
498
|
+
say(C.red(` ✗ ${path.basename(f)} isn't text — screenshots and PDFs go up from the host's machine`));
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
text = buf.toString("utf8");
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
say(C.red(` ✗ ${e?.code === "ENOENT" ? "no such file: " + f : e?.code === "EACCES" ? "permission denied: " + f : String(e?.message ?? e)}`));
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// Secrets never leave this machine: redacted here, before the send.
|
|
508
|
+
const red = redactSecrets(text);
|
|
509
|
+
if (red.count)
|
|
510
|
+
say(C.yellow(` ⚠ redacted ${red.count} likely secret${red.count > 1 ? "s" : ""} in ${path.basename(f)} before sending`));
|
|
511
|
+
const r = await request("POST", "/context/add", { text: red.text, title: path.basename(f) });
|
|
512
|
+
say(r.status === 200 ? ` 📎 ${r.body.duplicate ? "already on the screen" : "on the screen"}: ${r.body.id} ${q(path.basename(f))}` : C.red(` ✗ ${r.body?.error ?? "not added"}`));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
//# sourceMappingURL=paste.js.map
|
package/dist/ui/remote.js
CHANGED
|
@@ -22,7 +22,8 @@ import { transportFor } from "../room/transport.js";
|
|
|
22
22
|
import { signAs, githubUser } from "../room/identity.js";
|
|
23
23
|
import { canonicalRemote, pushBranch, remoteInfo } from "../sandbox/sync.js";
|
|
24
24
|
import { connectAgents, readPrefs, workersFrom, ensureInit } from "./app.js";
|
|
25
|
-
import { launchAgent } from "../orchestrator/launcher.js";
|
|
25
|
+
import { launchAgent, stopChild } from "../orchestrator/launcher.js";
|
|
26
|
+
import { looksLikePaths, remoteScreen } from "./paste.js";
|
|
26
27
|
import { removeWorktree } from "../sandbox/worktree.js";
|
|
27
28
|
import { fmtDuration, fmtTokens } from "../adapters/usage.js";
|
|
28
29
|
export const INVITE_CODE = /^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/i;
|
|
@@ -199,7 +200,7 @@ export async function joinRoom(address, code) {
|
|
|
199
200
|
// Worktree off the room's pushed main (or the branch itself, on a fix round).
|
|
200
201
|
const f = git(root, ["fetch", "--quiet", "origin"]);
|
|
201
202
|
if (!f.ok)
|
|
202
|
-
return deliver({ ok: false, reason: `couldn't fetch: ${f.err.split(
|
|
203
|
+
return deliver({ ok: false, reason: `couldn't fetch: ${f.err.split(/\r?\n/)[0]}` });
|
|
203
204
|
const wtPath = path.join(root, ".huddle", "worktrees", branch.replace(/[^\w.-]/g, "_"));
|
|
204
205
|
if (!fs.existsSync(wtPath)) {
|
|
205
206
|
const hasRemoteBranch = git(root, ["rev-parse", "--verify", "--quiet", `origin/${branch}`]).ok;
|
|
@@ -207,7 +208,7 @@ export async function joinRoom(address, code) {
|
|
|
207
208
|
? git(root, ["worktree", "add", "-B", branch, wtPath, `origin/${branch}`])
|
|
208
209
|
: git(root, ["worktree", "add", "-B", branch, wtPath, p.base]);
|
|
209
210
|
if (!add.ok)
|
|
210
|
-
return deliver({ ok: false, reason: `couldn't prepare a worktree: ${add.err.split(
|
|
211
|
+
return deliver({ ok: false, reason: `couldn't prepare a worktree: ${add.err.split(/\r?\n/)[0]}` });
|
|
211
212
|
}
|
|
212
213
|
say(` ${dot(w.id)} ${pad(w.id, 10)} ${C.violet("▸")} ${p.task.title} ${C.faint("· on your machine")}`);
|
|
213
214
|
const prefs = readPrefs();
|
|
@@ -250,7 +251,7 @@ export async function joinRoom(address, code) {
|
|
|
250
251
|
return;
|
|
251
252
|
leaving = true;
|
|
252
253
|
for (const a of assigned.values())
|
|
253
|
-
a.handle?.child
|
|
254
|
+
stopChild(a.handle?.child);
|
|
254
255
|
await t.request("POST", "/leave", {}, token).catch(() => { });
|
|
255
256
|
proxy?.close();
|
|
256
257
|
t.close();
|
|
@@ -304,7 +305,7 @@ export async function joinRoom(address, code) {
|
|
|
304
305
|
process.stdout.write("\r\x1b[2K");
|
|
305
306
|
console.log(code === 4403 ? C.yellow("you were removed from the room") : C.faint("the room closed"));
|
|
306
307
|
for (const a of assigned.values())
|
|
307
|
-
a.handle?.child
|
|
308
|
+
stopChild(a.handle?.child);
|
|
308
309
|
proxy?.close();
|
|
309
310
|
process.exit(0);
|
|
310
311
|
});
|
|
@@ -321,6 +322,11 @@ export async function joinRoom(address, code) {
|
|
|
321
322
|
printBoard(b.body);
|
|
322
323
|
return rl.prompt();
|
|
323
324
|
}
|
|
325
|
+
// The host's shared screen: `ctx` lists it, `add <file>` puts a text file up.
|
|
326
|
+
if (/^(ctx|context)$/.test(line) || /^add\s+\S/.test(line) && looksLikePaths(line.slice(3))) {
|
|
327
|
+
await remoteScreen((m, p, b) => t.request(m, p, b, token), line, say);
|
|
328
|
+
return rl.prompt();
|
|
329
|
+
}
|
|
324
330
|
const r = await t.request("POST", "/message", { body: line }, token);
|
|
325
331
|
if (r.status !== 200)
|
|
326
332
|
say(C.red(` ✗ ${r.body.error ?? "not sent"}`));
|