@herdingchats/friday 0.1.563 → 0.1.569

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 +246 -91
  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.563", gitHead: "055f38c1feb0ea86dac8b463c249d3f7cd782018" };
21
+ define_HERD_PACKAGE_default = { version: "0.1.569", gitHead: "ac28f0e97b92fbe2178387595a511d35c3ae4953" };
22
22
  }
23
23
  });
24
24
 
@@ -1319,6 +1319,19 @@ var init_events = __esm({
1319
1319
  }),
1320
1320
  event("terminal.exited", { terminalId: nonEmpty6, exitCode: z12.number().int().nullable() }),
1321
1321
  event("terminal.notice_failed", { terminalId: nonEmpty6, reason: nonEmpty6 }),
1322
+ /**
1323
+ * The agent's start didn't check in with `herd inbox` within its window, so it looks like it
1324
+ * needs someone at its terminal. `agent` is its CLI's name, and `detail` what the screen showed,
1325
+ * when herd recognized it.
1326
+ */
1327
+ event("terminal.needs_attention", {
1328
+ terminalId: nonEmpty6,
1329
+ agent: nonEmpty6,
1330
+ machineName: nonEmpty6,
1331
+ detail: z12.string().max(200).optional()
1332
+ }),
1333
+ /** A start that needed attention checked in after all. */
1334
+ event("terminal.checked_in", { terminalId: nonEmpty6 }),
1322
1335
  /** The daemon closed one of Claude Code's own dialogs that it has recorded, named by its title. */
1323
1336
  event("terminal.dialog_closed", { terminalId: nonEmpty6, dialog: nonEmpty6 }),
1324
1337
  // A subscribed event that didn't wake the conversation, with the reason. One that woke it is a
