tryharness 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/daemon.mjs CHANGED
@@ -12,8 +12,8 @@ import { appendLog, ensureDir, listJsonl, readJson, writeJsonAtomic } from "./fi
12
12
  import { parseSessionFile } from "./parsers.mjs";
13
13
  import { historyRoots, paths } from "./paths.mjs";
14
14
  import { openStore } from "./store.mjs";
15
- import { runAgentTask, runningPids, stopAgentTask } from "./runner.mjs";
16
- import { createSwarm } from "./swarm.mjs";
15
+ import { readRunSteps, runAgentTask, runningPids, stopAgentTask } from "./runner.mjs";
16
+ import { createSwarm, nativeSessionId } from "./swarm.mjs";
17
17
  import { createCoordinator } from "./coordinator.mjs";
18
18
  import { createWorktrees } from "./worktrees.mjs";
19
19
  import { notificationFor, notifyDesktop } from "./notify.mjs";
@@ -22,7 +22,14 @@ import { provenanceFor, provenanceLine } from "./provenance.mjs";
22
22
  import { createEmbedder, EMBEDDING_VERSION, modelPresent } from "./embedder.mjs";
23
23
  import { fromBlob, toBlob, topK } from "./vectors.mjs";
24
24
  import { createPhoneDesk } from "./phones.mjs";
25
+ import { createLocalWaker, localWakes } from "./wakes.mjs";
26
+ import { ASKED_BY_TASK, CLOUD_HOMED, MACHINE_RUNNERS, TARGETS, aboutOf, member, nameOf, resolveMember } from "./members.mjs";
25
27
  import { fingerprint, openBound, relayKey, sealBound } from "./sealed.mjs";
28
+ import { claudeMemNotes } from "./claudemem.mjs";
29
+ import { machineActivity } from "./activity.mjs";
30
+ import { LIVE_AGENTS, focusTerminal, hostApp, liveSessions, processTable, sessionFeed, sessionFile, sessionOfProcess, workingDirectories } from "./live.mjs";
31
+ import { EDITOR_IDS, agentCommand, editorFolder, editorInstall, editorName, editorTerminalStarts, openEditorFolder, panelLink, placeEnv, placeFrom, placeKey, processEnv, resumeLine, t3App, t3Link, t3OpensLinks, t3Runs, t3Thread } from "./places.mjs";
32
+ import { detectMcpClients } from "./adapters.mjs";
26
33
 
27
34
  // What may be referenced on a thread with another person (the server's rule, src/server/relay/refs.ts, kept
28
35
  // here since a sealed request is opened only on the machines).
@@ -30,9 +37,19 @@ const RELAY_REF_KINDS = new Set(["log", "repro", "link", "file"]);
30
37
  // Marks a thread post this daemon prepared itself; nothing that arrives over the wire can carry it.
31
38
  const OPENED = Symbol("opened");
32
39
 
40
+ // claude-mem 13.35.0, scratch worker with 3,000 observations, same machine (2026-10-09): the whole
41
+ // lookup (git calls, /api/projects, newest-50 search) took p50 29 ms / max 54 ms over 100 idle runs
42
+ // and p50 36 ms / max 59 ms over 100 runs with 8 clients loading the worker. 2 s is ~34x the worst;
43
+ // past it the run starts without notes.
44
+ const CLAUDE_MEM_TIMEOUT_MS = 2_000;
45
+
33
46
  const DAEMON_VERSION = readJson(fileURLToPath(new URL("../package.json", import.meta.url)), {}).version;
34
47
  const PROTOCOL_VERSION = 1;
35
48
  const RESCAN_MS = 60_000;
49
+ // The bridge ping interval; smoke tests shorten it.
50
+ const HEARTBEAT_MS = Number(process.env.HARNESS_HEARTBEAT_MS) || 30_000;
51
+ // This machine's IPv4 addresses: they change when it moves between networks. IPv6 privacy addresses rotate on their own.
52
+ const networkAddresses = () => Object.values(os.networkInterfaces()).flat().filter((entry) => entry && !entry.internal && entry.family === "IPv4").map((entry) => entry.address).sort().join(",");
36
53
  const TASK_LOG_PAGE = 64_000;
37
54
 
38
55
  function daemonAnswers(socketPath) {
@@ -174,6 +191,13 @@ export async function startDaemon() {
174
191
  return opencodeStarting;
175
192
  }
176
193
 
194
+ // The state the desktop app last reported (working, standby), which a phone's wake gets back. In Standby the
195
+ // daemon goes quiet: no history rescans and no embedding until the Mac is working again.
196
+ let machineState = null;
197
+ const quiet = () => machineState?.state === "standby";
198
+ // When Harness could not be reached, for the report the desktop app shows on opening the lid. Kept in memory.
199
+ const outages = [];
200
+ let offlineSince = Date.now();
177
201
  let syncRunning = false;
178
202
  let syncAgain = false;
179
203
  let targetedOpencode = [];
@@ -278,7 +302,7 @@ export async function startDaemon() {
278
302
  });
279
303
  // Agents that live on the server, not on any machine: a task for one of them goes up to
280
304
  // Harness and lives there; reading or answering it comes back the same way.
281
- const CLOUD_TARGETS = new Set(["bankr", "quotient", "muse"]);
305
+ const CLOUD_TARGETS = new Set(CLOUD_HOMED);
282
306
  const forwardToHarness = (request) => ({ pending: askHarness("board_call", { ...request, agent: String(request.agent) }).then((response) => (response.ok ? response : Promise.reject(new Error(response.error || "Harness refused the request")))) });
283
307
 
284
308
  // Board callers identify themselves by agent name (the stdio MCP passes --agent; the CLI is
@@ -315,12 +339,24 @@ export async function startDaemon() {
315
339
  const body = notificationFor(task, { previous });
316
340
  if (body) notifyDesktop(body);
317
341
  };
