@herdingchats/friday 0.1.683 → 0.1.701

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 (3) hide show
  1. package/herd.mjs +171 -49
  2. package/package.json +2 -2
  3. package/terminal-host.mjs +12 -2
package/herd.mjs CHANGED
@@ -18,7 +18,7 @@ var __export = (target, all) => {
18
18
  var define_HERD_PACKAGE_default;
19
19
  var init_define_HERD_PACKAGE = __esm({
20
20
  "<define:HERD_PACKAGE>"() {
21
- define_HERD_PACKAGE_default = { version: "0.1.683", gitHead: "a61eea3d8eae51e4b4365ef53c98c40dc64360f3" };
21
+ define_HERD_PACKAGE_default = { version: "0.1.701", gitHead: "dee83b97beed4102f6d56d692f1c1e306b62dbb6" };
22
22
  }
23
23
  });
24
24
 
@@ -508,7 +508,13 @@ var init_terminals = __esm({
508
508
  rows: z6.number().int().min(5).max(1e3)
509
509
  };
510
510
  terminalFrameSchema = z6.discriminatedUnion("type", [
511
- z6.object({ type: z6.literal("snapshot"), data: z6.string() }),
511
+ // The screen, and its size, which a watcher draws it at. Hosts from before sizes don't say.
512
+ z6.object({
513
+ type: z6.literal("snapshot"),
514
+ data: z6.string(),
515
+ cols: size.cols.optional(),
516
+ rows: size.rows.optional()
517
+ }),
512
518
  z6.object({ type: z6.literal("data"), data: z6.string() }),
513
519
  z6.object({ type: z6.literal("exit"), exitCode: z6.number().int().nullable() }),
514
520
  z6.object({ type: z6.literal("mode"), mode: terminalModeSchema }),
@@ -1507,7 +1513,10 @@ var init_events = __esm({
1507
1513
  kind: z13.literal("bound"),
1508
1514
  machine: z13.object({ name: nonEmpty6, online: z13.boolean() }),
1509
1515
  // The worker's name, such as `mac-mini/claude`, and never its configuration or capabilities.
1510
- worker: nonEmpty6.optional()
1516
+ worker: nonEmpty6.optional(),
1517
+ // Only for the machine's owner, while it's online: what they may do with its terminal from
1518
+ // the web. Absent for anyone else, and where the machine allows nothing or can't.
1519
+ terminal: z13.enum(["drive", "watch"]).optional()
1511
1520
  }),
1512
1521
  z13.object({ kind: z13.literal("waiting_for_machine") }),
1513
1522
  z13.object({ kind: z13.literal("author_not_linked") })
@@ -3309,7 +3318,7 @@ import { spawn as spawn8 } from "node:child_process";
3309
3318
  import { existsSync as existsSync12, readFileSync as readFileSync10 } from "node:fs";
3310
3319
  import { homedir as homedir5 } from "node:os";
3311
3320
  import { dirname as dirname10, join as join16 } from "node:path";
3312
- import { createInterface } from "node:readline/promises";
3321
+ import { createInterface as createInterface2 } from "node:readline/promises";
3313
3322
  import { z as z23 } from "zod";
3314
3323
 
3315
3324
  // src/auth.ts
@@ -3368,16 +3377,93 @@ async function postForm(url, fields) {
3368
3377
  body: new URLSearchParams(fields)
3369
3378
  });
3370
3379
  }
3371
- async function login(openBrowser2, { api = apiUrl(), timeoutMs = 10 * 60 * 1e3 } = {}) {
3372
- const discovered = await fetch(`${api}/.well-known/oauth-authorization-server`).catch(() => null);
3373
- const metadata = metadataSchema.safeParse(await discovered?.json().catch(() => null));
3374
- if (!metadata.success) throw new HerdError(`Couldn't find where to sign in for ${api}.`);
3375
- const meta = metadata.data;
3380
+ var deviceRedirectFor = (api) => {
3381
+ const url = new URL(api);
3382
+ url.hostname = url.hostname.replace(/^api\./, "chat.");
3383
+ return `${url.origin}/sign-in/code`;
3384
+ };
3385
+ var wantsDevice = (args, env = process.env) => args.includes("--device") || Boolean(env.SSH_CONNECTION || env.SSH_TTY);
3386
+ function parsePasted(pasted) {
3387
+ const text4 = pasted.trim();
3388
+ if (/^https?:\/\//.test(text4)) {
3389
+ const url = new URL(text4);
3390
+ const code = url.searchParams.get("code");
3391
+ return code ? { state: url.searchParams.get("state") ?? "", code } : null;
3392
+ }
3393
+ const dot = text4.indexOf(".");
3394
+ return dot > 0 && dot < text4.length - 1 ? { state: text4.slice(0, dot), code: text4.slice(dot + 1) } : null;
3395
+ }
3396
+ async function loopback(openBrowser2, timeoutMs) {
3376
3397
  const server = createServer();
3377
3398
  await new Promise((resolve8) => server.listen(0, "127.0.0.1", resolve8));
3378
3399
  const address = server.address();
3379
3400
  const port = typeof address === "object" && address ? address.port : 0;
3380
3401
  const redirectUri = `http://127.0.0.1:${port}/callback`;
3402
+ return {
3403
+ redirectUri,
3404
+ close: () => server.close(),
3405
+ code: async (authorize, state) => {
3406
+ const code = new Promise((resolve8, reject) => {
3407
+ const timer = setTimeout(
3408
+ () => reject(new HerdError("Nobody signed in within 10 minutes. Run herd login again.")),
3409
+ timeoutMs
3410
+ );
3411
+ server.on("request", (req, res) => {
3412
+ const url = new URL(req.url ?? "/", redirectUri);
3413
+ if (url.pathname !== "/callback") return void res.writeHead(404).end();
3414
+ clearTimeout(timer);
3415
+ const error = url.searchParams.get("error");
3416
+ const ok = !error && url.searchParams.get("state") === state;
3417
+ res.writeHead(ok ? 200 : 400, { "Content-Type": "text/plain" }).end(ok ? "Signed in to herd. You can close this tab." : "Sign-in didn't complete.");
3418
+ if (error) return reject(new HerdError(`Sign-in was refused: ${error}.`));
3419
+ if (!ok) return reject(new HerdError("Sign-in came back for a different request."));
3420
+ resolve8(url.searchParams.get("code") ?? "");
3421
+ });
3422
+ });
3423
+ code.catch(() => {
3424
+ });
3425
+ await openBrowser2(authorize);
3426
+ return code;
3427
+ }
3428
+ };
3429
+ }
3430
+ function device(redirectUri, print, readCode) {
3431
+ return {
3432
+ redirectUri,
3433
+ close: () => {
3434
+ },
3435
+ code: async (authorize, state) => {
3436
+ print(`Open this link on any device and sign in:
3437
+
3438
+ ${authorize}
3439
+ `);
3440
+ for (let tries = 0; tries < 3; tries += 1) {
3441
+ const pasted = parsePasted(await readCode("Paste the code it shows: "));
3442
+ if (!pasted) {
3443
+ print("That doesn't look like the code. Copy it with the button on the page.");
3444
+ continue;
3445
+ }
3446
+ if (pasted.state !== state) {
3447
+ throw new HerdError("That code is from a different sign-in. Run herd login again.");
3448
+ }
3449
+ return pasted.code;
3450
+ }
3451
+ throw new HerdError("No code was pasted. Run herd login again.");
3452
+ }
3453
+ };
3454
+ }
3455
+ async function login(how, {
3456
+ api = apiUrl(),
3457
+ timeoutMs = 10 * 60 * 1e3,
3458
+ deviceRedirect = deviceRedirectFor(api)
3459
+ } = {}) {
3460
+ const discovered = await fetch(`${api}/.well-known/oauth-authorization-server`).catch(() => null);
3461
+ const metadata = metadataSchema.safeParse(await discovered?.json().catch(() => null));
3462
+ if (!metadata.success) throw new HerdError(`Couldn't find where to sign in for ${api}.`);
3463
+ const meta = metadata.data;
3464
+ const way = typeof how === "function" ? { openBrowser: how } : how;
3465
+ const source = "openBrowser" in way ? await loopback(way.openBrowser, timeoutMs) : device(deviceRedirect, way.print, way.readCode);
3466
+ const { redirectUri } = source;
3381
3467
  try {
3382
3468
  const registration2 = await fetch(meta.registration_endpoint, {
3383
3469
  method: "POST",
@@ -3406,30 +3492,11 @@ async function login(openBrowser2, { api = apiUrl(), timeoutMs = 10 * 60 * 1e3 }
3406
3492
  state,
3407
3493
  resource: api
3408
3494
  }).toString();
3409
- const code = new Promise((resolve8, reject) => {
3410
- const timer = setTimeout(
3411
- () => reject(new HerdError("Nobody signed in within 10 minutes. Run herd login again.")),
3412
- timeoutMs
3413
- );
3414
- server.on("request", (req, res) => {
3415
- const url = new URL(req.url ?? "/", redirectUri);
3416
- if (url.pathname !== "/callback") return void res.writeHead(404).end();
3417
- clearTimeout(timer);
3418
- const error = url.searchParams.get("error");
3419
- const ok = !error && url.searchParams.get("state") === state;
3420
- res.writeHead(ok ? 200 : 400, { "Content-Type": "text/plain" }).end(ok ? "Signed in to herd. You can close this tab." : "Sign-in didn't complete.");
3421
- if (error) return reject(new HerdError(`Sign-in was refused: ${error}.`));
3422
- if (!ok) return reject(new HerdError("Sign-in came back for a different request."));
3423
- resolve8(url.searchParams.get("code") ?? "");
3424
- });
3425
- });
3426
- code.catch(() => {
3427
- });
3428
- await openBrowser2(authorize.toString());
3495
+ const code = await source.code(authorize.toString(), state);
3429
3496
  const response = await postForm(meta.token_endpoint, {
3430
3497
  grant_type: "authorization_code",
3431
3498
  client_id: client.data.client_id,
3432
- code: await code,
3499
+ code,
3433
3500
  redirect_uri: redirectUri,
3434
3501
  code_verifier: verifier,
3435
3502
  resource: api
@@ -3446,7 +3513,7 @@ async function login(openBrowser2, { api = apiUrl(), timeoutMs = 10 * 60 * 1e3 }
3446
3513
  refreshToken: tokens.data.refresh_token
3447
3514
  });
3448
3515
  } finally {
3449
- server.close();
3516
+ source.close();
3450
3517
  }
3451
3518
  }
3452
3519
  var EXPIRY_MARGIN_MS = 60 * 1e3;
@@ -3508,7 +3575,7 @@ async function apiFetch(path, init = {}) {
3508
3575
  // src/cli-flags.ts
3509
3576
  init_define_HERD_PACKAGE();
3510
3577
  var HERD_FLAGS = {
3511
- login: [],
3578
+ login: ["--device"],
3512
3579
  logout: [],
3513
3580
  daemon: ["--repo"],
3514
3581
  attach: ["--watch"],
@@ -3539,7 +3606,7 @@ var HERD_FLAGS = {
3539
3606
  persona: []
3540
3607
  };
3541
3608
  var FRIDAY_FLAGS = {
3542
- start: ["--agents", "--config", "--verbose"],
3609
+ start: ["--agents", "--config", "--device", "--verbose"],
3543
3610
  stop: [],
3544
3611
  status: [],
3545
3612
  logs: ["--follow", "-f", "-n"]
@@ -3591,6 +3658,7 @@ function commandHelp(overview, command) {
3591
3658
  // src/commands/login.ts
3592
3659
  init_define_HERD_PACKAGE();
3593
3660
  import { spawn } from "node:child_process";
3661
+ import { createInterface } from "node:readline/promises";
3594
3662
  function openBrowser(url) {
3595
3663
  console.log(`Opening your browser to sign in. If it doesn't open, visit:
3596
3664
 
@@ -3603,12 +3671,34 @@ function openBrowser(url) {
3603
3671
  } catch {
3604
3672
  }
3605
3673
  }
3606
- async function runLogin() {
3674
+ async function readCodeFromTerminal(prompt) {
3675
+ if (!process.stdin.isTTY) {
3676
+ throw new HerdError("Signing in with a code needs a terminal to paste it into.");
3677
+ }
3678
+ const lines = createInterface({ input: process.stdin, output: process.stdout });
3679
+ try {
3680
+ return await lines.question(prompt);
3681
+ } finally {
3682
+ lines.close();
3683
+ }
3684
+ }
3685
+ function signInWay(args, env = process.env) {
3686
+ if (wantsDevice(args, env)) {
3687
+ return { readCode: readCodeFromTerminal, print: (line2) => console.log(line2) };
3688
+ }
3689
+ return {
3690
+ openBrowser: (url) => {
3691
+ openBrowser(url);
3692
+ console.log("Signing in on another device, such as over SSH? Run this with --device.\n");
3693
+ }
3694
+ };
3695
+ }
3696
+ async function runLogin(args = []) {
3607
3697
  if (isLocal(apiUrl())) {
3608
3698
  console.log("The local dev server needs no sign-in.");
3609
3699
  return 0;
3610
3700
  }
3611
- await login(openBrowser);
3701
+ await login(signInWay(args));
3612
3702
  console.log("Signed in.");
3613
3703
  return 0;
3614
3704
  }
@@ -5415,6 +5505,25 @@ var Activator = class {
5415
5505
  }
5416
5506
  this.request([{ name: agent, version }]);
5417
5507
  }
5508
+ /**
5509
+ * The terminal an agent's activation runs in, for its owner to sign in from the web. One that
5510
+ * isn't running is started, and its terminal named once it's open. Null if it didn't open.
5511
+ */
5512
+ async terminalFor(agent, version, { waitMs = 6e4, pollMs = 200 } = {}) {
5513
+ if (this.current?.agent !== agent) this.request([{ name: agent, version }], { force: true });
5514
+ const until = Date.now() + waitMs;
5515
+ while (Date.now() < until && !this.stopped) {
5516
+ const current = this.current;
5517
+ if (current?.agent === agent) {
5518
+ const open = (await this.options.terminals.list().catch(() => [])).some(
5519
+ (t) => t.name === current.terminal && !t.exited
5520
+ );
5521
+ if (open) return current.terminal;
5522
+ }
5523
+ await new Promise((resolve8) => setTimeout(resolve8, pollMs));
5524
+ }
5525
+ return null;
5526
+ }
5418
5527
  /** Resolves once nothing is queued or running, as tests wait for. */
5419
5528
  async settled() {
5420
5529
  while (this.running) await this.running;
@@ -5515,7 +5624,8 @@ var Activator = class {
5515
5624
  async waitForCheckIn(version, driver, current, folder, wasSignedOut) {
5516
5625
  const { terminals } = this.options;
5517
5626
  const { agent } = current;
5518
- const deadline = Date.now() + (this.options.deadlineMs ?? ACTIVATION_DEADLINE_MS);
5627
+ const deadlineMs = this.options.deadlineMs ?? ACTIVATION_DEADLINE_MS;
5628
+ let deadline = Date.now() + deadlineMs;
5519
5629
  const restartMs = this.options.signInRestartMs ?? SIGN_IN_RESTART_MS;
5520
5630
  const checkInMs = this.options.checkInMs ?? CHECK_IN_MS;
5521
5631
  const pollMs = this.options.pollMs ?? ACTIVATION_POLL_MS;
@@ -5551,6 +5661,8 @@ var Activator = class {
5551
5661
  };
5552
5662
  await start();
5553
5663
  while (!current.checkedIn && Date.now() < deadline && !this.stopped) {
5664
+ const lookers = lookingAt((await terminals.list()).find((t) => t.name === current.terminal));
5665
+ if (lookers > 0) deadline = Math.max(deadline, Date.now() + deadlineMs);
5554
5666
  const reading = await driver.read(current.terminal).catch(() => null);
5555
5667
  if (reading?.state === "signed_out" && Date.now() - startedAt >= checkInMs) {
5556
5668
  seen = "signed_out";
@@ -6676,7 +6788,7 @@ async function runStart(options) {
6676
6788
  }
6677
6789
  }
6678
6790
  if (!options.signedIn()) {
6679
- print("Signing in through your browser.");
6791
+ print("Signing in.");
6680
6792
  await options.signIn();
6681
6793
  verbose2("signed in");
6682
6794
  } else verbose2("already signed in");
@@ -7043,7 +7155,8 @@ Usage: ${FRIDAY_COMMAND} <command>
7043
7155
 
7044
7156
  start Make this computer a machine, with the folder this runs in as its root, which
7045
7157
  holds your repos, such as ~/code. Signs in through your browser when
7046
- needed, asks how much of each agent you have the first time, starts the daemon
7158
+ needed, or with a code to paste from another device with --device or over
7159
+ SSH. Asks how much of each agent you have the first time, starts the daemon
7047
7160
  now and whenever you log in, then shows what's left to set up. Safe to run
7048
7161
  again. Run in another folder, it offers to move the root there. --config asks
7049
7162
  about the agents again, and --agents claude=lot,codex=little sets their shares
@@ -7120,7 +7233,7 @@ async function friday(args, deps = defaults()) {
7120
7233
  version: HERD_VERSION,
7121
7234
  run: verboseExec(deps.run),
7122
7235
  signedIn: () => isLocal(apiUrl()) || readCredentials() !== null,
7123
- signIn: () => login(openBrowser),
7236
+ signIn: () => login(signInWay(rest)),
7124
7237
  serviceKind: deps.serviceKind ?? await detectService(verboseExec(deps.run)),
7125
7238
  env: passedEnv(),
7126
7239
  print: deps.print,
@@ -7169,7 +7282,7 @@ ${HELP}`);
7169
7282
  }
7170
7283
  }
7171
7284
  async function confirmInTerminal(question) {
7172
- const lines = createInterface({ input: process.stdin, output: process.stdout });
7285
+ const lines = createInterface2({ input: process.stdin, output: process.stdout });
7173
7286
  try {
7174
7287
  return /^y(es)?$/i.test((await lines.question(`${question} [y/N] `)).trim());
7175
7288
  } finally {
@@ -10647,15 +10760,15 @@ async function signInGh(options) {
10647
10760
  const codeTimer = setTimeout(() => resolveCode(false), options.codeWaitMs ?? GH_CODE_WAIT_MS);
10648
10761
  const found = await codeShown;
10649
10762
  clearTimeout(codeTimer);
10650
- const device = readDeviceCode(output2);
10651
- if (!found || !device) {
10763
+ const device2 = readDeviceCode(output2);
10764
+ if (!found || !device2) {
10652
10765
  child.kill();
10653
10766
  const failed = { state: "failed", reason: "no_code" };
10654
10767
  await options.update(failed);
10655
10768
  return failed;
10656
10769
  }
10657
10770
  sent = true;
10658
- await options.update({ state: "code", code: device.code, url: device.url });
10771
+ await options.update({ state: "code", code: device2.code, url: device2.url });
10659
10772
  const approveTimer = setTimeout(() => child.kill(), options.approveWaitMs ?? GH_APPROVE_WAIT_MS);
10660
10773
  await exited;
10661
10774
  clearTimeout(approveTimer);
@@ -10986,9 +11099,13 @@ var Session = class {
10986
11099
  });
10987
11100
  }
10988
11101
  fromHost(message) {
10989
- if (message.type === "snapshot") {
11102
+ const size2 = typeof message.cols === "number" && typeof message.rows === "number" ? { cols: message.cols, rows: message.rows } : null;
11103
+ if (message.type === "resize" && size2) {
11104
+ this.flush();
11105
+ this.send({ type: "resize", ...size2 });
11106
+ } else if (message.type === "snapshot") {
10990
11107
  this.pending = "";
10991
- this.send({ type: "snapshot", data: message.data ?? "" });
11108
+ this.send({ type: "snapshot", data: message.data ?? "", ...size2 ?? {} });
10992
11109
  } else if (message.type === "data") {
10993
11110
  if (this.behind) return;
10994
11111
  this.pending += message.data ?? "";
@@ -14172,8 +14289,12 @@ async function runDaemon(args) {
14172
14289
  }),
14173
14290
  access: () => daemon?.configuration().config?.terminals?.remote ?? "drive",
14174
14291
  resolve: async (target) => {
14175
- if (target.kind !== "conversation") {
14176
- return { refused: "This machine can't open an agent's sign-in terminal from the web yet." };
14292
+ if (target.kind === "activation") {
14293
+ const cli = clis.find((candidate) => candidate.name === target.agent);
14294
+ if (!cli || !activator) return { refused: "This machine doesn't run that agent." };
14295
+ const terminal = await activator.terminalFor(cli.name, cli.version);
14296
+ if (!terminal) return { refused: "The agent's sign-in didn't start on this machine." };
14297
+ return { terminal, socketPath: await terminals.pathOf(terminal) };
14177
14298
  }
14178
14299
  const workspace = workspaces.get(target.conversationId);
14179
14300
  if (!workspace) return { refused: "This machine doesn't hold that conversation." };
@@ -15592,7 +15713,8 @@ var HELP4 = `herd: work on HerdingChats conversations from this computer.
15592
15713
  Usage: herd <command>
15593
15714
 
15594
15715
  On this computer:
15595
- login Sign in through your browser
15716
+ login [--device] Sign in through your browser. --device, or running over SSH, signs in from
15717
+ another device with a code to paste
15596
15718
  logout Remove this computer's sign-in
15597
15719
  daemon [--repo <path>]...
15598
15720
  Run this machine's daemon, working from its root folder. Repos given with
@@ -15676,7 +15798,7 @@ async function main(args) {
15676
15798
  console.log(HERD_VERSION);
15677
15799
  return 0;
15678
15800
  case "login":
15679
- return await runLogin();
15801
+ return await runLogin(rest);
15680
15802
  case "logout":
15681
15803
  return await runLogout();
15682
15804
  case "daemon":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herdingchats/friday",
3
- "version": "0.1.683",
3
+ "version": "0.1.701",
4
4
  "description": "Friday, HerdingChats' agent daemon, and the herd command it runs agents with.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,5 +22,5 @@
22
22
  "satori": "0.32.0",
23
23
  "zod": "4.6.7"
24
24
  },
25
- "gitHead": "a61eea3d8eae51e4b4365ef53c98c40dc64360f3"
25
+ "gitHead": "dee83b97beed4102f6d56d692f1c1e306b62dbb6"
26
26
  }
package/terminal-host.mjs CHANGED
@@ -230,6 +230,16 @@ var TerminalHost = class {
230
230
  if (held.info.exited) return;
231
231
  held.pty.resize(cols, rows);
232
232
  held.term.resize(cols, rows);
233
+ for (const socket of held.watchers) {
234
+ socket.write(`${JSON.stringify({ type: "resize", cols, rows })}
235
+ `);
236
+ }
237
+ }
238
+ /** The screen as `attach` and `watch` send it first: its contents, and its size. */
239
+ async sized(name) {
240
+ const held = this.get(name);
241
+ const data = await this.snapshot(name);
242
+ return { type: "snapshot", data, cols: held.term.cols, rows: held.term.rows };
233
243
  }
234
244
  subscribe(name, socket) {
235
245
  const held = this.get(name);
@@ -253,7 +263,7 @@ var TerminalHost = class {
253
263
  return;
254
264
  }
255
265
  if (size.cols && size.rows) this.resize(name, size.cols, size.rows);
256
- socket.write(`${JSON.stringify({ type: "snapshot", data: await this.snapshot(name) })}
266
+ socket.write(`${JSON.stringify(await this.sized(name))}
257
267
  `);
258
268
  held.viewers.add(socket);
259
269
  held.drivenAt = Date.now();
@@ -294,7 +304,7 @@ var TerminalHost = class {
294
304
  `);
295
305
  return;
296
306
  }
297
- socket.write(`${JSON.stringify({ type: "snapshot", data: await this.snapshot(name) })}
307
+ socket.write(`${JSON.stringify(await this.sized(name))}
298
308
  `);
299
309
  held.watchers.add(socket);
300
310
  held.subscribers.add(socket);