@herdingchats/friday 0.1.666 → 0.1.670

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 (2) hide show
  1. package/herd.mjs +242 -7
  2. package/package.json +2 -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.666", gitHead: "696b23f94dba08b3cf37c586ef28c4ce29fab715" };
21
+ define_HERD_PACKAGE_default = { version: "0.1.670", gitHead: "ec9dd13ab4eb2461db3f70f5798e1444a4ab281f" };
22
22
  }
23
23
  });
24
24
 
@@ -515,6 +515,9 @@ var init_terminals = __esm({
515
515
  z6.object({ type: z6.literal("input"), data: z6.string().max(64 * 1024) }),
516
516
  z6.object({ type: z6.literal("resize"), ...size }),
517
517
  z6.object({ type: z6.literal("drive") }),
518
+ // The browser's socket is open, so the screen can be sent: frames sent before a browser's
519
+ // socket has finished opening can be lost on the way, so it asks once it's ready.
520
+ z6.object({ type: z6.literal("ready") }),
518
521
  z6.object({ type: z6.literal("watch") }),
519
522
  z6.object({
520
523
  type: z6.literal("closed"),
@@ -611,6 +614,7 @@ var init_workers = __esm({
611
614
  "use strict";
612
615
  init_define_HERD_PACKAGE();
613
616
  init_activation();
617
+ init_terminals();
614
618
  CAPABILITIES = [
615
619
  "macos",
616
620
  "linux",
@@ -685,6 +689,7 @@ var init_workers = __esm({
685
689
  avoid: phrases.optional(),
686
690
  harnesses: z8.record(z8.string().regex(/^[a-z][a-z0-9-]*$/), harnessConfigSchema).optional(),
687
691
  builds: buildsSettingSchema.optional(),
692
+ terminals: z8.object({ remote: terminalAccessSchema.optional() }).strict().optional(),
688
693
  repos: z8.array(configRepoSchema).max(CONFIG_REPOS_MAX, `at most ${CONFIG_REPOS_MAX} repos`).optional()
689
694
  }).strict();
690
695
  HERD_CONFIG_TYPE = `type HerdConfig = {
@@ -698,6 +703,11 @@ var init_workers = __esm({
698
703
  * a build runner with its owner's approval, and a registered runner without it takes one.
699
704
  */
700
705
  builds?: true | { max: number };
706
+ /**
707
+ * What the web may do with this machine's terminals: "drive" (the default) to watch and type,
708
+ * "watch" to only watch, or "off".
709
+ */
710
+ terminals?: { remote?: "drive" | "watch" | "off" };
701
711
  /**
702
712
  * Repos the owner works in, so \`herd repos\` can tell an agent which one "the frontend" is. At
703
713
  * most ${CONFIG_REPOS_MAX}. Listing one clones nothing, and agents can add repos that aren't listed.
@@ -4524,7 +4534,9 @@ function registration(config, clis, facts = {}, cloned = []) {
4524
4534
  ...facts.configError ? { configError: facts.configError.slice(0, 1e3) } : {},
4525
4535
  ...facts.gh ? { gh: facts.gh } : {},
4526
4536
  ...facts.folderUnavailable ? { folderUnavailable: facts.folderUnavailable } : {},
4527
- herdVersion: HERD_VERSION
4537
+ herdVersion: HERD_VERSION,
4538
+ // Saying what it allows also says this daemon dials terminal sessions.
4539
+ terminals: { remote: facts.config?.terminals?.remote ?? "drive" }
4528
4540
  };
4529
4541
  }
4530
4542
  function describeCli(name, version) {
@@ -10397,6 +10409,7 @@ var Daemon = class {
10397
10409
  async onFrame(frame) {
10398
10410
  if (frame.type === "offer") return this.onOffer(frame.offer);
10399
10411
  if (frame.type === "sign_in") return this.options.onSignIn?.(frame);
10412
+ if (frame.type === "terminal_open") return this.options.onTerminalOpen?.(frame);
10400
10413
  if (frame.type === "message") {
10401
10414
  if (this.options.folderUnavailable?.()) {
10402
10415
  if (this.held.get(frame.conversationId) !== "interrupt") {
@@ -10849,6 +10862,207 @@ var MachineChecks = class {
10849
10862
  }
10850
10863
  };
10851
10864
 
10865
+ // src/remote-terminal.ts
10866
+ init_define_HERD_PACKAGE();
10867
+ init_src();
10868
+ import { connect as connect3 } from "node:net";
10869
+ var BATCH_MS = 16;
10870
+ var BEHIND_BYTES = 1024 * 1024;
10871
+ var RemoteTerminals = class {
10872
+ constructor(options) {
10873
+ this.options = options;
10874
+ }
10875
+ options;
10876
+ open = /* @__PURE__ */ new Set();
10877
+ async opened(frame) {
10878
+ const { options } = this;
10879
+ const access = options.access();
10880
+ const resolved = await options.resolve(frame.target).catch((error) => ({
10881
+ refused: `herd couldn't find the terminal: ${error}`
10882
+ }));
10883
+ const refusal = access === "off" ? "This machine doesn't allow its terminals to be opened from the web." : "refused" in resolved ? resolved.refused : null;
10884
+ const mode = frame.mode === "drive" && access === "drive" ? "drive" : "watch";
10885
+ const target = await options.target(frame.sessionId);
10886
+ const Impl = options.WebSocketImpl ?? WebSocket;
10887
+ const socket = new Impl(target.url, { headers: target.headers });
10888
+ const session = new Session(socket, {
10889
+ ...options,
10890
+ allowDrive: access === "drive",
10891
+ onEnd: () => this.open.delete(session)
10892
+ });
10893
+ this.open.add(session);
10894
+ socket.addEventListener("open", () => {
10895
+ if (refusal || "refused" in resolved) {
10896
+ session.refuse(refusal ?? "herd couldn't open that terminal.");
10897
+ return;
10898
+ }
10899
+ session.attach(resolved.terminal, resolved.socketPath, mode);
10900
+ });
10901
+ }
10902
+ /** Ends every session, as when the daemon stops. The terminals keep running. */
10903
+ stop() {
10904
+ for (const session of [...this.open]) session.end("machine_gone");
10905
+ }
10906
+ };
10907
+ var Session = class {
10908
+ constructor(socket, options) {
10909
+ this.socket = socket;
10910
+ this.options = options;
10911
+ socket.addEventListener("message", (event2) => this.fromSession(String(event2.data)));
10912
+ socket.addEventListener("close", () => this.end());
10913
+ socket.addEventListener("error", () => this.end());
10914
+ }
10915
+ socket;
10916
+ options;
10917
+ host = null;
10918
+ terminal = "";
10919
+ socketPath = "";
10920
+ pending = "";
10921
+ flushTimer = null;
10922
+ behind = false;
10923
+ drainTimer = null;
10924
+ ended = false;
10925
+ mode = "watch";
10926
+ refuse(detail2) {
10927
+ this.options.log?.(`A terminal session was refused: ${detail2}`);
10928
+ this.send({ type: "closed", reason: "refused", detail: detail2 });
10929
+ this.end();
10930
+ }
10931
+ /** Attaches to the terminal's host, watching or driving, which sends a fresh screen. */
10932
+ attach(terminal, socketPath, mode) {
10933
+ this.terminal = terminal;
10934
+ this.socketPath = socketPath;
10935
+ this.mode = mode;
10936
+ this.detachHost();
10937
+ const host = connect3(socketPath);
10938
+ this.host = host;
10939
+ let buffer = "";
10940
+ let answered = false;
10941
+ host.on(
10942
+ "connect",
10943
+ () => host.write(
10944
+ `${JSON.stringify(mode === "drive" ? { op: "attach", name: terminal } : { op: "watch", name: terminal })}
10945
+ `
10946
+ )
10947
+ );
10948
+ host.on("data", (chunk) => {
10949
+ buffer += chunk;
10950
+ let end = buffer.indexOf("\n");
10951
+ while (end >= 0) {
10952
+ const line2 = buffer.slice(0, end);
10953
+ buffer = buffer.slice(end + 1);
10954
+ end = buffer.indexOf("\n");
10955
+ let message;
10956
+ try {
10957
+ message = JSON.parse(line2);
10958
+ } catch {
10959
+ continue;
10960
+ }
10961
+ if (!answered) {
10962
+ answered = true;
10963
+ if (message.ok === false) {
10964
+ this.refuse(message.error ?? "The terminal's host wouldn't open it.");
10965
+ return;
10966
+ }
10967
+ this.send({ type: "mode", mode });
10968
+ if (message.ok === true) continue;
10969
+ }
10970
+ this.fromHost(message);
10971
+ }
10972
+ });
10973
+ host.on("error", (error) => {
10974
+ if (host === this.host)
10975
+ this.refuse(`The terminal's host couldn't be reached: ${error.message}`);
10976
+ });
10977
+ }
10978
+ fromHost(message) {
10979
+ if (message.type === "snapshot") {
10980
+ this.pending = "";
10981
+ this.send({ type: "snapshot", data: message.data ?? "" });
10982
+ } else if (message.type === "data") {
10983
+ if (this.behind) return;
10984
+ this.pending += message.data ?? "";
10985
+ this.flushTimer ??= setTimeout(() => this.flush(), this.options.batchMs ?? BATCH_MS);
10986
+ } else if (message.type === "exit") {
10987
+ this.flush();
10988
+ this.send({ type: "exit", exitCode: message.exitCode ?? null });
10989
+ this.end();
10990
+ }
10991
+ }
10992
+ flush() {
10993
+ if (this.flushTimer) clearTimeout(this.flushTimer);
10994
+ this.flushTimer = null;
10995
+ if (!this.pending) return;
10996
+ const data = this.pending;
10997
+ this.pending = "";
10998
+ this.send({ type: "data", data });
10999
+ if (this.socket.bufferedAmount > (this.options.behindBytes ?? BEHIND_BYTES)) {
11000
+ this.behind = true;
11001
+ this.drainTimer = setInterval(() => {
11002
+ if (this.socket.bufferedAmount > 0) return;
11003
+ if (this.drainTimer) clearInterval(this.drainTimer);
11004
+ this.drainTimer = null;
11005
+ this.behind = false;
11006
+ this.attach(this.terminal, this.socketPath, this.mode);
11007
+ }, this.options.drainPollMs ?? 100);
11008
+ }
11009
+ }
11010
+ fromSession(raw) {
11011
+ let parsed;
11012
+ try {
11013
+ parsed = JSON.parse(raw);
11014
+ } catch {
11015
+ return;
11016
+ }
11017
+ const frame = terminalFrameSchema.safeParse(parsed);
11018
+ if (!frame.success) return;
11019
+ const { data } = frame;
11020
+ if (data.type === "closed") {
11021
+ this.end();
11022
+ return;
11023
+ }
11024
+ if (data.type === "drive" || data.type === "watch") {
11025
+ this.mode = data.type === "drive" && this.options.allowDrive ? "drive" : "watch";
11026
+ if (this.terminal) this.attach(this.terminal, this.socketPath, this.mode);
11027
+ return;
11028
+ }
11029
+ if (this.mode !== "drive" || !this.host) return;
11030
+ if (data.type === "input")
11031
+ this.host.write(`${JSON.stringify({ type: "input", data: data.data })}
11032
+ `);
11033
+ if (data.type === "resize") {
11034
+ this.host.write(`${JSON.stringify({ type: "resize", cols: data.cols, rows: data.rows })}
11035
+ `);
11036
+ }
11037
+ }
11038
+ send(frame) {
11039
+ if (this.socket.readyState !== WebSocket.OPEN) return;
11040
+ try {
11041
+ this.socket.send(JSON.stringify(frame));
11042
+ } catch {
11043
+ this.end();
11044
+ }
11045
+ }
11046
+ detachHost() {
11047
+ const host = this.host;
11048
+ this.host = null;
11049
+ host?.destroy();
11050
+ }
11051
+ end(reason) {
11052
+ if (this.ended) return;
11053
+ this.ended = true;
11054
+ if (this.flushTimer) clearTimeout(this.flushTimer);
11055
+ if (this.drainTimer) clearInterval(this.drainTimer);
11056
+ this.detachHost();
11057
+ if (reason) this.send({ type: "closed", reason });
11058
+ try {
11059
+ this.socket.close(1e3);
11060
+ } catch {
11061
+ }
11062
+ this.options.onEnd();
11063
+ }
11064
+ };
11065
+
10852
11066
  // src/usage-reporter.ts
10853
11067
  init_define_HERD_PACKAGE();
10854
11068
  var USAGE_REPORT_MS = 5 * 60 * 1e3;
@@ -13933,13 +14147,33 @@ async function runDaemon(args) {
13933
14147
  const api = apiUrl();
13934
14148
  const terminals = new HostTerminals({ log: (line2) => console.log(line2) });
13935
14149
  await terminals.ensureHost();
14150
+ const signIn = async () => isLocal(api) ? localSignIn() : { Authorization: `Bearer ${await accessToken()}` };
13936
14151
  const daemonApi = {
13937
14152
  fetch: apiFetch,
13938
14153
  connection: async (machineId) => ({
13939
14154
  url: `${api.replace(/^http/, "ws")}/api/machines/${encodeURIComponent(machineId)}/connect`,
13940
- headers: isLocal(api) ? localSignIn() : { Authorization: `Bearer ${await accessToken()}` }
14155
+ headers: await signIn()
13941
14156
  })
13942
14157
  };
14158
+ const remoteTerminals = new RemoteTerminals({
14159
+ target: async (sessionId) => ({
14160
+ url: `${api.replace(/^http/, "ws")}/api/terminal-sessions/${sessionId}/machine?machine=${encodeURIComponent(loadConfig().machineId)}`,
14161
+ headers: await signIn()
14162
+ }),
14163
+ access: () => daemon?.configuration().config?.terminals?.remote ?? "drive",
14164
+ resolve: async (target) => {
14165
+ if (target.kind !== "conversation") {
14166
+ return { refused: "This machine can't open an agent's sign-in terminal from the web yet." };
14167
+ }
14168
+ const workspace = workspaces.get(target.conversationId);
14169
+ if (!workspace) return { refused: "This machine doesn't hold that conversation." };
14170
+ const lead = (await terminals.list()).find((t) => t.name === workspace.lead && !t.exited);
14171
+ if (!lead)
14172
+ return { refused: "The conversation's agent isn't running in a terminal right now." };
14173
+ return { terminal: lead.name, socketPath: await terminals.pathOf(lead.name) };
14174
+ },
14175
+ log: (line2) => console.log(line2)
14176
+ });
13943
14177
  let activator = null;
13944
14178
  let checks = null;
13945
14179
  const log = (line2) => console.log(line2);
@@ -14066,6 +14300,7 @@ async function runDaemon(args) {
14066
14300
  activations: () => activator?.states() ?? {},
14067
14301
  gh: () => checks?.gh ?? null,
14068
14302
  onSignIn: (frame) => void checks?.signIn(frame),
14303
+ onTerminalOpen: (frame) => void remoteTerminals.opened(frame).catch((error) => console.log(`A terminal session didn't open: ${error}`)),
14069
14304
  onConfigChanged: () => checks?.activateChosen(),
14070
14305
  folderUnavailable: () => folders.problem
14071
14306
  });
@@ -14301,7 +14536,7 @@ async function runAgyHook(event2, stdin, env, options = {}) {
14301
14536
  init_define_HERD_PACKAGE();
14302
14537
  import { spawn as spawn13 } from "node:child_process";
14303
14538
  import { readFileSync as readFileSync22 } from "node:fs";
14304
- import { connect as connect3 } from "node:net";
14539
+ import { connect as connect4 } from "node:net";
14305
14540
  import { homedir as homedir9 } from "node:os";
14306
14541
  import { join as join36 } from "node:path";
14307
14542
  var MAX_INPUT = 64 * 1024;
@@ -14397,7 +14632,7 @@ function runOwnCommand(command, input, cwd, timeoutMs = OWN_COMMAND_TIMEOUT_MS)
14397
14632
  }
14398
14633
  function tellDaemon(path, request, timeoutMs = REPORT_TIMEOUT_MS) {
14399
14634
  return new Promise((resolve8) => {
14400
- const socket = connect3(path);
14635
+ const socket = connect4(path);
14401
14636
  const done = () => {
14402
14637
  clearTimeout(timer);
14403
14638
  socket.destroy();
@@ -14680,11 +14915,11 @@ init_define_HERD_PACKAGE();
14680
14915
 
14681
14916
  // src/attach.ts
14682
14917
  init_define_HERD_PACKAGE();
14683
- import { connect as connect4 } from "node:net";
14918
+ import { connect as connect5 } from "node:net";
14684
14919
  var DETACH_KEY = "";
14685
14920
  function attach(name, { stdin, stdout } = { stdin: process.stdin, stdout: process.stdout }, path = currentHostSocketPath(), { watch: watch2 = false } = {}) {
14686
14921
  return new Promise((resolve8, reject) => {
14687
- const socket = connect4(path);
14922
+ const socket = connect5(path);
14688
14923
  const size2 = () => ({ cols: stdout.columns || 120, rows: stdout.rows || 40 });
14689
14924
  let buffer = "";
14690
14925
  let started = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herdingchats/friday",
3
- "version": "0.1.666",
3
+ "version": "0.1.670",
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": "696b23f94dba08b3cf37c586ef28c4ce29fab715"
25
+ "gitHead": "ec9dd13ab4eb2461db3f70f5798e1444a4ab281f"
26
26
  }