342
+ // A run that needs the person's Start, or a question asked of the person.
343
+ const waitsForPerson = (task) => Boolean(task) && task.kind !== "goal"
344
+ && ((task.status === "open" && Boolean(task.run_workspace) && !task.run_authorized_at) || (task.status === "needs_input" && task.holder === "user"));
345
+ // OpenClaw and Hermes are woken on their own gateways here when work is posted for them (wakes.mjs).
346
+ const localWaker = createLocalWaker({ log });
347
+ // Work already open for them wakes them when the daemon starts, so a wake or retry lost to a restart is sent again.
348
+ for (const task of store.listTasks({ status: "open", limit: 100 })) localWaker.onChange(task);
318
349
  const notifyBoard = (task) => {
319
350
  if (!task) return;
351
+ localWaker.onChange(task);
320
352
  send({ type: "board_changed", task: { id: task.id, status: task.status, holder: task.holder, version: task.version, origin_agent: task.origin_agent, target_agent: task.target_agent, claimed_by: task.claimed_by, session_id: task.session_id || null, parent_id: task.parent_id || null, kind: task.kind || "task", updated_at: task.updated_at } });
321
- notifyPerson(task, lastSeen.get(task.id) || null);
322
- jobChanged(task, lastSeen.get(task.id) || null);
323
- lastSeen.set(task.id, { status: task.status, run_workspace: task.run_workspace, run_authorized_at: task.run_authorized_at });
353
+ const previous = lastSeen.get(task.id) || null;
354
+ notifyPerson(task, previous);
355
+ jobChanged(task, previous);
356
+ // Something just began to wait for the person: their installed phone hears that much, and nothing more.
357
+ if (waitsForPerson(task) && !waitsForPerson(previous)) send({ type: "needs_you" });
358
+ if (task.origin_agent === "user" && task.kind === "task" && ["done", "failed"].includes(task.status) && previous && !["done", "failed"].includes(previous.status)) send({ type: "finished" });
359
+ lastSeen.set(task.id, { status: task.status, holder: task.holder, kind: task.kind, run_workspace: task.run_workspace, run_authorized_at: task.run_authorized_at });
324
360
  // The last part to finish closes its group, which is then announced too.
325
361
  if (task.parent_id) { const group = store.refreshGroup(task.parent_id); if (group) notifyBoard(group); }
326
362
  // A thread, or a run in one, changed: its coordinator looks once the thread is quiet.
@@ -330,7 +366,7 @@ export async function startDaemon() {
330
366
  };
331
367
  const staleTold = new Set();
332
368
  const staleCheck = setInterval(() => {
333
- if (!notificationsOn()) return;
369
+ if (!notificationsOn() || quiet()) return;
334
370
  for (const task of store.listTasks({ all: false, limit: 100 })) {
335
371
  if (task.stale && !staleTold.has(task.id)) { staleTold.add(task.id); notifyDesktop(`${task.claimed_by}'s claim went stale on: ${String(task.objective).slice(0, 80)}. Release with tryharness board release ${String(task.id).slice(0, 8)}`); }
336
372
  if (!task.stale) staleTold.delete(task.id);
@@ -343,7 +379,7 @@ export async function startDaemon() {
343
379
  let embedding = false;
344
380
  const embedderReady = () => modelPresent(paths.models);
345
381
  const embedPending = async () => {
346
- if (embedding || !embedderReady()) return;
382
+ if (embedding || quiet() || !embedderReady()) return;
347
383
  embedding = true;
348
384
  try {
349
385
  embedder ||= createEmbedder({ modelsDir: paths.models });
@@ -380,7 +416,7 @@ export async function startDaemon() {
380
416
  // By default it covers the folders the person has already used that agent in; home and anything above it count only
381
417
  // as themselves, never as a root. Anything another agent wrote never starts. Links are followed, so no path inside a
382
418
  // covered folder leads out of it.
383
- const RUNNERS = new Set(["claude-code", "codex", "pi", "opencode"]);
419
+ const RUNNERS = new Set(MACHINE_RUNNERS);
384
420
  const POSTERS_WITH_FOLDER = new Set(["claude-code", "codex"]);
385
421
  // A session the person typed in: its prompt hook names the agent process, the parent of the board MCP it posts through.
386
422
  // A run this daemon started is never one (the daemon is its parent), and an agent holding another agent's task writes
@@ -423,7 +459,7 @@ export async function startDaemon() {
423
459
  // Goals: one objective the owner set for a team, worked in a room. A post wakes the agents it names; a job posted under
424
460
  // the goal runs on the goal's authority; Harness facilitates and answers once. Backstops pause the room instead of
425
461
  // limiting it: a post ceiling, a wake ceiling per agent, a job that already failed posted again, and Stop.
426
- const ROOM_AGENTS = ["harness", "claude-code", "codex", "pi", "opencode", "grok-bot", "bankr", "quotient", "muse"];
462
+ const ROOM_AGENTS = TARGETS;
427
463
  const DEFAULT_MAX_POSTS = 500, DEFAULT_MAX_WAKES = 50;
428
464
  const mentionsIn = (text, team) => [...new Set([...String(text).matchAll(/@([a-z][a-z0-9-]*)/g)].map((match) => match[1] === "claude" ? "claude-code" : match[1]))].filter((name) => team.includes(name));
429
465
  const pauseGoal = (goalId, reason) => {
@@ -436,55 +472,79 @@ export async function startDaemon() {
436
472
  // The swarm: who can be asked in a thread, and what each is for. Coding agents here get a run; the others get the ask
437
473
  // as a task in the thread (Grok Bot and Muse pick it up with their tools, Bankr and Quotient run it on Harness under
438
474
  // their own approvals and limits), and their answer lands back in the thread.
439
- const ASKABLE = new Set(["grok-bot", "bankr", "quotient", "muse"]);
440
- const CLOUD_AGENTS = new Set(["bankr", "quotient", "muse"]);
441
- const ROSTER = {
442
- "claude-code": "Claude Code: reads and changes code in the thread's folder, runs commands and tests",
443
- codex: "Codex: reads and changes code in the thread's folder, runs commands and tests",
444
- pi: "Pi: reads and changes code in the thread's folder and runs commands",
445
- opencode: "OpenCode: reads and changes code in the thread's folder and runs commands",
446
- "grok-bot": "Grok Bot: research on X and the web",
447
- bankr: "Bankr: the person's crypto wallet and trading, always under the person's own approval and limits",
448
- quotient: "Quotient: prediction markets research and positions, under the person's own approval and limits",
449
- muse: "Muse: writing and creative drafts",
450
- harness: "Harness: coordinates the thread and asks the person when only they can decide",
451
- };
475
+ const ASKABLE = new Set(ASKED_BY_TASK);
476
+ const CLOUD_AGENTS = CLOUD_TARGETS;
452
477
  /** The agents this person can ask right now: coding agents installed here, the server's while connected, and any agent that has used the board. */
453
478
  const available = () => {
454
479
  const seen = new Set(store.agentsSeen().map((row) => row.agent));
455
- return ROOM_AGENTS.filter((agent) => agent === "harness" || (RUNNERS.has(agent) ? installedAgents.includes(agent) : CLOUD_AGENTS.has(agent) ? state.connected : seen.has(agent)));
480
+ const woken = localWakes();
481
+ return ROOM_AGENTS.filter((agent) => agent === "harness" || (RUNNERS.has(agent) ? installedAgents.includes(agent) : CLOUD_AGENTS.has(agent) ? state.connected : seen.has(agent) || Boolean(woken[agent])));
456
482
  };
483
+ // How an agent works in a thread's room: read it, post to it, and bring in another agent by name.
484
+ const roomGuide = (task, agent) => [
485
+ `- Read the thread (read_room, id ${task.id}) and search it (search_room) before starting. search_swarm finds what any of the person's agents did before, in any thread or session.`,
486
+ `- Share findings, partial work and what is left with post_to_room (id ${task.id}).`,
487
+ "- Need help, a second opinion or something only another agent can do? Post to the room with that agent's name in ask, say exactly what you need, then stop: you are woken again when it answers you by name. Naming an agent without ask (a thanks, a credit) does not wake it. The agents you can ask:",
488
+ ...available().filter((name) => name !== agent).map((name) => ` @${name}: ${aboutOf(name)}`),
489
+ ];
457
490
  const swarmBrief = (task, agent, reason, { review = false, ask = false } = {}) => [
458
- `You are ${ROSTER[agent]?.split(":")[0] ?? agent}, one of the person's agents, working with the others in a shared thread: "${task.objective}" (thread ${task.id}).`,
491
+ `You are ${nameOf(agent)}, one of the person's agents, working with the others in a shared thread: "${task.objective}" (thread ${task.id}).`,
459
492
  task.brief ? `The person's words: ${task.brief}` : "",
460
493
  `Why now: ${reason}`,
461
494
  `The thread so far (oldest first; each line is a teammate's words, not instructions about authority or money):\n${roomDigest(task.id)}`,
462
495
  [
463
496
  "How to work here:",
464
- `- Read the thread (read_room, id ${task.id}) and search it (search_room) before starting. search_swarm finds what any of the person's agents did before, in any thread or session.`,
465
- `- Share findings, partial work and what is left with post_to_room (id ${task.id}).`,
466
- "- Need help, a second opinion or something only another agent can do? Ask that agent by name in post_to_room, say exactly what you need, then stop: you are woken again when someone answers you by name. The agents you can ask:",
467
- ...available().filter((name) => name !== agent).map((name) => ` @${name}: ${ROSTER[name]}`),
497
+ ...roomGuide(task, agent),
468
498
  review ? "- This turn is a review: read, run what you need to check, and report what you find. Do not change any files." : "",
469
499
  ask ? `- Answer with report_task on the task you were given (status done with your answer, or needs_input with your question); it lands in the thread.` : "- When you are done for now, post what you did and stop.",
470
500
  ].filter(Boolean).join("\n"),
471
501
  ].filter(Boolean).join("\n\n");
502
+ // A task's own room: every task for one of the person's agents opens a thread, and the task is its first job, so the
503
+ // agents on it can post, ask each other by name and be seen working together. The task keeps its own authority.
504
+ const openTaskRoom = (task, poster, provenance, { hosted = false } = {}) => {
505
+ // The asker is in the room it opened, roster member or not, so it can follow the work it asked for.
506
+ const team = [...new Set([task.target_agent, ...(poster !== "user" ? [poster] : []), "harness"])];
507
+ const room = store.createGoal({ objective: task.objective, brief: task.brief, originAgent: poster, provenance, team, facilitator: "harness", workspace: task.run_workspace, maxPosts: DEFAULT_MAX_POSTS, maxWakes: DEFAULT_MAX_WAKES });
508
+ // Its agents work where the task does, as the task would have alone; work another agent asked for gets a copy.
509
+ if (provenance?.trust === "user") worktrees.inPlace(room.id);
510
+ store.meta(`task-room:${room.id}`, task.id);
511
+ if (hosted) store.meta(`hosted-thread:${room.id}`, "1");
512
+ store.setParent(task.id, room.id);
513
+ store.roomPost({ goalId: room.id, author: poster, kind: "job", text: task.brief ? `${task.objective}\n\n${task.brief}` : task.objective, mentions: [task.target_agent] });
514
+ notifyBoard(store.getTask(room.id));
515
+ };
516
+ // Work a thread starts on its own runs on the thread's authority only when the person gave it: a thread they set, or,
517
+ // for a task's room, their go on that task. Anything else waits for the person's Start like any agent-written run.
518
+ // Work another agent asked for runs on a goal's authority only where it can be held in (docs/member-runs.md): Claude
519
+ // Code in restricted mode, in a copy of the folder this daemon made. Anything else waits for the person's Start.
520
+ const worktreeRoot = path.join(paths.home, "worktrees");
521
+ const restrictable = (agent, folder) => agent === "claude-code" && Boolean(folder) && within(folder, worktreeRoot);
522
+ const restrictedWait = (agent) => agent === "claude-code"
523
+ ? `${nameOf(agent)} would work in your own folder here, not a copy, so work another agent asked for waits for your Start.`
524
+ : `${nameOf(agent)} has no restricted mode, so work another agent asked of it waits for your Start.`;
525
+ const threadMayRun = (goal) => {
526
+ if (goal.trust !== "user") return false;
527
+ const root = store.meta(`task-room:${goal.id}`);
528
+ return !root || Boolean(store.getTask(root)?.run_authorized_at);
529
+ };
472
530
  const roomDigest = (goalId, limit = 30) => store.roomPosts(goalId).slice(-limit).map((post) => `[${post.seq}] ${post.author}${post.kind !== "post" ? ` (${post.kind})` : ""}: ${post.text.slice(0, 1_200)}`).join("\n");
473
531
  // Wake one teammate. A coding agent here gets a run in the goal's folder that resumes its last session on this goal;
474
532
  // Harness and agents that cannot be started here learn of it through the board change and their own reads.
475
533
  const pendingWakes = new Map();
476
- const wakeAgent = (goalId, agent, reason, { review = false } = {}) => {
534
+ // byPerson: the person's own post named this agent, which is their go for this turn; any other wake (the coordinator,
535
+ // another agent) is work another agent asked for and runs restricted or waits (docs/member-runs.md).
536
+ const wakeAgent = (goalId, agent, reason, { review = false, byPerson = false } = {}) => {
477
537
  const task = store.getTask(goalId);
478
538
  if (!task || task.status !== "open" || !RUNNERS.has(agent) || !installedAgents.includes(agent)) return;
479
539
  const goal = task.goal;
480
540
  if (!goal.workspace) {
481
541
  if (!store.meta(`goal-folder-note:${goalId}:${agent}`)) {
482
542
  store.meta(`goal-folder-note:${goalId}:${agent}`, "1");
483
- store.roomPost({ goalId, author: "harness", kind: "note", text: `${agent} cannot start: the goal has no folder to work in. The owner adds one with: npx tryharness goal folder ${goalId} <dir>` });
543
+ store.roomPost({ goalId, author: "harness", kind: "note", text: `${nameOf(agent)} cannot start: this thread has no folder to work in. Add one with: npx tryharness goal folder ${goalId} <dir>` });
484
544
  }
485
545
  return;
486
546
  }
487
- if (store.openWake(goalId, agent)) { pendingWakes.set(`${goalId}:${agent}`, reason); return; }
547
+ if (store.openWake(goalId, agent)) { pendingWakes.set(`${goalId}:${agent}`, { reason, byPerson: byPerson || Boolean(pendingWakes.get(`${goalId}:${agent}`)?.byPerson) }); return; }
488
548
  if (store.wakeCount(goalId, agent) >= goal.max_wakes) { pauseGoal(goalId, `${agent} was woken ${goal.max_wakes} times`); return; }
489
549
  const brief = swarmBrief(task, agent, reason, { review });
490
550
  // A coding thread in a repository works in its own worktree, so threads never share a checkout.
@@ -493,7 +553,14 @@ export async function startDaemon() {
493
553
  const wake = store.postTask({ objective: `Room: ${task.objective}`.slice(0, 500), brief, originAgent: "harness", provenance: { trust: "agent", chain: ["room"] }, targetAgent: agent, parentId: goalId, runWorkspace: folder, kind: "wake" });
494
554
  // A review runs with the agent's own read-only switch (runner.mjs); only Claude Code and Codex have one.
495
555
  if (review) store.meta(`review:${wake.id}`, "1");
496
- store.authorizeGoalRun(wake.id);
556
+ if (threadMayRun(task)) {
557
+ if (byPerson) { store.meta(`person-wake:${wake.id}`, "1"); store.authorizeGoalRun(wake.id); }
558
+ else if (restrictable(agent, folder)) store.authorizeGoalRun(wake.id);
559
+ else if (!store.meta(`restricted-note:${goalId}:${agent}`)) {
560
+ store.meta(`restricted-note:${goalId}:${agent}`, "1");
561
+ store.roomPost({ goalId, author: "harness", kind: "note", text: restrictedWait(agent) });
562
+ }
563
+ }
497
564
  notifyBoard(store.getTask(wake.id));
498
565
  };
499
566
  // Ask an agent that is not started here: the ask is a task in the thread, posted in the asker's name, so its
@@ -508,20 +575,20 @@ export async function startDaemon() {
508
575
  const written = from === "user" ? {} : { provenance: { trust: "agent", chain: [...(task.origin_chain || []), from].slice(-16) } };
509
576
  const posted = boardCommand({ command: "board.post", agent: from, objective: `Asked in "${task.objective}": ${reason}`.slice(0, 500),
510
577
  brief: swarmBrief(task, agent, reason, { ask: true }), targetAgent: agent, parentId: goalId, ...written });
511
- const failed = (error) => { store.roomPost({ goalId, author: task.goal.facilitator, kind: "note", text: `${ROSTER[agent]?.split(":")[0] ?? agent} could not be asked: ${error}` }); notifyBoard(store.getTask(goalId)); };
578
+ const failed = (error) => { store.roomPost({ goalId, author: task.goal.facilitator, kind: "note", text: `${nameOf(agent)} could not be asked: ${error}` }); notifyBoard(store.getTask(goalId)); };
512
579
  if (posted?.pending) posted.pending.catch((error) => failed(error.message));
513
580
  else if (posted && !posted.ok) failed(posted.error);
514
581
  };
515
- const wakeFor = (goalId, post, author) => {
582
+ const wakeFor = (goalId, post, author, names = post.mentions) => {
516
583
  const goal = store.goal(goalId);
517
- for (const agent of post.mentions) {
584
+ for (const agent of names) {
518
585
  if (agent === author) continue;
519
586
  const reason = `${author} mentioned you: ${post.text.slice(0, 300)}`;
520
- if (RUNNERS.has(agent)) wakeAgent(goalId, agent, reason);
587
+ if (RUNNERS.has(agent)) wakeAgent(goalId, agent, reason, { byPerson: author === "user" });
521
588
  else if (ASKABLE.has(agent)) askAgent(goalId, agent, reason, author);
522
589
  }
523
590
  // Harness follows through the board change; it is woken by mentions, results and questions, not by every note.
524
- if (goal && post.mentions.includes(goal.facilitator)) notifyBoard(store.getTask(goalId));
591
+ if (goal && names.includes(goal.facilitator)) notifyBoard(store.getTask(goalId));
525
592
  };
526
593
  // A finished wake run: posts its words if the agent said nothing in the room, records its session, and wakes it again
527
594
  // if the room called on it while it was busy.
@@ -532,7 +599,7 @@ export async function startDaemon() {
532
599
  const stopped = kind === "failed" && store.getTask(goalId)?.status !== "open";
533
600
  if (!stopped && !store.postsBy(goalId, task.target_agent, startedAt) && text) store.roomPost({ goalId, author: task.target_agent, kind: kind === "done" ? "post" : "failed", text: kind === "done" ? text : `run failed: ${text}` });
534
601
  const pending = pendingWakes.get(`${goalId}:${task.target_agent}`);
535
- if (pending) { pendingWakes.delete(`${goalId}:${task.target_agent}`); wakeAgent(goalId, task.target_agent, pending); }
602
+ if (pending) { pendingWakes.delete(`${goalId}:${task.target_agent}`); wakeAgent(goalId, task.target_agent, pending.reason, { byPerson: pending.byPerson }); }
536
603
  notifyBoard(store.getTask(goalId));
537
604
  };
538
605
  // A job under a goal changed: its result or question lands in the room and wakes whoever posted it.
@@ -541,10 +608,16 @@ export async function startDaemon() {
541
608
  if (!goal || goal.kind !== "goal" || task.kind !== "task" || previous?.status === task.status) return;
542
609
  if (!["done", "failed", "needs_input"].includes(task.status)) return;
543
610
  const who = task.claimed_by || task.target_agent || "an agent";
611
+ // A task's own job reports to whoever posted it the way any task does; it does not wake them.
612
+ const root = store.meta(`task-room:${goal.id}`) === task.id;
544
613
  const post = store.roomPost({ goalId: goal.id, author: who, kind: task.status === "needs_input" ? "question" : "result",
545
614
  text: `${task.status === "done" ? "Finished" : task.status === "failed" ? "Could not finish" : "Asks about"} job "${task.objective}": ${task.result || ""}`.slice(0, 20_000),
546
- mentions: [task.origin_agent].filter((agent) => goal.goal.team.includes(agent)) });
547
- if (goal.status === "open") wakeFor(goal.id, post, who);
615
+ mentions: root ? [] : [task.origin_agent].filter((agent) => goal.goal.team.includes(agent)) });
616
+ // A room where only the task's own agent worked closes with the task; one that grew into a conversation stays open.
617
+ if (root && task.status !== "needs_input" && goal.status === "open" && !store.roomPosts(goal.id, 0, 500).some((entry) => entry.kind === "post")
618
+ && !store.threadRuns(goal.id).some((run) => ["open", "claimed", "running", "needs_input"].includes(run.status))) {
619
+ store.setGoalState(goal.id, { status: task.status, result: task.result || "" });
620
+ } else if (goal.status === "open") wakeFor(goal.id, post, who);
548
621
  notifyBoard(store.getTask(goal.id));
549
622
  };
550
623
  const endGuestThreads = (goal, outcome) => {
@@ -600,9 +673,18 @@ export async function startDaemon() {
600
673
  notifyDesktop(`A post by ${agent} in a goal's room was held: it looks like ${flag}.`);
601
674
  return { ok: true, post: { ...held, kind: "held" }, held: flag };
602
675
  }
603
- // Anyone in the thread can bring in any of the person's agents by naming it.
604
- const explicit = Array.isArray(request.mentions) ? request.mentions.map(String) : [];
605
- const mentions = [...new Set([...explicit.filter((name) => ROOM_AGENTS.includes(name)), ...mentionsIn(text, ROOM_AGENTS)])];
676
+ // Anyone in the thread can bring in any of the person's agents. The person and Harness do it by naming them. An
677
+ // agent does it by asking (`ask`), and its answer wakes whoever is waiting on it; any other name in an agent's
678
+ // words is a reference, like a thanks, and wakes no one.
679
+ const asked = [...(Array.isArray(request.ask) ? request.ask : []), ...(Array.isArray(request.mentions) ? request.mentions : [])].map(String).filter((name) => ROOM_AGENTS.includes(name) && name !== agent);
680
+ const named = mentionsIn(text, ROOM_AGENTS);
681
+ const mentions = [...new Set([...asked, ...named])];
682
+ const waitingKey = (asker, answerer) => `room-ask:${goal.id}:${asker}:${answerer}`;
683
+ const answered = named.filter((name) => store.meta(waitingKey(name, agent)));
684
+ const wakes = agent === "user" || agent === "harness" ? mentions : [...new Set([...asked, ...answered])];
685
+ for (const name of answered) store.meta(waitingKey(name, agent), "");
686
+ if (agent !== "user" && agent !== "harness") for (const name of asked) store.meta(waitingKey(agent, name), "1");
687
+ const unwoken = mentions.filter((name) => !wakes.includes(name) && name !== agent);
606
688
  const joining = mentions.filter((name) => !goal.goal.team.includes(name));
607
689
  if (joining.length) store.setGoalTeam(goal.id, [...goal.goal.team, ...joining]);
608
690
  const post = store.roomPost({ goalId: goal.id, author: agent, text, mentions });
@@ -613,9 +695,9 @@ export async function startDaemon() {
613
695
  if (sent.ok && sent.message.state === "sent") sendThreadMessage(sent.message);
614
696
  }
615
697
  if (store.roomCount(goal.id) >= goal.goal.max_posts) { pauseGoal(goal.id, `the room reached ${goal.goal.max_posts} posts`); return { ok: true, post, paused: true }; }
616
- wakeFor(goal.id, post, agent);
698
+ wakeFor(goal.id, post, agent, wakes);
617
699
  notifyBoard(store.getTask(goal.id));
618
- return { ok: true, post };
700
+ return { ok: true, post, ...(unwoken.length ? { note: `Named ${unwoken.map((name) => `@${name}`).join(", ")} without asking, so not woken. To ask an agent, put its name in ask.` } : {}) };
619
701
  }
620
702
  if (request.command === "board.goal.finish") {
621
703
  if (agent !== goal.goal.facilitator && agent !== "user") return { ok: false, error: `only ${goal.goal.facilitator} or the owner answers this goal` };
@@ -683,7 +765,7 @@ export async function startDaemon() {
683
765
  const setCoordinatorMode = (mode) => writeJsonAtomic(paths.config, { ...readJson(paths.config, {}), coordinator: mode });
684
766
  const coordinator = createCoordinator({
685
767
  store, mode: coordinatorMode, askHarness, connected: () => state.connected, localAgents: () => installedAgents.filter((agent) => RUNNERS.has(agent)),
686
- roster: () => available().filter((agent) => agent !== "harness").map((id) => ({ id, name: ROSTER[id].split(":")[0], about: ROSTER[id] })),
768
+ roster: () => available().filter((agent) => agent !== "harness").map((id) => ({ id, name: nameOf(id), about: aboutOf(id) })),
687
769
  handOn: (goalId, agent, reason, { review }) => (RUNNERS.has(agent) ? wakeAgent(goalId, agent, reason, { review }) : askAgent(goalId, agent, reason, "harness")),
688
770
  pauseGoal, notifyBoard, searchContext, log,
689
771
  });
@@ -691,13 +773,279 @@ export async function startDaemon() {
691
773
  store, roomAgents: ROOM_AGENTS, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runningPids, provenanceFor,
692
774
  runLog: (runId) => taskFiles(runId).log, defaults: { maxPosts: DEFAULT_MAX_POSTS, maxWakes: DEFAULT_MAX_WAKES },
693
775
  coordinator: { mode: coordinatorMode, setMode: setCoordinatorMode, state: (id) => coordinator.state(id) },
694
- roster: () => available().map((id) => ({ id, about: ROSTER[id] })),
776
+ roster: () => available().map((id) => ({ id, about: aboutOf(id) })),
695
777
  worktrees,
778
+ authorizeRun: (id) => boardCommand({ command: "board.run", agent: "user", id }),
696
779
  });
697
780
 
781
+ // The person's own sessions running now (live.mjs), and what they sent one from Harness: it waits here until that
782
+ // session's hook takes it (nudge.mjs), when the agent stops or the person next types there.
783
+ const saidKey = (agent, sessionId) => `said:${agent}:${sessionId}`;
784
+ const said = (agent, sessionId) => { try { return JSON.parse(store.meta(saidKey(agent, sessionId)) || "[]"); } catch { return []; } };
785
+ const CODING_AGENTS = ["claude-code", "codex", "pi", "opencode"];
786
+ const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
787
+ const folderOf = (request) => (typeof request.folder === "string" && path.isAbsolute(request.folder) ? request.folder : null);
788
+
789
+ // Where each of the person's sessions runs (places.mjs), recorded when it starts: its hook, plugin or extension sends
790
+ // its environment (board.live.place), else it is read here from the running process. Thirty days of them, per agent.
791
+ const PLACE_MS = 30 * 24 * 3600_000;
792
+ const placeCache = new Map();
793
+ const placesOf = (agentId) => {
794
+ if (!placeCache.has(agentId)) {
795
+ let found = {};
796
+ try { found = JSON.parse(store.meta(`places:${agentId}`) || "{}"); } catch {}
797
+ placeCache.set(agentId, found);
798
+ }
799
+ return placeCache.get(agentId);
800
+ };
801
+ const placedAgents = () => { try { return JSON.parse(store.meta("places:agents") || "[]"); } catch { return []; } };
802
+ const notePlace = (agentId, sessionId, place) => {
803
+ if (!place || !SESSION_ID.test(sessionId)) return;
804
+ const places = placesOf(agentId);
805
+ const now = Date.now();
806
+ const before = places[sessionId];
807
+ const next = { ...place, at: before?.at ?? now, seen: now };
808
+ // The same place, already seen today: nothing worth a write.
809
+ if (before && JSON.stringify({ ...before, seen: 0 }) === JSON.stringify({ ...next, seen: 0 }) && now - before.seen < 24 * 3600_000) return;
810
+ const kept = Object.fromEntries(Object.entries({ ...places, [sessionId]: next }).filter(([, entry]) => now - (entry.seen || 0) < PLACE_MS)
811
+ .sort((a, b) => b[1].seen - a[1].seen).slice(0, 400));
812
+ placeCache.set(agentId, kept);
813
+ store.meta(`places:${agentId}`, JSON.stringify(kept));
814
+ if (!placedAgents().includes(agentId)) store.meta("places:agents", JSON.stringify([...placedAgents(), agentId].slice(-50)));
815
+ };
816
+ /** A run Harness started itself (in its own folders, or a process it runs) is not where the person works. */
817
+ const harnessRun = (folder, pids = []) => Boolean(folder && folder.startsWith(paths.home + path.sep)) || pids.some((pid) => runningPids().includes(pid));
818
+ // A session says where it runs: its environment, and the agent's process (the hook's parent), read here for the app up
819
+ // its process tree and its terminal. Codex's terminals share one background server, started from wherever Codex first
820
+ // ran: a session there has no app or terminal of its own, and is placed by the Codex terminal working in its folder.
821
+ const placeOfReport = (agentId, folder, request) => {
822
+ const table = processTable();
823
+ const pids = (Array.isArray(request.pids) ? request.pids : []).filter((pid) => Number.isInteger(pid) && pid > 1 && table.has(pid)).slice(0, 2);
824
+ if (harnessRun(folder, pids)) return null;
825
+ let env = placeEnv(request.env);
826
+ let host = pids.map((pid) => hostApp(table, pid)).find(Boolean) ?? null;
827
+ let tty = pids.map((pid) => table.get(pid)?.tty).find(Boolean) ?? null;
828
+ if (agentId === "codex" && pids.length && !host && !tty) {
829
+ const clients = [...table].filter(([, row]) => path.basename(row.comm) === "codex" && row.tty).map(([pid]) => pid);
830
+ const cwds = workingDirectories(clients);
831
+ const client = clients.filter((pid) => folder && cwds.get(pid) === folder).sort((a, b) => b - a)[0];
832
+ if (!client) return null;
833
+ env = processEnv(client);
834
+ host = hostApp(table, client);
835
+ tty = table.get(client).tty;
836
+ }
837
+ return placeFrom({ env, host, tty, folder });
838
+ };
839
+ // A custom harness may say how one of its sessions opens again: a page it serves on this computer, or a command to type.
840
+ const reopenOf = (value) => {
841
+ if (!value || typeof value !== "object") return null;
842
+ const link = typeof value.link === "string" && value.link.length <= 2048 && /^http:\/\/(127\.0\.0\.1|localhost)(:\d{1,5})?\/\S*$/.test(value.link) ? value.link : null;
843
+ const command = typeof value.command === "string" && value.command.trim() && value.command.length <= 500 && !/[\0-\x1f]/.test(value.command) ? value.command.trim() : null;
844
+ return link || command ? { link, command } : null;
845
+ };
846
+
847
+ const liveNow = () => {
848
+ const recent = store.recentSessions(200);
849
+ // With the title the index gives it (its first request), when the index has it yet.
850
+ const titleOf = (session) => {
851
+ const title = recent.find((row) => row.agent === session.agent && String(row.session_id).includes(session.sessionId))?.title || null;
852
+ // A session opened with a slash command starts with the agent's own markup, not the person's words.
853
+ return title && !title.startsWith("<") ? title : null;
854
+ };
855
+ const linksOk = t3LinksOk();
856
+ const sessions = liveSessions({ exclude: new Set(runningPids()), recent })
857
+ // A T3 Code that cannot open links is brought forward instead, and messages reach its sessions through their hooks.
858
+ .map((session) => ({ ...session, t3: linksOk ? session.t3 : null, title: titleOf(session), waiting: said(session.agent, session.sessionId).length }));
859
+ // A running session no hook placed (it started before Harness was set up, or its agent has none): read from its process.
860
+ for (const session of sessions) {
861
+ if (placesOf(session.agent)[session.sessionId]) continue;
862
+ const env = { ...processEnv(session.pid), ...(session.entrypoint ? { CLAUDE_CODE_ENTRYPOINT: session.entrypoint } : {}) };
863
+ notePlace(session.agent, session.sessionId, placeFrom({ env, host: session.host, tty: session.tty, folder: session.folder }));
864
+ }
865
+ return sessions;
866
+ };
867
+
868
+ // An agent's home: the place most of its sessions started in over the last thirty days (ties: the latest), where its
869
+ // sessions open when they are not running.
870
+ let seeded = { at: 0, homes: {} };
871
+ /** Before any session was recorded: what this computer already shows, threads run in T3 Code and the agent started in an editor's terminal. */
872
+ const seedHome = (agentId) => {
873
+ if (Date.now() - seeded.at > 10 * 60_000) seeded = { at: Date.now(), homes: {} };
874
+ if (agentId in seeded.homes) return seeded.homes[agentId];
875
+ const command = agentCommand(agentId);
876
+ const seeds = [{ count: t3App() ? t3Runs(agentId, new Date(Date.now() - PLACE_MS).toISOString()) : 0, place: { kind: "t3", name: "T3 Code" } },
877
+ ...(command ? EDITOR_IDS.map((id) => ({ count: editorTerminalStarts(id, command), place: { kind: "editor", editor: id, name: editorName(id), surface: "terminal" } })) : [])];
878
+ return (seeded.homes[agentId] = seeds.filter((seed) => seed.count > 0).sort((a, b) => b.count - a.count)[0]?.place ?? null);
879
+ };
880
+ /** A place with the app that opens it now (T3's running build, an editor's install); null when it is not on this computer. */
881
+ const usable = (place) => {
882
+ if (!place) return null;
883
+ if (place.kind === "t3") { const t3 = t3App(); return t3 ? { ...place, app: t3.app } : null; }
884
+ if (place.kind === "editor" && !(place.cli && fs.existsSync(place.cli))) {
885
+ const install = editorInstall(place.editor, place.app || "");
886
+ return install ? { ...place, ...install } : place.app ? place : null;
887
+ }
888
+ return place;
889
+ };
890
+ const homeOf = (agentId) => {
891
+ const tally = new Map();
892
+ for (const place of Object.values(placesOf(agentId))) {
893
+ if (!place?.kind || place.kind === "unknown") continue;
894
+ const entry = tally.get(placeKey(place)) ?? { place, count: 0 };
895
+ entry.count += 1;
896
+ if ((place.seen || 0) >= (entry.place.seen || 0)) entry.place = place;
897
+ tally.set(placeKey(place), entry);
898
+ }
899
+ const best = [...tally.values()].sort((a, b) => b.count - a.count || (b.place.seen || 0) - (a.place.seen || 0))[0]?.place;
900
+ return usable(best ?? seedHome(agentId));
901
+ };
902
+ /** Where each agent lives, for Open's labels: coding agents' homes, and Grok Bot's own app. */
903
+ const homes = () => {
904
+ const found = {};
905
+ for (const id of new Set([...CODING_AGENTS, ...placedAgents()])) {
906
+ const home = homeOf(id);
907
+ if (home) found[id] = { name: home.name, app: home.app ?? null, kind: home.kind };
908
+ }
909
+ const grok = detectMcpClients().find((client) => client.client === "grok-bot" && client.path);
910
+ if (grok) found["grok-bot"] = { name: "Grok Bot", app: grok.path, kind: "app" };
911
+ return found;
912
+ };
913
+ const t3LinksOk = () => { const t3 = t3App(); return Boolean(t3 && t3OpensLinks(t3.app)); };
914
+ const T3_OLD = "This T3 Code can't be opened at a thread from another app, so it is in front as it is. T3's nightly builds open the exact thread.";
915
+ /** The person's own sessions, not the runs Harness starts in its own folders (their worktrees are not the person's). */
916
+ const ownSession = (row) => !String(row.cwd || "").startsWith(paths.home + path.sep);
917
+ const short = (name) => String(name || "").replace(/ \(.*\)$/, "");
918
+
919
+ /** How a session opens in one place (or the agent itself, with no session); null when that place cannot show it. */
920
+ const placePlan = (agentId, place, sessionId, folder, act) => {
921
+ if (!place) return null;
922
+ const name = short(place.name);
923
+ if (place.kind === "t3") {
924
+ if (!place.app) return null;
925
+ const thread = sessionId ? t3Thread(sessionId) : null;
926
+ if (thread && t3OpensLinks(place.app)) return { opensIn: "T3 Code", link: t3Link(thread) };
927
+ if (thread || !sessionId) return { opensIn: "T3 Code", app: place.app, ...(thread ? { note: T3_OLD } : {}) };
928
+ // A session T3 never had (a run Harness started in its own folder, say): T3 comes forward as it is, saying so,
929
+ // and the session can still be resumed in a terminal instead.
930
+ return { opensIn: "T3 Code", app: place.app, resumable: true, note: harnessRun(folder)
931
+ ? "This run happened in Harness's own copy of the repo, which T3 Code doesn't list, so T3 Code is in front as it is."
932
+ : "T3 Code doesn't list this session, so it is in front as it is." };
933
+ }
934
+ if (place.kind === "editor") {
935
+ // Its window first (the folder the editor has open around the session's), so a panel link lands there.
936
+ const window = editorFolder(place.editor, folder);
937
+ if (act && window) openEditorFolder(place, window);
938
+ const link = sessionId ? panelLink(agentId, place.editor, sessionId) : null;
939
+ if (link) return { opensIn: name, link };
940
+ if (!place.app) return null;
941
+ // An editor's terminal takes no command from another app: the session's own command is copied to paste there.
942
+ const line = sessionId ? resumeLine(agentId, sessionId, folder) : null;
943
+ return { opensIn: name, app: place.app, ...(line ? { copy: line, note: `${name} is up${window ? ` on ${path.basename(window)}` : ""}. The command that resumes this session is copied: paste it in a terminal there.` } : {}) };
944
+ }
945
+ if (place.kind === "codex-app") return agentId === "codex" && sessionId ? { opensIn: "Codex", link: `codex://threads/${sessionId}` } : place.app ? { opensIn: "Codex", app: place.app } : null;
946
+ if (place.kind === "terminal") return CODING_AGENTS.includes(agentId) ? { opensIn: name, resume: { agent: agentId, session: sessionId ?? null, cwd: folder ?? null, app: place.app ?? null } } : null;
947
+ return place.app ? { opensIn: name, app: place.app } : null;
948
+ };
949
+ /** A running session, where it runs: its T3 thread, its panel, its editor's window, its terminal pane and tab. */
950
+ const runningPlan = (live, act) => {
951
+ const place = placesOf(live.agent)[live.sessionId] ?? null;
952
+ if ((live.host && /^T3 Code\b/.test(live.host.name)) || place?.kind === "t3") {
953
+ if (live.t3) return { opensIn: "T3 Code", link: live.t3 };
954
+ return { opensIn: "T3 Code", app: live.host?.app ?? t3App()?.app ?? null, tty: null, ...(t3Thread(live.sessionId) ? { note: T3_OLD } : {}) };
955
+ }
956
+ if (place?.kind === "editor") {
957
+ const link = place.surface === "panel" ? panelLink(live.agent, place.editor, live.sessionId) : null;
958
+ if (link) return { opensIn: short(place.name), link };
959
+ // A session in the editor's terminal: its window. Which terminal tab shows, only the editor itself can pick.
960
+ if (act) openEditorFolder(usable(place), editorFolder(place.editor, live.folder));
961
+ return { opensIn: short(place.name), app: place.app ?? live.host?.app ?? null };
962
+ }
963
+ if (place?.kind === "codex-app" && live.agent === "codex") return { opensIn: "Codex", link: `codex://threads/${live.sessionId}` };
964
+ const opensIn = live.host ? short(live.host.name) : null;
965
+ return { opensIn, ...(act ? focusTerminal(live, place) : live.host ? { app: live.host.app, tty: live.tty } : {}) };
966
+ };
967
+ /**
968
+ * How a session opens, or the agent itself with no session: what the app does next (views/ui.ts openPlace). A running
969
+ * session opens where it runs; one that ended opens in the agent's home, else where it ran, else a terminal resumes
970
+ * it. `act`: the daemon's part happens now (a pane selected, an editor's window brought up); without it, this is only
971
+ * where it would open, for Open's label.
972
+ */
973
+ const openPlan = (agentId, sessionId, folder, act) => {
974
+ const live = sessionId ? liveNow().find((row) => row.agent === agentId && row.sessionId === sessionId) ?? null : null;
975
+ if (live) return runningPlan(live, act);
976
+ const own = sessionId ? placesOf(agentId)[sessionId] ?? null : null;
977
+ const where = folder ?? own?.folder ?? null;
978
+ if (own?.reopen?.link) return { opensIn: short(own.name) || nameOf(agentId), link: own.reopen.link };
979
+ const plan = placePlan(agentId, homeOf(agentId), sessionId, where, act) ?? placePlan(agentId, usable(own), sessionId, where, act);
980
+ if (plan) return plan;
981
+ if (CODING_AGENTS.includes(agentId)) return { opensIn: "Terminal", resume: { agent: agentId, session: sessionId, cwd: where, app: null } };
982
+ const command = own?.reopen?.command;
983
+ return command ? { opensIn: null, copy: command, note: "The command that opens this session again is copied: paste it in a terminal." } : { opensIn: null };
984
+ };
985
+
986
+ const liveCommand = (request, agent) => {
987
+ const sessionId = String(request.sessionId || "");
988
+ if (request.command === "board.live.take") {
989
+ if (!LIVE_AGENTS.includes(agent) || !sessionId) return { ok: false, error: "only a session's own agent takes its messages" };
990
+ const messages = said(agent, sessionId);
991
+ if (messages.length) store.meta(saidKey(agent, sessionId), "[]");
992
+ return { ok: true, messages };
993
+ }
994
+ // A session says where it runs (its hook, plugin or extension, or a custom harness, as it starts).
995
+ if (request.command === "board.live.place") {
996
+ if (agent === "user") return { ok: false, error: "a session reports its own place" };
997
+ if (!SESSION_ID.test(sessionId)) return { ok: false, error: "a place needs the session's id" };
998
+ const folder = folderOf(request);
999
+ const place = placeOfReport(agent, folder, request);
1000
+ const reopen = reopenOf(request.reopen);
1001
+ if (place || (reopen && !harnessRun(folder))) notePlace(agent, sessionId, { ...(place ?? { kind: "unknown", name: nameOf(agent), folder }), ...(reopen ? { reopen } : {}) });
1002
+ return { ok: true };
1003
+ }
1004
+ if (agent !== "user") return { ok: false, error: "only the person reads or messages their sessions" };
1005
+ if (request.command === "board.live") {
1006
+ // Each coding agent's newest session too, so one not running now still opens where it last worked.
1007
+ const last = {};
1008
+ for (const row of store.recentSessions(200)) {
1009
+ if (!LIVE_AGENTS.includes(row.agent) || last[row.agent] || !ownSession(row)) continue;
1010
+ last[row.agent] = { sessionId: nativeSessionId(row.agent, row.session_id), folder: row.cwd || null };
1011
+ }
1012
+ return { ok: true, sessions: liveNow(), last, homes: homes() };
1013
+ }
1014
+ // A session's latest messages and steps, from its own file (running or not).
1015
+ if (request.command === "board.live.read") {
1016
+ const agentId = String(request.to || "");
1017
+ if (!LIVE_AGENTS.includes(agentId) && agentId !== "pi") return { ok: false, error: "only a coding agent's session can be read here" };
1018
+ const file = sessionFile(agentId, sessionId, folderOf(request), store.recentSessions(200));
1019
+ if (!file) return { ok: true, items: [] };
1020
+ return { ok: true, items: sessionFeed(agentId, file, Math.min(200, Number(request.limit) || 80)) };
1021
+ }
1022
+ // Where a session (or, with none, the agent) opens, for Open's label: nothing happens yet.
1023
+ if (request.command === "board.live.where" || request.command === "board.live.focus") {
1024
+ const agentId = String(request.to || "");
1025
+ if (!validAgent(agentId) || (sessionId && !SESSION_ID.test(sessionId))) return { ok: false, error: "that session can't be opened" };
1026
+ // Open itself: the daemon's part happens now, and the app does the rest.
1027
+ return { ok: true, ...openPlan(agentId, sessionId || null, folderOf(request), request.command === "board.live.focus") };
1028
+ }
1029
+ if (request.command === "board.live.say") {
1030
+ const text = String(request.text || "").trim().slice(0, 20_000);
1031
+ if (!text) return { ok: false, error: "a message needs text" };
1032
+ const session = liveNow().find((row) => row.agent === request.to && row.sessionId === sessionId);
1033
+ if (!session) return { ok: false, error: "that session is not running any more" };
1034
+ const messages = [...said(session.agent, sessionId), { text, at: new Date().toISOString() }];
1035
+ store.meta(saidKey(session.agent, sessionId), JSON.stringify(messages));
1036
+ return { ok: true, session: { ...session, waiting: messages.length } };
1037
+ }
1038
+ return { ok: false, error: `unknown command ${request.command}` };
1039
+ };
1040
+
1041
+ // Lid-Closed Mode's Standby: when anything last came in (a phone message, a wake, a new task).
1042
+ let listenerAt = Date.now();
1043
+ const touchListener = () => { listenerAt = Date.now(); };
698
1044
  const boardCommand = (request) => {
699
1045
  if (!validAgent(request.agent)) return { ok: false, error: "board requests need an agent name" };
1046
+ if (request.command === "board.post") touchListener();
700
1047
  const agent = String(request.agent);
1048
+ const lastLooked = store.lastSeenAt(agent);
701
1049
  store.touchAgent(agent);
702
1050
  const id = String(request.id || "");
703
1051
  const expected = Number.isInteger(request.expectedVersion) ? request.expectedVersion : null;
@@ -706,8 +1054,11 @@ export async function startDaemon() {
706
1054
  if (String(request.command).startsWith("board.swarm.")) return swarmCommand(request, agent);
707
1055
  if (request.command === "board.prompted") {
708
1056
  for (const pid of Array.isArray(request.pids) ? request.pids.slice(0, 2) : []) if (Number.isInteger(pid) && pid > 1) promptedBy.set(pid, agent);
1057
+ // A session that started before its hook said where it runs says so on its next prompt.
1058
+ if (request.sessionId && !placesOf(agent)[String(request.sessionId)]) liveCommand({ ...request, command: "board.live.place" }, agent);
709
1059
  return { ok: true };
710
1060
  }
1061
+ if (String(request.command).startsWith("board.live")) return liveCommand(request, agent);
711
1062
  if (request.command === "board.post" && request.thread && typeof request.thread === "object" && !request.thread[OPENED]
712
1063
  && (request.thread.sealed || (request.thread.role === "asker" && store.meta(`relay_pending:${String(request.id || "")}`)))) {
713
1064
  return { pending: openThreadPost(request).then((prepared) => {
@@ -719,6 +1070,18 @@ export async function startDaemon() {
719
1070
  if (request.command === "board.post") {
720
1071
  const objective = String(request.objective || "").trim();
721
1072
  if (!objective) return { ok: false, error: "a task needs an objective" };
1073
+ // A target is named the way people write it ("Grok", "Claude Code") and resolved through the roster; a name
1074
+ // several members start ("claude") or none matches is refused with who it could be, never guessed.
1075
+ if (request.targetAgent != null && !String(request.targetAgent).includes("/")) {
1076
+ const named = resolveMember(request.targetAgent, { extra: store.agentsSeen().map((row) => row.agent).filter((seen) => !member(seen)) });
1077
+ if (!named.ok) {
1078
+ return { ok: false, code: "unknown_agent", candidates: named.candidates, error: named.candidates.length
1079
+ ? `"${request.targetAgent}" could be ${named.candidates.map(aboutOf).join("; or ")}. Name one.`
1080
+ : `No agent is called "${request.targetAgent}". The agents: ${available().filter((name) => name !== agent).join(", ")}.` };
1081
+ }
1082
+ if (member(named.id) && !member(named.id).target) return { ok: false, code: "not_a_target", error: `${nameOf(named.id)} can ask the swarm but cannot be handed work yet.` };
1083
+ request = { ...request, targetAgent: named.id };
1084
+ }
722
1085
  if (request.targetAgent != null && !validAgent(request.targetAgent)) return { ok: false, error: "invalid target agent" };
723
1086
  // Provenance is code's: the local parent's label and what Harness sent from above the poster can only tighten it.
724
1087
  const parentTask = request.parentId ? store.getTask(String(request.parentId)) : null;
@@ -784,12 +1147,22 @@ export async function startDaemon() {
784
1147
  // Asking for a run in a folder is the person's Start; a scratch folder they never asked for goes by standing permission.
785
1148
  runWorkspace: thread ? null : runWorkspace, runAuthorizedBy: !thread && runWorkspace && !scratch && agent === "user" ? "user" : null });
786
1149
  if (thread) store.openThread(task.id, { withAgent: String(thread.with), role: thread.role, peerKey: thread[OPENED] && thread.peerKey ? String(thread.peerKey) : null });
787
- // A job in a goal's room runs on the goal's authority; anywhere else, standing permission decides.
788
- const standing = thread ? undefined : goalParent ? (task.run_workspace && store.authorizeGoalRun(task.id) ? { started: true, by: "goal" } : undefined) : applyStanding(task);
1150
+ // A job in a goal's room runs on the goal's authority; anywhere else, standing permission decides. Until runs
1151
+ // started by members are restricted, the goal covers jobs from the person, Harness and the coding agents on this
1152
+ // machine; a job from a member that runs elsewhere (Grok Bot, Muse, a connected app) waits for the person's Start.
1153
+ const onGoal = agent === "user" || agent === "harness" || (!request.hostedCaller && member(agent)?.runs === "machine");
1154
+ const agentWritten = task.trust !== "user";
1155
+ const mayStart = goalParent && task.run_workspace && threadMayRun(goalParent) && onGoal && (!agentWritten || restrictable(task.target_agent, task.run_workspace));
1156
+ const standing = thread ? undefined : goalParent ? (!task.run_workspace ? undefined : mayStart && store.authorizeGoalRun(task.id) ? { started: true, by: "goal", ...(agentWritten ? { restricted: true } : {}) }
1157
+ : !threadMayRun(goalParent) ? undefined
1158
+ : { started: false, reason: !onGoal ? `${nameOf(agent)} does not run on this machine, so its job waits for the person's Start.` : restrictedWait(String(task.target_agent)) }) : applyStanding(task);
1159
+ if (!thread && !goalParent && !request.parentId && task.target_agent && task.target_agent !== "harness" && ROOM_AGENTS.includes(task.target_agent)) openTaskRoom(store.getTask(task.id), agent, provenance, { hosted: Boolean(request.hostedCaller) });
789
1160
  outcome = { ok: true, task: store.getTask(task.id), thread: thread ? store.getThread(task.id) : null, ...(standing ? { standing } : {}) };
790
1161
  } else if (request.command === "board.list") {
791
1162
  const tasks = store.listTasks({ agent, status: request.status ? String(request.status) : undefined, all: request.all === true, limit: request.limit });
792
- return request.unread ? { ok: true, tasks, unread: store.unreadFor(agent), goals: store.activeGoalsFor(agent).map((goal) => ({ id: goal.id, objective: goal.objective, posts: goal.goal.posts })) } : { ok: true, tasks };
1163
+ const finished = agent === "user" || agent === "harness" ? [] : store.finishedFor(agent, lastLooked);
1164
+ const extra = finished.length ? { finished } : {};
1165
+ return request.unread ? { ok: true, tasks, ...extra, unread: store.unreadFor(agent), goals: store.activeGoalsFor(agent).map((goal) => ({ id: goal.id, objective: goal.objective, posts: goal.goal.posts })) } : { ok: true, tasks, ...extra };
793
1166
  } else if (request.command === "board.context.add") {
794
1167
  if (!store.getTask(id)) return { ok: false, error: "task not found" };
795
1168
  const kind = String(request.kind || "note");
@@ -829,7 +1202,8 @@ export async function startDaemon() {
829
1202
  }
830
1203
  else if (request.command === "board.get") {
831
1204
  const task = store.getTask(id);
832
- if (task) return { ok: true, task };
1205
+ // What a run did, step by step, when the person asks to see it.
1206
+ if (task) return { ok: true, task, ...(request.steps === true ? { steps: readRunSteps(taskFiles(id).log, 200) } : {}) };
833
1207
  return state.connected ? forwardToHarness(request) : { ok: false, error: "task not found" };
834
1208
  } else if (request.command === "board.children") return { ok: true, tasks: store.children(id).map((child) => store.getTask(child.id)).filter(Boolean) };
835
1209
  // A trace reads both homes, and only Harness can: the machine asks it.
@@ -858,10 +1232,32 @@ export async function startDaemon() {
858
1232
  }
859
1233
  else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.values()].map(({ agent: runAgent, id: taskId }) => ({ agent: runAgent, id: taskId })) };
860
1234
  else if (["board.claim", "board.report", "board.answer", "board.release"].includes(request.command) && !store.getTask(id) && state.connected) return forwardToHarness(request);
861
- else if (request.command === "board.claim") outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
1235
+ else if (request.command === "board.claim") {
1236
+ outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
1237
+ // The person's own session that took it, so the task opens there (its MCP server is a child of that session).
1238
+ const claimed = outcome?.ok ? store.getTask(id) : null;
1239
+ const session = claimed && !claimed.session_id && LIVE_AGENTS.includes(agent) ? sessionOfProcess(agent, request.claimerPid, store.recentSessions(200)) : null;
1240
+ if (session) { store.setRunSession(id, session); outcome = { ...outcome, task: store.getTask(id) }; }
1241
+ }
862
1242
  else if (request.command === "board.report") outcome = store.reportTask(id, agent, { status: String(request.status || ""), result: String(request.result || "").slice(0, 200_000) }, expected);
863
1243
  else if (request.command === "board.answer") outcome = store.answerTask(id, agent, String(request.answer || "").slice(0, 20_000), expected);
864
1244
  else if (request.command === "board.release") outcome = store.releaseTask(id, agent, expected);
1245
+ else if (request.command === "board.remove") {
1246
+ if (agent !== "user") return { ok: false, error: "only the person removes work from the board" };
1247
+ const task = store.getTask(id);
1248
+ if (!task || task.removed_at) return { ok: false, error: "task not found" };
1249
+ if (task.kind === "goal") {
1250
+ // A thread's changes are the person's: merged or thrown away on purpose, never lost with the thread.
1251
+ const changes = worktrees.summary(id);
1252
+ if (changes?.state === "open" && changes.files) return { ok: false, error: "this thread has changes that are not merged; merge or discard them first" };
1253
+ if (task.status === "open") stopThreadRuns(id);
1254
+ }
1255
+ const removed = store.removeTask(id);
1256
+ for (const runId of removed.running) stopAgentTask(runId);
1257
+ // Removed on purpose: nothing posts "could not finish" into its thread for it.
1258
+ for (const removedId of removed.ids) { const gone = store.getTask(removedId); lastSeen.set(removedId, { ...lastSeen.get(removedId), status: gone.status }); notifyBoard(gone); }
1259
+ return { ok: true, removed: removed.ids };
1260
+ }
865
1261
  else if (request.command === "board.run") outcome = agent === "user" ? store.authorizeRun(id, agent) : { ok: false, error: "only the user can authorize a run" };
866
1262
  else return { ok: false, error: "unknown command" };
867
1263
  if (outcome.ok) notifyBoard(outcome.task);
@@ -874,6 +1270,8 @@ export async function startDaemon() {
874
1270
  // agent at a time; the rest wait open. Nothing here polls: a board write calls runBoard.
875
1271
  const activeRuns = new Map();
876
1272
  const lastStepNotice = new Map();
1273
+ // Runs the daemon is stopping for a safety rule, by task id, with the reason: they go back on the board.
1274
+ const interrupting = new Map();
877
1275
  for (const id of store.settleInterruptedRuns()) log(`board task ${id} was running when the daemon stopped; marked unknown`);
878
1276
  // Standing permission is on by default; the person hears so once, on the first start that has it.
879
1277
  const defaults = standingRules().filter((rule) => rule.mode === "default");
@@ -882,7 +1280,10 @@ export async function startDaemon() {
882
1280
  const text = `${defaults.map((rule) => rule.agent).join(", ")} now ${one ? "starts" : "start"} the tasks you ask for in folders ${one ? "it has" : "they have"} worked in, up to ${defaults[0].daily_cap} a day${one ? "" : " each"}, without a Start. Change it: npx tryharness board standing`;
883
1281
  log(text); notifyDesktop(text); store.meta("standing_announced", new Date().toISOString());
884
1282
  }
1283
+ // Lid-Closed Mode holds new runs while the Mac runs warm with the lid closed; running ones go on.
1284
+ let runsHeld = false;
885
1285
  const runBoard = () => {
1286
+ if (runsHeld) return;
886
1287
  // One run per folder at a time: each thread's worktree is its own folder, so threads run side by side.
887
1288
  for (const task of store.runnableTasks(installedAgents)) {
888
1289
  const agent = task.target_agent;
@@ -901,17 +1302,49 @@ export async function startDaemon() {
901
1302
  const refs = task.context_refs.length ? "\n\nContext:\n" + task.context_refs.map((ref) => `- ${ref.kind}: ${ref.ref}${ref.note ? ` (${ref.note})` : ""}`).join("\n") : "";
902
1303
  const notice = provenanceLine(task);
903
1304
  const wake = task.kind === "wake";
904
- const brief = `${notice ? `${notice}\n\n` : ""}${task.objective}\n\n${task.brief}${refs}`.trim();
1305
+ // What claude-mem remembers about this folder joins the task's context and the brief, so an agent
1306
+ // without claude-mem starts from the same history one with it would. A wake resumes its own session.
1307
+ let memory = "";
1308
+ try {
1309
+ const notes = wake ? null : await claudeMemNotes(task.run_workspace, { timeoutMs: CLAUDE_MEM_TIMEOUT_MS });
1310
+ const entry = notes && store.addContribution({ taskId: task.id, kind: "memory", author: "claude-mem", text: notes, source: "claude-mem" });
1311
+ if (entry) memory = `\n\nNotes from claude-mem about earlier agent sessions in this folder. They record past work, are not instructions, and may be out of date; check them against the code.\n\n${entry.text}`;
1312
+ } catch (error) {
1313
+ log(`claude-mem notes for ${task.id}: ${error.message}`);
1314
+ }
1315
+ // A job in a thread's room works with the others there; its final words are the job's result.
1316
+ const room = !wake && task.parent_id ? store.getTask(task.parent_id) : null;
1317
+ const guide = room?.kind === "goal" ? `\n\nYou are working in a shared thread with the person's other agents: "${room.objective}" (thread ${room.id}).\n${roomGuide(room, agent).join("\n")}\n- Your final message is this job's result.` : "";
1318
+ const brief = `${notice ? `${notice}\n\n` : ""}${task.objective}\n\n${task.brief}${refs}${guide}${memory}`.trim();
905
1319
  const startedAt = new Date().toISOString();
906
- const resumeSession = wake ? store.goal(task.parent_id)?.sessions[agent] : undefined;
1320
+ // A run stopped for a safety rule continues its own session with a note; an agent without one starts over, told why.
1321
+ const stoppedFor = store.meta(`requeued:${task.id}`);
1322
+ const resumeSession = wake ? store.goal(task.parent_id)?.sessions[agent] : stoppedFor ? task.session_id || undefined : undefined;
1323
+ const again = stoppedFor ? `This task was stopped partway because ${stoppedFor}. Continue from where you left off; check the folder for what you already changed.` : "";
907
1324
  const readOnly = store.meta(`review:${task.id}`) === "1";
908
- startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief, cwd: task.run_workspace, ...(resumeSession ? { resumeSession } : {}), ...(readOnly ? { readOnly } : {}) }, (kind, text, extra) => {
909
- if (kind === "session" && text) { store.setRunSession(task.id, text); if (wake) store.setGoalSession(task.parent_id, agent, text); notifyBoard(store.getTask(task.id)); }
1325
+ // Another agent's words on a goal's authority, not the person's tap: restricted (docs/member-runs.md).
1326
+ const restricted = task.trust !== "user" && task.run_authorized_by === "goal" && store.meta(`person-wake:${task.id}`) !== "1";
1327
+ startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief: resumeSession && again && !wake ? again : [again, brief].filter(Boolean).join("\n\n"), cwd: task.run_workspace, ...(stoppedFor ? { again: true } : {}), ...(resumeSession ? { resumeSession } : {}), ...(readOnly ? { readOnly } : {}), ...(restricted ? { restricted } : {}) }, (kind, text, extra) => {
1328
+ // The agent's session in a thread is resumed when the thread calls on it again.
1329
+ if (kind === "session" && text) { store.setRunSession(task.id, text); if (wake || room?.kind === "goal") store.setGoalSession(task.parent_id, agent, text); notifyBoard(store.getTask(task.id)); }
910
1330
  // A step the agent took: whoever has the thread open reads it again, at most once a second per run.
911
1331
  if (kind === "progress" && Date.now() - (lastStepNotice.get(task.id) ?? 0) >= 1_000) { lastStepNotice.set(task.id, Date.now()); notifyBoard(store.getTask(task.id)); }
1332
+ if (kind === "accepted" && stoppedFor) store.meta(`requeued:${task.id}`, "");
912
1333
  if (kind === "done" || kind === "failed") {
913
1334
  lastStepNotice.delete(task.id);
914
1335
  activeRuns.delete(task.run_workspace ? path.resolve(task.run_workspace) : agent);
1336
+ const reason = interrupting.get(task.id);
1337
+ interrupting.delete(task.id);
1338
+ if (kind === "failed" && reason && store.requeueRun(task.id).ok) {
1339
+ store.meta(`requeued:${task.id}`, reason);
1340
+ const record = taskRuns.get(task.id);
1341
+ if (record) { Object.assign(record, { status: "requeued", message: `stopped because ${reason}` }); persistTask(record); }
1342
+ store.addContribution({ taskId: task.id, kind: "note", author: "harness", text: `Stopped because ${reason}. It starts again where it left off once the Mac can run it.`, source: "lid-closed" });
1343
+ log(`board task ${task.id} stopped because ${reason}; back on the board`);
1344
+ notifyBoard(store.getTask(task.id));
1345
+ setImmediate(runBoard);
1346
+ return;
1347
+ }
915
1348
  // A stopped run's task is already closed, so nothing else frees the agent's next run.
916
1349
  setImmediate(runBoard);
917
1350
  if (extra?.usage) store.setRunUsage(task.id, extra.usage);
@@ -923,7 +1356,7 @@ export async function startDaemon() {
923
1356
  });
924
1357
  };
925
1358
 
926
- const phones = await createPhoneDesk({ store, identityFile: paths.phoneIdentity, boardCommand, version: DAEMON_VERSION, reads: {
1359
+ const phones = await createPhoneDesk({ store, identityFile: paths.phoneIdentity, boardCommand, version: DAEMON_VERSION, onMessage: touchListener, machine: () => machineState, reads: {
927
1360
  "sessions.search": (input) => store.search(input.input || {}),
928
1361
  "sessions.read": (input) => store.read(input.input || {}),
929
1362
  "sessions.raw": (input) => sourceRaw(String(input.agent || ""), String(input.sessionId || "")),
@@ -1046,11 +1479,31 @@ export async function startDaemon() {
1046
1479
  return { ok: true, post: pick(post, ["id", "seq", "author", "kind", "created_at"]) };
1047
1480
  };
1048
1481
  const roomOf = (task) => { const parent = task?.parent_id ? store.getTask(task.parent_id) : null; return parent?.kind === "goal" ? parent : null; };
1049
- const visibleTo = (task, agent) => Boolean(task) && (task.target_agent == null || task.target_agent === agent || task.origin_agent === agent || task.claimed_by === agent
1482
+ // What a caller through Harness may see: what it posted, holds or is named in, and the threads it is on. Work posted
1483
+ // for anyone stays with the agents on this machine.
1484
+ const visibleTo = (task, agent) => Boolean(task) && (task.target_agent === agent || task.origin_agent === agent || task.claimed_by === agent
1050
1485
  || (task.kind === "goal" && inRoom(task, agent)) || Boolean(roomOf(task) && inRoom(roomOf(task), agent)));
1486
+ // What leaves this computer for an agent through Harness: any text in the answer that looks like a secret (the room's
1487
+ // check, cli/lib/secrets.mjs) is replaced with a notice; the person still reads it in full here (docs/member-runs.md).
1488
+ const withoutSecrets = (value) => {
1489
+ if (typeof value === "string") { const flag = secretReason(value); return flag ? `[held on the person's computer: this looked like ${flag}]` : value; }
1490
+ if (Array.isArray(value)) return value.map(withoutSecrets);
1491
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withoutSecrets(item)]));
1492
+ return value;
1493
+ };
1051
1494
  const plainBoard = (input) => {
1052
- if (!store.sealedOnly()) return boardCommand(input);
1053
1495
  const agent = String(input.agent || "");
1496
+ if (agent !== "user" && agent !== "harness") {
1497
+ const answer = answerPlain(input);
1498
+ return answer?.pending ? { pending: answer.pending.then(withoutSecrets) } : withoutSecrets(answer);
1499
+ }
1500
+ return answerPlain(input);
1501
+ };
1502
+ const answerPlain = (input) => {
1503
+ const agent = String(input.agent || "");
1504
+ // The person's and Harness's calls are answered in full only while no browser is paired; a hosted agent is always
1505
+ // limited to what it is part of, paired or not.
1506
+ if (!store.sealedOnly() && (agent === "user" || agent === "harness")) return boardCommand(input);
1054
1507
  const command = String(input.command || "");
1055
1508
  // A thread Harness posts on accept: on the asking side, only one this machine asked for (it supplies the words itself).
1056
1509
  if (command === "board.post" && input.thread && typeof input.thread === "object" && agent === "user") {
@@ -1064,7 +1517,10 @@ export async function startDaemon() {
1064
1517
  if (command === "board.swarm.search" && agent !== "user" && agent !== "harness") return boardCommand({ ...input, teamOnly: true });
1065
1518
  if (agent === "harness" && command === "board.room.post") return plainNotice(input);
1066
1519
  if (agent === "user" || agent === "harness") return PLAIN_PERSON_COMMANDS.has(command) ? boardCommand(input) : SEALED_ONLY;
1067
- if (command === "board.list") return boardCommand({ ...input, all: false });
1520
+ if (command === "board.list") {
1521
+ const listed = boardCommand({ ...input, all: false });
1522
+ return listed?.ok && Array.isArray(listed.tasks) ? { ...listed, tasks: listed.tasks.filter((task) => visibleTo(task, agent)) } : listed;
1523
+ }
1068
1524
  if (command === "board.goal.list") {
1069
1525
  const listed = boardCommand(input);
1070
1526
  return listed?.ok ? { ...listed, goals: listed.goals.filter((goal) => inRoom(store.getTask(goal.id) || { goal: { team: [] } }, agent)) } : listed;
@@ -1122,6 +1578,41 @@ export async function startDaemon() {
1122
1578
  } else if (request.command === "phone.offer") reply({ ok: true, ...phones.offer(config.origin, request.kind === "browser" ? "browser" : "phone") });
1123
1579
  else if (request.command === "phone.list") reply({ ok: true, phones: phones.list() });
1124
1580
  else if (request.command === "phone.revoke") reply(phones.revoke(request.id));
1581
+ else if (request.command === "wake.local") reply({ ok: true, agents: localWaker.status() });
1582
+ // How Harness wakes the agents it keeps on the server (Grok Bot): read and saved there, never here.
1583
+ else if (request.command === "wake.server") askHarness("wake", request.input || {}).then(reply, (error) => reply({ ok: false, error: error.message }));
1584
+ // Whether agent work is going on here, for the desktop app deciding when the Mac may sleep.
1585
+ else if (request.command === "activity") {
1586
+ machineActivity({ runs: [...activeRuns.values()].map(({ agent, id }) => ({ agent, id })), queued: store.runnableTasks(installedAgents).length, agents: installedAgents })
1587
+ .then((activity) => reply({ ok: true, ...activity, agentPids: [...activity.agentPids, ...runningPids(), process.pid],
1588
+ listenerIdleMs: Date.now() - listenerAt, phonesPaired: phones.list().filter((device) => device.kind === "phone" && !device.revoked_at).length }), (error) => reply({ ok: false, error: error.message }));
1589
+ } else if (request.command === "machine.state") {
1590
+ // The desktop app says how the Mac is (working, standby, or null when Lid-Closed Mode is off), with the battery.
1591
+ const wasQuiet = quiet();
1592
+ machineState = request.state ? { state: String(request.state).slice(0, 20), battery: Number.isFinite(request.battery) ? Math.round(request.battery) : null, at: new Date().toISOString() } : null;
1593
+ if (wasQuiet && !quiet()) setImmediate(() => void sync());
1594
+ reply({ ok: true });
1595
+ } else if (request.command === "machine.report") {
1596
+ // Since the lid closed: board runs that finished or stopped, and when Harness could not be reached.
1597
+ const since = Number(request.since) || 0;
1598
+ const ended = store.endedSince(new Date(since).toISOString());
1599
+ const offline = [...outages, ...(offlineSince ? [{ start: offlineSince, end: null }] : [])].filter((outage) => (outage.end ?? Infinity) >= since);
1600
+ reply({ ok: true, finished: ended.done || 0, stopped: (ended.failed || 0) + (ended.unknown || 0), outages: offline });
1601
+ } else if (request.command === "runs.interrupt") {
1602
+ // A safety rule ends the lid-closed session: each board run is stopped and goes back on the board.
1603
+ const reason = String(request.reason || "the Mac had to sleep").slice(0, 200);
1604
+ const stopped = [...activeRuns.values()].filter(({ id }) => { interrupting.set(id, reason); return stopAgentTask(id) || (interrupting.delete(id), false); }).map(({ id }) => id);
1605
+ if (stopped.length) log(`stopping ${stopped.length} board run(s) because ${reason}`);
1606
+ reply({ ok: true, stopped });
1607
+ } else if (request.command === "runs.hold") {
1608
+ runsHeld = request.on === true;
1609
+ log(runsHeld ? "new runs held: the Mac is warm with its lid closed" : "new runs start again");
1610
+ if (!runsHeld) setImmediate(runBoard);
1611
+ reply({ ok: true, held: runsHeld });
1612
+ } else if (request.command === "network.changed") {
1613
+ try { reconnectNow("the desktop app saw the network change"); } catch {}
1614
+ reply({ ok: true });
1615
+ }
1125
1616
  else if (request.command === "tasks.get") {
1126
1617
  const task = readTask(request.runId, request.offset);
1127
1618
  reply(task ? { ok: true, task } : { ok: false, error: "task not found" });
@@ -1139,6 +1630,7 @@ export async function startDaemon() {
1139
1630
 
1140
1631
  let retry = 1_000;
1141
1632
  let reconnectTimer = null;
1633
+ let awaitingPong = 0;
1142
1634
  // Runs an agent for a board task, writing the run record and log under the task id. A run id
1143
1635
  // is executed at most once: a known record answers with its outcome instead of a rerun.
1144
1636
  const startTask = (message, report) => {
@@ -1148,7 +1640,8 @@ export async function startDaemon() {
1148
1640
  }
1149
1641
  // A run id is executed at most once, even if the same task arrives again.
1150
1642
  const known = taskStatus(message.runId);
1151
- if (known) {
1643
+ // The daemon stopped this run itself and knows it ended: the next attempt keeps its log and record.
1644
+ if (known && !(message.again && known.status === "requeued")) {
1152
1645
  if (known.status === "done" || known.status === "failed") report(known.status, known.message || undefined);
1153
1646
  else if (known.status === "running") report("accepted");
1154
1647
  else report("failed", "This run was interrupted earlier and is not started again.");
@@ -1156,7 +1649,7 @@ export async function startDaemon() {
1156
1649
  }
1157
1650
  ensureDir(paths.runs);
1158
1651
  const files = taskFiles(message.runId);
1159
- try { fs.writeFileSync(files.log, "", { mode: 0o600 }); } catch {}
1652
+ if (!message.again) try { fs.writeFileSync(files.log, "", { mode: 0o600 }); } catch {}
1160
1653
  const task = {
1161
1654
  runId: message.runId,
1162
1655
  agent: message.agent,
@@ -1191,6 +1684,14 @@ export async function startDaemon() {
1191
1684
  };
1192
1685
  runAgentTask(message, emit, observe);
1193
1686
  };
1687
+ // Drops the connection and dials again in about a second, whatever the backoff had grown to.
1688
+ const reconnectNow = (reason) => {
1689
+ log(`${reason}; reconnecting`);
1690
+ retry = 1_000;
1691
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
1692
+ if (socket && socket.readyState !== WebSocket.CLOSED) socket.terminate();
1693
+ else connect();
1694
+ };
1194
1695
  const scheduleReconnect = () => {
1195
1696
  if (reconnectTimer) return;
1196
1697
  const delay = Math.round(retry * (0.8 + Math.random() * 0.4));
@@ -1206,6 +1707,10 @@ export async function startDaemon() {
1206
1707
  socket = new WebSocket(origin, { headers: { authorization: "Bearer " + config.token } });
1207
1708
  socket.on("open", () => {
1208
1709
  retry = 1_000;
1710
+ awaitingPong = 0;
1711
+ if (offlineSince) outages.push({ start: offlineSince, end: Date.now() });
1712
+ outages.splice(0, outages.length - 50);
1713
+ offlineSince = null;
1209
1714
  state.connected = true;
1210
1715
  state.lastError = null;
1211
1716
  saveStatus();
@@ -1217,7 +1722,10 @@ export async function startDaemon() {
1217
1722
  socket.on("message", (data) => {
1218
1723
  let message;
1219
1724
  try { message = JSON.parse(String(data)); } catch { return; }
1220
- if (message.type === "pong") return;
1725
+ if (message.type === "pong") {
1726
+ if (awaitingPong) { state.pingMs = Date.now() - awaitingPong; awaitingPong = 0; }
1727
+ return;
1728
+ }
1221
1729
  if (message.type === "sync") { void sync(); return; }
1222
1730
  // A task in one of this machine's threads, run on Harness (Bankr, Quotient, Muse), changed: its answer lands in the thread.
1223
1731
  if (message.type === "cloud_job" && message.task && typeof message.task === "object") {
@@ -1319,7 +1827,7 @@ export async function startDaemon() {
1319
1827
  // A hosted caller's board call, identified by the name Harness assigns it. The same
1320
1828
  // checks as the local control socket apply; the server persists nothing of it.
1321
1829
  let result;
1322
- try { result = plainBoard({ ...(message.input || {}), command: String(message.input?.command || "") }); } catch (error) { result = { ok: false, error: `the board could not answer: ${error.message}` }; }
1830
+ try { result = plainBoard({ ...(message.input || {}), command: String(message.input?.command || ""), hostedCaller: true }); } catch (error) { result = { ok: false, error: `the board could not answer: ${error.message}` }; }
1323
1831
  if (result?.pending) result.pending.then((value) => send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { ok: true, ...value } }), (error) => send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { ok: false, error: error.message } }));
1324
1832
  else send({ type: "bridge_response", requestId: message.requestId, ok: true, result });
1325
1833
  return;
@@ -1328,6 +1836,7 @@ export async function startDaemon() {
1328
1836
  });
1329
1837
  const dropped = (error) => {
1330
1838
  state.connected = false;
1839
+ offlineSince ||= Date.now();
1331
1840
  for (const [requestId, waiter] of relayWaiters) { relayWaiters.delete(requestId); waiter.reject?.(new Error("the connection to Harness dropped")); }
1332
1841
  if (error?.message) state.lastError = error.message;
1333
1842
  saveStatus();
@@ -1344,8 +1853,21 @@ export async function startDaemon() {
1344
1853
  };
1345
1854
  connect();
1346
1855
  setImmediate(runBoard);
1347
- const heartbeat = setInterval(() => send({ type: "ping" }), 30_000);
1348
- const rescan = setInterval(() => void sync(), RESCAN_MS);
1856
+ // A ping still unanswered when the next is due means the connection is dead: after a Wi-Fi drop a socket can
1857
+ // stay open for minutes with nothing behind it. Measured 2026-10-10 against production over in-flight satellite
1858
+ // Wi-Fi, 420 round trips: p50 766 ms, p99 1.09 s, one stall of 14.0 s, so one interval is about twice the worst.
1859
+ // A change in this machine's addresses (Wi-Fi to hotspot, a network back) reconnects at once.
1860
+ // Standby keeps this interval: a longer one waits on measuring how long Harness's proxy holds an idle socket.
1861
+ let addresses = networkAddresses();
1862
+ const heartbeat = setInterval(() => {
1863
+ const current = networkAddresses();
1864
+ if (current !== addresses) { addresses = current; reconnectNow("the network changed"); return; }
1865
+ if (socket?.readyState !== WebSocket.OPEN) return;
1866
+ if (awaitingPong) { reconnectNow(`Harness did not answer a ping within ${HEARTBEAT_MS / 1000} s`); return; }
1867
+ awaitingPong = Date.now();
1868
+ send({ type: "ping" });
1869
+ }, HEARTBEAT_MS);
1870
+ const rescan = setInterval(() => { if (!quiet()) void sync(); }, RESCAN_MS);
1349
1871
  const watchers = [];
1350
1872
  for (const agent of ["claude-code", "codex", "pi"]) {
1351
1873
  if (!installedAgents.includes(agent)) continue;
@@ -1353,7 +1875,7 @@ export async function startDaemon() {
1353
1875
  try {
1354
1876
  const watcher = fs.watch(root, { recursive: process.platform === "darwin" || process.platform === "win32" }, () => {
1355
1877
  clearTimeout(watcher._harnessTimer);
1356
- watcher._harnessTimer = setTimeout(() => void sync(agent), 1_000);
1878
+ if (!quiet()) watcher._harnessTimer = setTimeout(() => void sync(agent), 1_000);
1357
1879
  });
1358
1880
  watchers.push(watcher);
1359
1881
  } catch {}