@@ -1466,6 +1479,9 @@ var init_conversation_state = __esm({
1466
1479
  "author_not_linked",
1467
1480
  // The only agents that could take it showed their own sign-in, so a person has to sign in.
1468
1481
  "agent_signed_out",
1482
+ // Its agent started and never checked in with `herd inbox`, so it looks like it needs someone
1483
+ // at its terminal.
1484
+ "agent_needs_attention",
1469
1485
  // The only machines that could take it have a folder that isn't there, such as a drive not
1470
1486
  // yet mounted.
1471
1487
  "folder_unavailable",
@@ -1568,6 +1584,7 @@ var init_conversation_state = __esm({
1568
1584
  author_not_linked: "the author's account isn't linked",
1569
1585
  delegate_waiting: "a delegate is waiting on someone",
1570
1586
  agent_signed_out: "its agent isn't signed in on its machine",
1587
+ agent_needs_attention: "its agent looks like it needs someone at its terminal",
1571
1588
  folder_unavailable: "its machine's folder isn't there",
1572
1589
  no_suitable_worker: "no worker may take it under its configuration",
1573
1590
  notice_failed: "a notice didn't reach the agent's terminal",
@@ -3156,6 +3173,7 @@ import { spawn as spawn6 } from "node:child_process";
3156
3173
  import { existsSync as existsSync11, readFileSync as readFileSync10 } from "node:fs";
3157
3174
  import { homedir as homedir4 } from "node:os";
3158
3175
  import { dirname as dirname10, join as join14 } from "node:path";
3176
+ import { createInterface } from "node:readline/promises";
3159
3177
  import { z as z22 } from "zod";
3160
3178
 
3161
3179
  // src/auth.ts
@@ -3384,7 +3402,7 @@ var HERD_FLAGS = {
3384
3402
  persona: []
3385
3403
  };
3386
3404
  var FRIDAY_FLAGS = {
3387
- start: ["--agents"],
3405
+ start: ["--agents", "--config"],
3388
3406
  stop: [],
3389
3407
  status: [],
3390
3408
  logs: ["--follow", "-f", "-n"]
@@ -4472,6 +4490,8 @@ var localRequestSchema = z19.object({
4472
4490
  "repo",
4473
4491
  "activate"
4474
4492
  ]),
4493
+ // The key of the agent's start, from `HERD_START`, which `herd inbox` proves the start with.
4494
+ start: z19.string().min(1).max(100).optional(),
4475
4495
  // `herd repo`: the repo it added to the conversation's folder, for the daemon to record.
4476
4496
  repo: z19.object({
4477
4497
  remote: z19.string().min(1).max(500),
@@ -4820,6 +4840,50 @@ async function permissionHook(raw, options = {}) {
4820
4840
  }
4821
4841
  }
4822
4842
 
4843
+ // src/timing.ts
4844
+ init_define_HERD_PACKAGE();
4845
+ var CHECK_IN_MS = 15e3;
4846
+ var VARIABLES = {
4847
+ settleMs: "HERD_SETTLE_MS",
4848
+ pollMs: "HERD_POLL_MS",
4849
+ confirmMs: "HERD_CONFIRM_MS",
4850
+ pasteMs: "HERD_PASTE_MS",
4851
+ renewMs: "HERD_RENEW_MS",
4852
+ hookSettleMs: "HERD_HOOK_SETTLE_MS",
4853
+ agyIdleSettleMs: "HERD_AGY_IDLE_SETTLE_MS",
4854
+ promptPollMs: "HERD_PROMPT_POLL_MS",
4855
+ noticeRetryMs: "HERD_NOTICE_RETRY_MS",
4856
+ investigateAfterMs: "HERD_INVESTIGATE_AFTER_MS",
4857
+ idleMs: "HERD_IDLE_MS",
4858
+ watchMs: "HERD_WATCH_MS",
4859
+ usageReportMs: "HERD_USAGE_REPORT_MS",
4860
+ usageStaleMs: "HERD_USAGE_STALE_MS",
4861
+ activationMs: "HERD_ACTIVATION_MS",
4862
+ activationPollMs: "HERD_ACTIVATION_POLL_MS",
4863
+ signInRestartMs: "HERD_SIGN_IN_RESTART_MS",
4864
+ checkInMs: "HERD_CHECK_IN_MS",
4865
+ machineCheckMs: "HERD_MACHINE_CHECK_MS"
4866
+ };
4867
+ function timingFromEnv(env) {
4868
+ const timing = {};
4869
+ for (const [key, name] of Object.entries(VARIABLES)) {
4870
+ const raw = env[name];
4871
+ if (raw === void 0 || raw === "") continue;
4872
+ const value = Number(raw);
4873
+ if (!Number.isFinite(value) || value < 0) {
4874
+ throw new HerdError(`${name} must be a number of milliseconds, not "${raw}".`);
4875
+ }
4876
+ timing[key] = value;
4877
+ }
4878
+ return timing;
4879
+ }
4880
+ function driverTiming(timing) {
4881
+ const { settleMs, pollMs, confirmMs } = timing;
4882
+ return Object.fromEntries(
4883
+ Object.entries({ settleMs, pollMs, confirmMs }).filter(([, value]) => value !== void 0)
4884
+ );
4885
+ }
4886
+
4823
4887
  // src/activation.ts
4824
4888
  var ACTIVATION_DEADLINE_MS = 3 * 60 * 1e3;
4825
4889
  var ACTIVATION_POLL_MS = 500;
@@ -5026,6 +5090,7 @@ var Activator = class {
5026
5090
  const { agent } = current;
5027
5091
  const deadline = Date.now() + (this.options.deadlineMs ?? ACTIVATION_DEADLINE_MS);
5028
5092
  const restartMs = this.options.signInRestartMs ?? SIGN_IN_RESTART_MS;
5093
+ const checkInMs = this.options.checkInMs ?? CHECK_IN_MS;
5029
5094
  const pollMs = this.options.pollMs ?? ACTIVATION_POLL_MS;
5030
5095
  let seen = "didnt_start";
5031
5096
  let startedAt = 0;
@@ -5060,7 +5125,7 @@ var Activator = class {
5060
5125
  await start();
5061
5126
  while (!current.checkedIn && Date.now() < deadline && !this.stopped) {
5062
5127
  const reading = await driver.read(current.terminal).catch(() => null);
5063
- if (reading?.state === "signed_out") {
5128
+ if (reading?.state === "signed_out" && Date.now() - startedAt >= checkInMs) {
5064
5129
  seen = "signed_out";
5065
5130
  if (!signingIn) {
5066
5131
  signingIn = true;
@@ -5170,16 +5235,18 @@ async function chooseAgents(options, print) {
5170
5235
  }
5171
5236
  const saved = loaded.status === "ok" ? loaded.config : void 0;
5172
5237
  const choices = agentChoices(installed, saved);
5173
- const asked = options.given ? null : await (options.pick?.(choices) ?? Promise.resolve(null));
5238
+ const asked = options.given || !options.ask ? null : await (options.pick?.(choices) ?? Promise.resolve(null));
5174
5239
  const shares = options.given ?? asked ?? // No terminal: what was chosen before, or what's installed.
5175
5240
  Object.fromEntries(choices.map((choice) => [choice.cli, choice.share]));
5176
- if (!sameChoice(saved, shares) || !existsSync5(options.configPath)) {
5241
+ const choosing = options.ask || options.given !== void 0;
5242
+ if (choosing && (!sameChoice(saved, shares) || !existsSync5(options.configPath))) {
5177
5243
  mkdirSync6(dirname6(options.configPath), { recursive: true, mode: 448 });
5178
5244
  writeFileSync5(options.configPath, configSource(withChoice(saved, shares)), { mode: 384 });
5179
5245
  }
5180
5246
  const chosen = ADAPTERS.map(({ cli }) => cli).filter((cli) => (shares[cli] ?? "off") !== "off");
5247
+ const change = options.ask ? "" : ` ${START_COMMAND} --config changes that.`;
5181
5248
  print(
5182
- chosen.length > 0 ? `This machine runs ${chosen.map((cli) => `${label2(cli)} (${SHARE_LABELS[shares[cli]]})`).join(", ")}.` : "This machine runs no agents, so it takes no work. Run this again to choose some."
5249
+ chosen.length > 0 ? `This machine runs ${chosen.map((cli) => `${label2(cli)} (${SHARE_LABELS[shares[cli]]})`).join(", ")}.${change}` : `This machine runs no agents, so it takes no work. ${START_COMMAND} --config chooses some.`
5183
5250
  );
5184
5251
  return chosen;
5185
5252
  }
@@ -5888,16 +5955,25 @@ async function runStart(options) {
5888
5955
  const existing = readRoot2(paths);
5889
5956
  const wanted = chooseRoot(options.cwd, options.home);
5890
5957
  const running = runningSupervisor(paths);
5958
+ let moving = false;
5891
5959
  if (existing && real(existing) !== real(wanted)) {
5892
5960
  print(`This machine's root is ${existing}, which holds the repos Friday works on.`);
5893
- if (running) {
5894
- print(`Friday is running there, version ${running.version}, so nothing changed.`);
5895
- await waitForAgents(options, await chooseAndAsk(options, print), print);
5896
- await printSetup(options, print);
5897
- return 0;
5961
+ moving = await options.confirm?.(
5962
+ `Make ${wanted} the root instead? Conversations already going carry on where they are.`
5963
+ ) ?? false;
5964
+ if (!moving) {
5965
+ print(
5966
+ options.confirm ? "The root stays where it is." : `Run this in a terminal to move the root to ${wanted}.`
5967
+ );
5968
+ if (running) {
5969
+ print(`Friday is running there, version ${running.version}, so nothing changed.`);
5970
+ await waitForAgents(options, await chooseAndAsk(options, print), print);
5971
+ await printSetup(options, print);
5972
+ return 0;
5973
+ }
5898
5974
  }
5899
5975
  }
5900
- const root = existing ?? wanted;
5976
+ const root = moving ? wanted : existing ?? wanted;
5901
5977
  if (running && root === existing) {
5902
5978
  print(`Friday is running from ${root}, version ${running.version}. Nothing changed.`);
5903
5979
  await waitForAgents(options, await chooseAndAsk(options, print), print);
@@ -5906,6 +5982,7 @@ async function runStart(options) {
5906
5982
  }
5907
5983
  const repo = insideWorkTree(root);
5908
5984
  if (repo) {
5985
+ if (moving) print("The root stays where it is.");
5909
5986
  throw new StartError(
5910
5987
  `${root} is inside the git repo ${repo}, so it can't be the machine's root: every conversation would load that repo's settings. Run ${START_COMMAND} in a folder outside any repo, such as the one that holds your checkouts, like ~/code.`
5911
5988
  );
@@ -5929,13 +6006,18 @@ async function runStart(options) {
5929
6006
  `Stopped the old daemon: removed ${retired.join(" and ")}. Its checkout is left as it was.`
5930
6007
  );
5931
6008
  await ensureInstalled(options, print);
5932
- mkdirSync10(root, { recursive: true });
5933
- writeRoot(paths, root);
5934
6009
  const command = [process.execPath, binOf(paths.current, "friday"), "supervise"];
5935
6010
  const env = { HERD_HOME: paths.herdHome, ...options.env };
5936
6011
  const service = (options.service ?? defaultService(options))(command, env);
6012
+ if (moving && running) {
6013
+ print(`Stopping Friday at ${existing} to start it again from ${root}.`);
6014
+ await service.stop();
6015
+ await stopped(options);
6016
+ }
6017
+ mkdirSync10(root, { recursive: true });
6018
+ writeRoot(paths, root);
5937
6019
  const changed = await service.install();
5938
- await service.start(changed);
6020
+ await service.start(changed || moving);
5939
6021
  print(
5940
6022
  `Friday is running from ${root}, version ${currentVersion(paths)}, ${service.kind === "launchd" ? "and starts whenever you log in" : service.kind === "systemd" ? "and starts whenever you log in, as a systemd user service" : "in the background"}.`
5941
6023
  );
@@ -5946,10 +6028,22 @@ async function runStart(options) {
5946
6028
  );
5947
6029
  return 0;
5948
6030
  }
6031
+ async function stopped(options) {
6032
+ const deadline = Date.now() + (options.stopWaitMs ?? 15e3);
6033
+ while (runningSupervisor(options.paths)) {
6034
+ if (Date.now() > deadline) {
6035
+ throw new StartError(
6036
+ `Friday didn't stop within ${Math.round((options.stopWaitMs ?? 15e3) / 1e3)} seconds, so the root wasn't moved. Run ${FRIDAY_COMMAND} stop, then this again.`
6037
+ );
6038
+ }
6039
+ await new Promise((done) => setTimeout(done, options.stopPollMs ?? 250));
6040
+ }
6041
+ }
5949
6042
  async function chooseAndAsk(options, print) {
5950
6043
  if (!options.agents) return null;
5951
6044
  const chosen = await chooseAgents(options.agents, print);
5952
6045
  if (!chosen) return null;
6046
+ if (!options.agents.ask && !options.agents.given) return null;
5953
6047
  return { chosen, since: askToActivate(options.paths.herdHome) };
5954
6048
  }
5955
6049
  async function waitForAgents(options, asked, print) {
@@ -6235,10 +6329,12 @@ Usage: ${FRIDAY_COMMAND} <command>
6235
6329
 
6236
6330
  start Make this computer a machine, with the folder this runs in as its root, which
6237
6331
  holds your repos, such as ~/code. Signs in through your browser when
6238
- needed, asks how much of each agent you have, starts the daemon now and whenever
6239
- you log in, waits for each agent to check in, then shows what's left to set up.
6240
- Safe to run again. --agents claude=lot,codex=little sets the shares without
6241
- asking: off, little, some or lot, where a bare name is some and one left out off
6332
+ needed, asks how much of each agent you have the first time, starts the daemon
6333
+ now and whenever you log in, then shows what's left to set up. Safe to run
6334
+ again. Run in another folder, it offers to move the root there. --config asks
6335
+ about the agents again, and --agents claude=lot,codex=little sets their shares
6336
+ without asking: off, little, some or lot, where a bare name is some and one
6337
+ left out off
6242
6338
  stop Stop the daemon until you log in again, or run start again
6243
6339
  status Whether the daemon is running, which version, and its root
6244
6340
  logs [-f] Show the daemon's latest log lines. -f keeps following them
@@ -6312,7 +6408,11 @@ async function friday(args, deps = defaults()) {
6312
6408
  print: deps.print,
6313
6409
  setup: fetchSetup,
6314
6410
  ...process.env.HERD_PACKAGE_SPEC ? { install: { spec: process.env.HERD_PACKAGE_SPEC } } : {},
6411
+ ...process.stdin.isTTY && process.stdout.isTTY ? { confirm: confirmInTerminal } : {},
6315
6412
  agents: {
6413
+ // The choice lives in herd.config.ts, so it's asked for only before there is one, or
6414
+ // with --config.
6415
+ ask: rest.includes("--config") || !existsSync11(herdConfigIn(deps.paths.herdHome)),
6316
6416
  installed: async () => installedAgents(),
6317
6417
  load: () => loadHerdConfig(herdConfigIn(deps.paths.herdHome)),
6318
6418
  configPath: herdConfigIn(deps.paths.herdHome),
@@ -6345,6 +6445,14 @@ ${HELP}`);
6345
6445
  throw error;
6346
6446
  }
6347
6447
  }
6448
+ async function confirmInTerminal(question) {
6449
+ const lines = createInterface({ input: process.stdin, output: process.stdout });
6450
+ try {
6451
+ return /^y(es)?$/i.test((await lines.question(`${question} [y/N] `)).trim());
6452
+ } finally {
6453
+ lines.close();
6454
+ }
6455
+ }
6348
6456
  function agentsFlag(args) {
6349
6457
  const at = args.findIndex((arg) => arg === "--agents" || arg.startsWith("--agents="));
6350
6458
  if (at < 0) return void 0;
@@ -6572,6 +6680,10 @@ init_define_HERD_PACKAGE();
6572
6680
  // src/commands/terminal.ts
6573
6681
  init_define_HERD_PACKAGE();
6574
6682
  var DAEMON_WAIT_MS = Number(process.env.HERD_DAEMON_WAIT_MS ?? 3e4);
6683
+ function withStart(request) {
6684
+ const start = process.env.HERD_START;
6685
+ return start ? { start, ...request } : request;
6686
+ }
6575
6687
  async function fromTerminal(request) {
6576
6688
  const socket = process.env.HERD_SOCKET;
6577
6689
  const secret = process.env.HERD_TERMINAL;
@@ -6581,7 +6693,7 @@ async function fromTerminal(request) {
6581
6693
  let reply = null;
6582
6694
  const deadline = Date.now() + DAEMON_WAIT_MS;
6583
6695
  while (!reply) {
6584
- reply = await askDaemon(socket, { secret, ...request }).catch(() => null);
6696
+ reply = await askDaemon(socket, { secret, ...withStart(request) }).catch(() => null);
6585
6697
  if (reply) break;
6586
6698
  if (Date.now() > deadline) throw new HerdError("Couldn't reach herd's daemon. Is it running?");
6587
6699
  await new Promise((resolve7) => setTimeout(resolve7, 500));
@@ -7404,6 +7516,9 @@ var ownPrompt = (draft) => OWN_PROMPTS.find(
7404
7516
  ) ?? (draft.startsWith(DELEGATE_START) || draft.length >= 40 && DELEGATE_START.startsWith(draft) ? draft : null);
7405
7517
  var SESSION_ID = /^[A-Za-z0-9-]{1,100}$/;
7406
7518
  var SUBMIT_RETRIES = 3;
7519
+ function withStartKey(command, startKey) {
7520
+ return startKey ? `HERD_START=${startKey} ${command}` : command;
7521
+ }
7407
7522
  var DEFAULT_TIMING = { settleMs: 800, pollMs: 150, startMs: 6e4, confirmMs: 3e3 };
7408
7523
  var sleep3 = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
7409
7524
  var EXIT_WAIT_MS = 5e3;
@@ -7460,12 +7575,12 @@ var AgentDriver = class {
7460
7575
  * `claude --resume <id>`. Resuming without an ID types `/resume` and picks the newest
7461
7576
  * conversation, which is this worktree's only agent.
7462
7577
  */
7463
- async start(name, cwd, { resume = false, sessionId = null, ownFolder: ownFolder2 } = {}) {
7578
+ async start(name, cwd, { resume = false, sessionId = null, ownFolder: ownFolder2, startKey = null } = {}) {
7464
7579
  const known = sessionId !== null && SESSION_ID.test(sessionId);
7465
7580
  const byId = resume && known;
7466
7581
  const command = byId ? `claude --resume ${sessionId}` : !resume && known ? `claude --session-id ${sessionId}` : "claude";
7467
7582
  await this.settle(name);
7468
- await this.terminals.type(name, command);
7583
+ await this.terminals.type(name, withStartKey(command, startKey));
7469
7584
  await this.terminals.press(name, "enter");
7470
7585
  const first = await this.until(
7471
7586
  name,
@@ -7758,11 +7873,11 @@ var AgyDriver = class {
7758
7873
  * waits for its prompt box. It trusts the folder only in a worktree the daemon made. agy chooses
7759
7874
  * a session's ID itself, and its `SessionStart` hook reports it.
7760
7875
  */
7761
- async start(name, cwd, { resume = false, sessionId = null, ownFolder: ownFolder2 } = {}) {
7876
+ async start(name, cwd, { resume = false, sessionId = null, ownFolder: ownFolder2, startKey = null } = {}) {
7762
7877
  const byId = resume && sessionId !== null && SESSION_ID.test(sessionId);
7763
7878
  const command = byId ? `agy --conversation=${sessionId} ${AGY_FLAGS}` : `agy ${AGY_FLAGS}`;
7764
7879
  await this.settle(name);
7765
- await this.terminals.type(name, command);
7880
+ await this.terminals.type(name, withStartKey(command, startKey));
7766
7881
  await this.terminals.press(name, "enter");
7767
7882
  const first = await this.until(
7768
7883
  name,
@@ -8749,11 +8864,11 @@ var CodexDriver = class {
8749
8864
  * Types `codex` into the settled shell, or `codex resume <id>` to continue a session, and waits
8750
8865
  * for its composer. Codex chooses a session's ID itself, when it takes the first prompt.
8751
8866
  */
8752
- async start(name, cwd, { resume = false, sessionId = null, ownFolder: ownFolder2 } = {}) {
8867
+ async start(name, cwd, { resume = false, sessionId = null, ownFolder: ownFolder2, startKey = null } = {}) {
8753
8868
  const byId = resume && sessionId !== null && SESSION_ID.test(sessionId);
8754
8869
  const command = byId ? `codex resume ${sessionId} ${CODEX_FLAGS}` : `codex ${CODEX_FLAGS}`;
8755
8870
  await this.settle(name);
8756
- await this.terminals.type(name, command);
8871
+ await this.terminals.type(name, withStartKey(command, startKey));
8757
8872
  await this.terminals.press(name, "enter");
8758
8873
  const first = await this.until(
8759
8874
  name,
@@ -9764,7 +9879,7 @@ var HostTerminals = class {
9764
9879
  await this.askFor(name, { op: "kill", name });
9765
9880
  }
9766
9881
  async retire(canStop) {
9767
- const stopped = [];
9882
+ const stopped2 = [];
9768
9883
  for (const host of await this.discover()) {
9769
9884
  if (host.current) continue;
9770
9885
  const version = host.hello.version ?? "unversioned";
@@ -9776,12 +9891,12 @@ var HostTerminals = class {
9776
9891
  continue;
9777
9892
  }
9778
9893
  if (await this.stop(host)) {
9779
- stopped.push(version);
9894
+ stopped2.push(version);
9780
9895
  this.log(`Retired terminal host ${version}, which had nothing left running.`);
9781
9896
  }
9782
9897
  }
9783
- if (stopped.length > 0) await this.discover();
9784
- return stopped;
9898
+ if (stopped2.length > 0) await this.discover();
9899
+ return stopped2;
9785
9900
  }
9786
9901
  /** Stops a host: politely where it knows how, and otherwise by its process. */
9787
9902
  async stop(host) {
@@ -9809,48 +9924,6 @@ var HostTerminals = class {
9809
9924
  }
9810
9925
  };
9811
9926
 
9812
- // src/timing.ts
9813
- init_define_HERD_PACKAGE();
9814
- var VARIABLES = {
9815
- settleMs: "HERD_SETTLE_MS",
9816
- pollMs: "HERD_POLL_MS",
9817
- confirmMs: "HERD_CONFIRM_MS",
9818
- pasteMs: "HERD_PASTE_MS",
9819
- renewMs: "HERD_RENEW_MS",
9820
- hookSettleMs: "HERD_HOOK_SETTLE_MS",
9821
- agyIdleSettleMs: "HERD_AGY_IDLE_SETTLE_MS",
9822
- promptPollMs: "HERD_PROMPT_POLL_MS",
9823
- noticeRetryMs: "HERD_NOTICE_RETRY_MS",
9824
- investigateAfterMs: "HERD_INVESTIGATE_AFTER_MS",
9825
- idleMs: "HERD_IDLE_MS",
9826
- watchMs: "HERD_WATCH_MS",
9827
- usageReportMs: "HERD_USAGE_REPORT_MS",
9828
- usageStaleMs: "HERD_USAGE_STALE_MS",
9829
- activationMs: "HERD_ACTIVATION_MS",
9830
- activationPollMs: "HERD_ACTIVATION_POLL_MS",
9831
- signInRestartMs: "HERD_SIGN_IN_RESTART_MS",
9832
- machineCheckMs: "HERD_MACHINE_CHECK_MS"
9833
- };
9834
- function timingFromEnv(env) {
9835
- const timing = {};
9836
- for (const [key, name] of Object.entries(VARIABLES)) {
9837
- const raw = env[name];
9838
- if (raw === void 0 || raw === "") continue;
9839
- const value = Number(raw);
9840
- if (!Number.isFinite(value) || value < 0) {
9841
- throw new HerdError(`${name} must be a number of milliseconds, not "${raw}".`);
9842
- }
9843
- timing[key] = value;
9844
- }
9845
- return timing;
9846
- }
9847
- function driverTiming(timing) {
9848
- const { settleMs, pollMs, confirmMs } = timing;
9849
- return Object.fromEntries(
9850
- Object.entries({ settleMs, pollMs, confirmMs }).filter(([, value]) => value !== void 0)
9851
- );
9852
- }
9853
-
9854
9927
  // src/usage-reporter.ts
9855
9928
  init_define_HERD_PACKAGE();
9856
9929
  var USAGE_REPORT_MS = 5 * 60 * 1e3;
@@ -10115,6 +10188,17 @@ var workspaceFields = z25.object({
10115
10188
  codexOffset: z25.number().default(0),
10116
10189
  /** When the agent was last started, so Codex's new session file is told from older ones. */
10117
10190
  agentStartedAt: z25.number().default(0),
10191
+ /**
10192
+ * The key the agent was last started with, as `HERD_START`. Its `herd inbox` carrying this key
10193
+ * is what proves the start works: never a hook, a prompt being taken, or the screen.
10194
+ */
10195
+ startKey: z25.string().nullable().default(null),
10196
+ /** Whether the current start has checked in. One from before keys counts as proven. */
10197
+ startProven: z25.boolean().default(true),
10198
+ /** When the check-in window of an unproven start began, or null once it's proven or for none. */
10199
+ checkInFrom: z25.number().nullable().default(null),
10200
+ /** Whether this start has been flagged as needing someone at its terminal, so it's said once. */
10201
+ needsAttention: z25.boolean().default(false),
10118
10202
  /** What agy's status line last said its agent was doing, and when. */
10119
10203
  agentState: z25.enum(["working", "idle"]).nullable().default(null),
10120
10204
  agentStateAt: z25.number().default(0)
@@ -10191,7 +10275,7 @@ async function claimChange(ws, workspace, change) {
10191
10275
 
10192
10276
  // src/workspaces/delivery.ts
10193
10277
  init_define_HERD_PACKAGE();
10194
- import { randomUUID as randomUUID6 } from "node:crypto";
10278
+ import { randomBytes as randomBytes6, randomUUID as randomUUID6 } from "node:crypto";
10195
10279
  import { join as join30 } from "node:path";
10196
10280
 
10197
10281
  // src/workspaces/repos.ts
@@ -10491,6 +10575,10 @@ async function setUp(ws, conversationId) {
10491
10575
  codexFile: null,
10492
10576
  codexOffset: 0,
10493
10577
  agentStartedAt: 0,
10578
+ startKey: null,
10579
+ startProven: true,
10580
+ checkInFrom: null,
10581
+ needsAttention: false,
10494
10582
  agentState: null,
10495
10583
  agentStateAt: 0
10496
10584
  };
@@ -10534,6 +10622,12 @@ async function openLead(ws, workspace) {
10534
10622
  });
10535
10623
  }
10536
10624
 
10625
+ // src/workspaces/watch.ts
10626
+ init_define_HERD_PACKAGE();
10627
+
10628
+ // src/workspaces/check-in.ts
10629
+ init_define_HERD_PACKAGE();
10630
+
10537
10631
  // src/workspaces/signed-out.ts
10538
10632
  init_define_HERD_PACKAGE();
10539
10633
  init_src();
@@ -10554,14 +10648,50 @@ async function releaseSignedOut(ws, workspace) {
10554
10648
  terminalNotice: false,
10555
10649
  idleSince: null,
10556
10650
  noticeFailedAt: null,
10557
- noticeFailure: null
10651
+ noticeFailure: null,
10652
+ checkInFrom: null,
10653
+ needsAttention: false
10558
10654
  });
10559
10655
  if (workspace.status !== "exited") await ws.report(id2, "exited");
10560
10656
  await ws.post(`${ws.conversationPath(id2)}/release`, { reason: "agent_signed_out" });
10561
10657
  }
10562
10658
 
10563
- // src/workspaces/watch.ts
10564
- init_define_HERD_PACKAGE();
10659
+ // src/workspaces/check-in.ts
10660
+ async function checkIn(ws, workspace, grant, start) {
10661
+ if (workspace.startProven || !start || start !== workspace.startKey) return;
10662
+ if (grant.terminalId !== workspace.lead) return;
10663
+ const id2 = workspace.conversationId;
10664
+ ws.update(id2, { startProven: true, checkInFrom: null, needsAttention: false });
10665
+ if (!workspace.needsAttention) return;
10666
+ ws.log(`${id2}: the agent checked in after all.`);
10667
+ await ws.terminalEvent(id2, {
10668
+ type: "terminal.checked_in",
10669
+ payload: { terminalId: workspace.lead }
10670
+ });
10671
+ }
10672
+ function checkInOverdue(ws, workspace, now = Date.now()) {
10673
+ return !workspace.startProven && workspace.checkInFrom !== null && now - workspace.checkInFrom >= (ws.options.checkInMs ?? CHECK_IN_MS);
10674
+ }
10675
+ async function followCheckIn(ws, workspace, reading) {
10676
+ if (!checkInOverdue(ws, workspace)) return false;
10677
+ if (reading?.state === "signed_out") {
10678
+ await releaseSignedOut(ws, workspace);
10679
+ return true;
10680
+ }
10681
+ if (workspace.needsAttention) return false;
10682
+ const id2 = workspace.conversationId;
10683
+ ws.update(id2, { needsAttention: true });
10684
+ ws.log(`${id2}: ${workspace.agent} started but didn't check in, so it needs someone.`);
10685
+ await ws.terminalEvent(id2, {
10686
+ type: "terminal.needs_attention",
10687
+ payload: {
10688
+ terminalId: workspace.lead,
10689
+ agent: workspace.agent,
10690
+ machineName: ws.options.machineName()
10691
+ }
10692
+ });
10693
+ return false;
10694
+ }
10565
10695
 
10566
10696
  // src/workspaces/investigation.ts
10567
10697
  init_define_HERD_PACKAGE();
@@ -10569,11 +10699,19 @@ import { randomUUID as randomUUID5 } from "node:crypto";
10569
10699
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync21 } from "node:fs";
10570
10700
  import { join as join29 } from "node:path";
10571
10701
  function needsInvestigator(ws, workspace, reading) {
10702
+ const allowed = ws.options.canInvestigate ? ws.options.canInvestigate() : workspace.agent === "claude-code";
10703
+ if (!allowed || !reading || workspace.investigation) return false;
10704
+ if (reading.state !== "unknown" || reading.knownDialog || reading.program === "shell") {
10705
+ return false;
10706
+ }
10707
+ const trigger = investigationTrigger(ws, workspace);
10708
+ return trigger !== null && workspace.investigatedFailure !== trigger;
10709
+ }
10710
+ function investigationTrigger(ws, workspace) {
10711
+ if (workspace.needsAttention && checkInOverdue(ws, workspace)) return workspace.checkInFrom;
10572
10712
  const failedAt = workspace.noticeFailedAt;
10573
- if (workspace.agent === "codex" || workspace.agent === "agy") return false;
10574
- return Boolean(
10575
- reading && failedAt !== null && workspace.deferred && !workspace.investigation && workspace.investigatedFailure !== failedAt && Date.now() - failedAt >= (ws.options.investigateAfterMs ?? INVESTIGATE_AFTER_MS) && reading.state === "unknown" && !reading.knownDialog && reading.program !== "shell"
10576
- );
10713
+ const after = ws.options.investigateAfterMs ?? INVESTIGATE_AFTER_MS;
10714
+ return failedAt !== null && workspace.deferred && Date.now() - failedAt >= after ? failedAt : null;
10577
10715
  }
10578
10716
  async function openInvestigator(ws, workspace) {
10579
10717
  const id2 = workspace.conversationId;
@@ -10583,10 +10721,11 @@ async function openInvestigator(ws, workspace) {
10583
10721
  );
10584
10722
  const secret = ws.options.local().grant({ terminalId: terminal, conversationId: id2, investigates: workspace.lead });
10585
10723
  ws.update(id2, {
10586
- investigatedFailure: workspace.noticeFailedAt,
10724
+ investigatedFailure: investigationTrigger(ws, workspace),
10587
10725
  investigation: { terminal, secret, startedAt: Date.now(), screen, key: null, promptId: null }
10588
10726
  });
10589
10727
  ws.log(`${id2}: opened an investigator for a screen herd can't read.`);
10728
+ if (workspace.agent !== "claude-code") configureClaude(ws, workspace);
10590
10729
  const shell = ws.options.shell ?? process.env.SHELL ?? "/bin/sh";
10591
10730
  try {
10592
10731
  await ws.options.terminals.create({
@@ -10757,7 +10896,8 @@ async function watchOne(ws, conversationId, terminals, rows = []) {
10757
10896
  lead = (await ws.options.terminals.list()).find((t) => t.name === workspace?.lead);
10758
10897
  }
10759
10898
  let reading = lead && !lead.exited ? await ws.driverFor(workspace).read(workspace.lead) : null;
10760
- if (reading?.state === "signed_out") return releaseSignedOut(ws, workspace);
10899
+ if (await followCheckIn(ws, workspace, reading)) return;
10900
+ workspace = ws.get(conversationId) ?? workspace;
10761
10901
  if (reading && !reading.knownDialog && workspace.closedDialog) {
10762
10902
  workspace = ws.update(conversationId, { closedDialog: null });
10763
10903
  }
@@ -10856,12 +10996,12 @@ async function retireHosts(ws) {
10856
10996
  if (!terminals.retire || Date.now() - ws.retiredAt < every) return;
10857
10997
  ws.retiredAt = Date.now();
10858
10998
  const inHost = (version) => Object.values(ws.load()).filter((workspace) => workspace.hostVersion === version);
10859
- const stopped = await terminals.retire(
10999
+ const stopped2 = await terminals.retire(
10860
11000
  (version) => inHost(version).every(
10861
11001
  (workspace) => !workspace.claimId && (workspace.status === "idle" || workspace.status === "exited")
10862
11002
  )
10863
11003
  );
10864
- for (const version of stopped) {
11004
+ for (const version of stopped2) {
10865
11005
  for (const workspace of inHost(version)) {
10866
11006
  const id2 = workspace.conversationId;
10867
11007
  await ws.serially(id2, async () => {
@@ -10939,11 +11079,18 @@ async function deliver(ws, conversationId, delivery) {
10939
11079
  });
10940
11080
  }
10941
11081
  const resume = workspace.started && (!ownId || workspace.sessionId !== null);
10942
- workspace = ws.update(conversationId, { agentStartedAt: Date.now() });
11082
+ workspace = ws.update(conversationId, {
11083
+ agentStartedAt: Date.now(),
11084
+ startKey: randomBytes6(12).toString("hex"),
11085
+ startProven: false,
11086
+ checkInFrom: null,
11087
+ needsAttention: false
11088
+ });
10943
11089
  await ws.driverFor(workspace).start(workspace.lead, workspace.folder, {
10944
11090
  resume,
10945
11091
  sessionId: workspace.sessionId,
10946
- ownFolder: isHerdFolder(workspace.folder, rootOf(ws))
11092
+ ownFolder: isHerdFolder(workspace.folder, rootOf(ws)),
11093
+ startKey: workspace.startKey
10947
11094
  });
10948
11095
  workspace = ws.update(conversationId, {
10949
11096
  started: true,
@@ -10952,16 +11099,15 @@ async function deliver(ws, conversationId, delivery) {
10952
11099
  askedToReply: false,
10953
11100
  handledThisTurn: false
10954
11101
  });
10955
- if ((await ws.driverFor(workspace).read(workspace.lead)).state === "signed_out") {
10956
- return releaseSignedOut(ws, workspace);
10957
- }
10958
11102
  if (!await claim(ws, workspace)) return;
10959
11103
  const result2 = await submitKeepingClaim(
10960
11104
  ws,
10961
11105
  conversationId,
10962
11106
  workspace.readSeq === 0 ? workspace.parent ? delegatePrompt(workspace.parent) : STARTING_PROMPT : NOTICE
10963
11107
  );
10964
- return afterSubmit(ws, ws.get(conversationId) ?? workspace, result2);
11108
+ workspace = ws.get(conversationId) ?? workspace;
11109
+ if (!workspace.startProven) workspace = ws.update(conversationId, { checkInFrom: Date.now() });
11110
+ return afterSubmit(ws, workspace, result2);
10965
11111
  }
10966
11112
  let reading = await ws.driverFor(workspace).read(workspace.lead);
10967
11113
  if (reading.knownDialog) {
@@ -11352,7 +11498,10 @@ async function answer(ws, grant, request, gone = new AbortController().signal) {
11352
11498
  if (request.op === "activate") {
11353
11499
  return { ok: false, error: "Only an agent's activation terminal can check in." };
11354
11500
  }
11355
- if (request.op === "inbox") return inbox(ws, workspace);
11501
+ if (request.op === "inbox") {
11502
+ await checkIn(ws, workspace, grant, request.start);
11503
+ return inbox(ws, ws.get(grant.conversationId) ?? workspace);
11504
+ }
11356
11505
  if (request.op === "send") return send(ws, workspace, request);
11357
11506
  if (request.op === "channels") return channels(ws, workspace);
11358
11507
  if (request.op === "watch") return watch(ws, workspace, request);
@@ -12643,6 +12792,11 @@ async function runDaemon(args) {
12643
12792
  activations: {
12644
12793
  fromTerminal: async (grant, request) => activator?.fromTerminal(grant, request) ?? { ok: false, error: "This activation is over." }
12645
12794
  },
12795
+ // The investigator is Claude Code, so any agent's stuck screen gets one where it's activated.
12796
+ canInvestigate: () => Boolean(
12797
+ daemon?.chosenClis().some((cli) => cli.name === "claude-code") && activator?.states()["claude-code"]?.state === "activated"
12798
+ ),
12799
+ ...timing.checkInMs === void 0 ? {} : { checkInMs: timing.checkInMs },
12646
12800
  // The agent stops being offered before its conversation goes back, then activates again.
12647
12801
  agentSignedOut: async (agent) => {
12648
12802
  const version = daemon?.chosenClis().find((cli) => cli.name === agent)?.version ?? "unknown";
@@ -12680,6 +12834,7 @@ async function runDaemon(args) {
12680
12834
  ...timing.activationMs === void 0 ? {} : { deadlineMs: timing.activationMs },
12681
12835
  ...timing.activationPollMs === void 0 ? {} : { pollMs: timing.activationPollMs },
12682
12836
  ...timing.signInRestartMs === void 0 ? {} : { signInRestartMs: timing.signInRestartMs },
12837
+ ...timing.checkInMs === void 0 ? {} : { checkInMs: timing.checkInMs },
12683
12838
  log
12684
12839
  });
12685
12840
  checks = new MachineChecks({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herdingchats/friday",
3
- "version": "0.1.563",
3
+ "version": "0.1.569",
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": "055f38c1feb0ea86dac8b463c249d3f7cd782018"
25
+ "gitHead": "ac28f0e97b92fbe2178387595a511d35c3ae4953"
26
26
  }