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.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +6 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. package/package.json +6 -3
@@ -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("\n")[0]}` });
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("\n")[0]}` });
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?.kill("SIGTERM");
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?.kill("SIGTERM");
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"}`));