tryharness 0.7.16 → 0.7.22
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/README.md +9 -6
- package/assets/opencode-plugin.js +46 -66
- package/assets/pi-extension.ts +90 -90
- package/cli.mjs +31 -1
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/cards.mjs +11 -3
- package/lib/coordinator.mjs +19 -9
- package/lib/daemon.mjs +234 -62
- package/lib/folders.mjs +138 -0
- package/lib/live.mjs +30 -5
- package/lib/mcp-stdio.mjs +10 -2
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +6 -5
- package/lib/phones.mjs +5 -1
- package/lib/places.mjs +49 -1
- package/lib/runner.mjs +35 -6
- package/lib/store.mjs +32 -3
- package/lib/swarm.mjs +84 -20
- package/lib/t3.mjs +7 -4
- package/lib/worktrees.mjs +36 -4
- package/package.json +3 -3
package/lib/daemon.mjs
CHANGED
|
@@ -13,9 +13,10 @@ import { parseSessionFile } from "./parsers.mjs";
|
|
|
13
13
|
import { historyRoots, paths } from "./paths.mjs";
|
|
14
14
|
import { openStore } from "./store.mjs";
|
|
15
15
|
import { readRunSteps, runAgentTask, runningPids, stopAgentTask } from "./runner.mjs";
|
|
16
|
-
import { createSwarm, nativeSessionId } from "./swarm.mjs";
|
|
16
|
+
import { PROCESS_NAMES, createSwarm, nativeSessionId } from "./swarm.mjs";
|
|
17
17
|
import { createCoordinator } from "./coordinator.mjs";
|
|
18
18
|
import { createWorktrees } from "./worktrees.mjs";
|
|
19
|
+
import { createFolders } from "./folders.mjs";
|
|
19
20
|
import { notificationFor, notifyDesktop } from "./notify.mjs";
|
|
20
21
|
import { secretReason } from "./secrets.mjs";
|
|
21
22
|
import { provenanceFor, provenanceLine } from "./provenance.mjs";
|
|
@@ -27,11 +28,13 @@ import { ASKED_BY_TASK, CLOUD_HOMED, MACHINE_RUNNERS, TARGETS, aboutOf, member,
|
|
|
27
28
|
import { fingerprint, openBound, openBytes, relayKey, sealBound, sealBytes } from "./sealed.mjs";
|
|
28
29
|
import { claudeMemNotes } from "./claudemem.mjs";
|
|
29
30
|
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, t3Model, t3Open, t3OpensLinks, t3Project, t3Provider, t3Runs, t3Thread } from "./places.mjs";
|
|
31
|
+
import { LIVE_AGENTS, focusTerminal, hostApp, liveSessions, opencodeFeed, processTable, sessionFeed, sessionFile, sessionOfProcess, workingDirectories } from "./live.mjs";
|
|
32
|
+
import { EDITOR_IDS, agentCommand, editorFolder, editorInstall, editorName, editorRunsOpenTasks, editorSessionWorkspace, editorTerminalStarts, openEditorFolder, panelLink, placeEnv, placeFrom, placeKey, processEnv, resumeLine, t3App, t3Link, t3Model, t3Open, t3OpensLinks, t3Project, t3Provider, t3Runs, t3Thread } from "./places.mjs";
|
|
32
33
|
import { detectAgentApps, detectMcpClients } from "./adapters.mjs";
|
|
33
|
-
import { cardLine, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
|
|
34
|
+
import { cardLine, modelOf, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
|
|
35
|
+
import { PICKABLE, createModelRouter, picksIn, refreshCatalogs, resolvePick, setRunPick } from "./models.mjs";
|
|
34
36
|
import { forgetT3, handedToT3, runInT3, sendToT3, startT3SignIn, t3Access } from "./t3.mjs";
|
|
37
|
+
import { createNetwork } from "./network.mjs";
|
|
35
38
|
|
|
36
39
|
// What may be referenced on a thread with another person (the server's rule, src/server/relay/refs.ts, kept
|
|
37
40
|
// here since a sealed request is opened only on the machines).
|
|
@@ -301,6 +304,8 @@ export async function startDaemon() {
|
|
|
301
304
|
try { if (fs.existsSync(paths.socket)) fs.unlinkSync(paths.socket); } catch {}
|
|
302
305
|
}
|
|
303
306
|
let socket = null;
|
|
307
|
+
// What the Harness this machine is connected to says it can do, told when the connection opens.
|
|
308
|
+
let serverFeatures = new Set();
|
|
304
309
|
const relayWaiters = new Map();
|
|
305
310
|
const cloudSeen = new Map();
|
|
306
311
|
const send = (message) => {
|
|
@@ -464,15 +469,15 @@ export async function startDaemon() {
|
|
|
464
469
|
// as themselves, never as a root. Anything another agent wrote never starts. Links are followed, so no path inside a
|
|
465
470
|
// covered folder leads out of it.
|
|
466
471
|
const RUNNERS = new Set(MACHINE_RUNNERS);
|
|
467
|
-
|
|
468
|
-
//
|
|
472
|
+
// A session the person typed in: its prompt hook (Claude Code, Codex) or its extension or plugin (pi, OpenCode) names
|
|
473
|
+
// the agent process, the parent of the board MCP it posts through.
|
|
469
474
|
// A run this daemon started is never one (the daemon is its parent), and an agent holding another agent's task writes
|
|
470
475
|
// as an agent until it reports.
|
|
471
476
|
const promptedBy = new Map();
|
|
472
477
|
// Unknown parent: not the person's.
|
|
473
478
|
const parentOf = (pid) => Number(spawnSync(PS, ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }).stdout?.trim()) || null;
|
|
474
479
|
const personSession = (agent, pid) => {
|
|
475
|
-
if (!
|
|
480
|
+
if (!RUNNERS.has(agent) || !Number.isInteger(pid) || promptedBy.get(pid) !== agent) return false;
|
|
476
481
|
const parent = parentOf(pid);
|
|
477
482
|
return parent !== null && parent !== process.pid && !store.holdsAgentTask(agent);
|
|
478
483
|
};
|
|
@@ -586,12 +591,18 @@ export async function startDaemon() {
|
|
|
586
591
|
};
|
|
587
592
|
// Work a thread starts on its own runs on the thread's authority only when the person gave it: a thread they set, or,
|
|
588
593
|
// for a task's room, their go on that task. Anything else waits for the person's Start like any agent-written run.
|
|
589
|
-
//
|
|
590
|
-
//
|
|
594
|
+
// In such a thread, the person's own agents asking for work carry the person's go, as their @mention does (owner,
|
|
595
|
+
// October 13; docs/member-runs.md): Harness and its agents on its server (Bankr, Quotient), and the coding agents on
|
|
596
|
+
// this computer; full permissions, Stop always there. An agent the person set to wait still waits. Work another agent
|
|
597
|
+
// asked for (Grok Bot, Muse, another person's agent, a call through Harness) runs on a goal's authority only where it
|
|
598
|
+
// can be held in: Claude Code in restricted mode, in a copy of the folder this daemon made. Anything else waits.
|
|
591
599
|
const worktreeRoot = path.join(paths.home, "worktrees");
|
|
592
600
|
const restrictable = (agent, folder) => agent === "claude-code" && Boolean(folder) && within(folder, worktreeRoot);
|
|
593
|
-
const
|
|
594
|
-
|
|
601
|
+
const setToWait = (agent) => standingRules().find((rule) => rule.agent === agent)?.mode === "off";
|
|
602
|
+
const personsGo = (asker, agent, { hosted = false } = {}) => asker === "user"
|
|
603
|
+
|| ((member(asker)?.runs === "server" || (!hosted && member(asker)?.runs === "machine")) && !setToWait(agent));
|
|
604
|
+
const restrictedWait = (agent) => setToWait(agent) ? `${nameOf(agent)} is set to wait for your Start.`
|
|
605
|
+
: agent === "claude-code" ? `${nameOf(agent)} would work in your own folder here, not a copy, so work another agent asked for waits for your Start.`
|
|
595
606
|
: `${nameOf(agent)} has no restricted mode, so work another agent asked of it waits for your Start.`;
|
|
596
607
|
const threadMayRun = (goal) => {
|
|
597
608
|
if (goal.trust !== "user") return false;
|
|
@@ -601,24 +612,54 @@ export async function startDaemon() {
|
|
|
601
612
|
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");
|
|
602
613
|
// A thread's coding agent works in T3 Code when the person uses it there: its home is T3, Harness may send into T3,
|
|
603
614
|
// T3 is open with a project for the thread's folder, and the agent has run in T3 before (its model there is the
|
|
604
|
-
// person's last choice). A thread
|
|
605
|
-
//
|
|
615
|
+
// person's last choice). A thread in Harness's own copy moves into T3 on its next turn, in that same checkout (a git
|
|
616
|
+
// worktree of T3's project, which T3 takes as it is); one carrying on a session in place stays there; one in T3 stays
|
|
617
|
+
// in T3, and a turn T3 can't take then fails saying why. Reviews keep the agent's read-only switch here.
|
|
606
618
|
const inT3 = (thread, agent, review) => {
|
|
607
619
|
if (review || !t3Provider(agent) || !thread.goal.workspace) return false;
|
|
608
|
-
const tree = worktrees.get(thread.id)
|
|
609
|
-
if (tree === "t3") return true;
|
|
610
|
-
|
|
620
|
+
const tree = worktrees.get(thread.id);
|
|
621
|
+
if (tree?.state === "t3") return true;
|
|
622
|
+
// A task's room works in place, which T3 does in its project's own checkout; a thread carrying on a terminal
|
|
623
|
+
// session in place resumes that session here.
|
|
624
|
+
const movable = !tree || (tree.state === "open" && fs.existsSync(tree.path)) || (tree.state === "in_place" && Boolean(store.meta(`task-room:${thread.id}`)));
|
|
625
|
+
return movable && homeOf(agent)?.kind === "t3" && !t3Gap(agent, thread.goal.workspace);
|
|
626
|
+
};
|
|
627
|
+
// Why an agent the person uses in T3 Code would not run there in a folder; null when it would, or when its home is not T3.
|
|
628
|
+
const t3Gap = (agent, folder) => {
|
|
629
|
+
if (!t3Provider(agent) || homeOf(agent)?.kind !== "t3") return null;
|
|
630
|
+
if (t3Access()?.state !== "send") return "Harness isn't approved to send into T3 Code";
|
|
631
|
+
if (!t3Open()) return "T3 Code isn't open";
|
|
632
|
+
if (!t3Project(folder)) return `T3 Code has no project for ${path.basename(folder)}`;
|
|
633
|
+
if (!t3Model(agent)) return `${nameOf(agent)} hasn't run in T3 Code yet`;
|
|
634
|
+
return null;
|
|
635
|
+
};
|
|
636
|
+
// Where an agent's turns in a folder run, for the composer: in T3 Code, or here, with why not T3 when it is the agent's home.
|
|
637
|
+
const runsIn = (agent, folder) => {
|
|
638
|
+
const gap = t3Gap(agent, folder);
|
|
639
|
+
return homeOf(agent)?.kind === "t3" && !gap ? { in: "t3", why: null } : { in: "here", why: gap };
|
|
640
|
+
};
|
|
641
|
+
// A task's own run (its room's first job) goes where its room's turns go: into T3 Code when the person uses the agent
|
|
642
|
+
// there. Not when it would run restricted (work another agent asked for on a goal's authority): only this computer
|
|
643
|
+
// holds a run to that.
|
|
644
|
+
const taskInT3 = (task, agent) => {
|
|
645
|
+
const room = task.parent_id ? store.getTask(task.parent_id) : null;
|
|
646
|
+
if (task.kind !== "task" || room?.kind !== "goal" || store.meta(`task-room:${room.id}`) !== task.id || !task.run_workspace) return false;
|
|
647
|
+
const restricted = task.trust !== "user" && task.run_authorized_by === "goal" && store.meta(`person-wake:${task.id}`) !== "1";
|
|
648
|
+
return !restricted && inT3(room, agent, store.meta(`review:${task.id}`) === "1");
|
|
611
649
|
};
|
|
612
650
|
// A turn T3 carries out (t3.mjs runInT3): into the agent's T3 thread in this thread, else a new one in T3's project
|
|
613
651
|
// for the folder, on the thread's T3 checkout. The thread works in T3 from its first launch.
|
|
614
|
-
|
|
652
|
+
// A model the relay chose goes to T3 as its selection, effort under the option name T3 gives each agent.
|
|
653
|
+
const t3Turn = (task, agent, chosen) => {
|
|
615
654
|
const thread = store.getTask(task.parent_id);
|
|
616
655
|
const key = `t3-thread:${thread.id}:${agent}`;
|
|
617
656
|
const model = t3Model(agent);
|
|
618
657
|
const project = t3Project(thread.goal.workspace);
|
|
658
|
+
const effort = chosen.effort ? [{ id: agent === "codex" ? "reasoningEffort" : "effort", value: chosen.effort }] : [];
|
|
619
659
|
return {
|
|
620
660
|
threadId: store.meta(key) || null,
|
|
621
661
|
title: thread.objective.slice(0, 120),
|
|
662
|
+
model: chosen.model ? { instanceId: t3Provider(agent), model: chosen.model, options: effort } : null,
|
|
622
663
|
launch: { ...(project ? { projectId: project.id } : {}), ...(model ? { modelSelection: model } : {}), workspaceStrategy: worktrees.t3Workspace(thread) },
|
|
623
664
|
onThread: (threadId) => { store.meta(key, threadId); worktrees.inT3(thread.id); },
|
|
624
665
|
onWorktree: (where, branch) => worktrees.inT3(thread.id, where, branch),
|
|
@@ -627,33 +668,46 @@ export async function startDaemon() {
|
|
|
627
668
|
// Wake one teammate. A coding agent here gets a run in the goal's folder that resumes its last session on this goal;
|
|
628
669
|
// Harness and agents that cannot be started here learn of it through the board change and their own reads.
|
|
629
670
|
const pendingWakes = new Map();
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
671
|
+
// go: the person's go for this turn (personsGo), their own post naming this agent or one of their agents here asking;
|
|
672
|
+
// any other wake is work another agent asked for and runs restricted or waits (docs/member-runs.md).
|
|
673
|
+
// asker: who asked for this turn, the person or an agent, named on the card that waits for the person.
|
|
674
|
+
// pick: the model and effort the post named for this turn (models.mjs).
|
|
675
|
+
const wakeAgent = (goalId, agent, reason, { review = false, go = false, asker = "harness", pick = null } = {}) => {
|
|
633
676
|
const task = store.getTask(goalId);
|
|
634
677
|
if (!task || task.status !== "open" || !RUNNERS.has(agent) || !installedAgents.includes(agent)) return;
|
|
635
678
|
const goal = task.goal;
|
|
636
679
|
if (!goal.workspace) {
|
|
637
680
|
if (!store.meta(`goal-folder-note:${goalId}:${agent}`)) {
|
|
638
681
|
store.meta(`goal-folder-note:${goalId}:${agent}`, "1");
|
|
639
|
-
store.roomPost({ goalId, author: "harness", kind: "note", text: `${nameOf(agent)} cannot start: this thread has no folder to work in.
|
|
682
|
+
store.roomPost({ goalId, author: "harness", kind: "note", text: `${nameOf(agent)} cannot start: this thread has no folder to work in. Choose one for the thread in Harness, or: npx tryharness goal folder ${goalId} <dir>` });
|
|
640
683
|
}
|
|
641
684
|
return;
|
|
642
685
|
}
|
|
643
|
-
if (store.openWake(goalId, agent)) { pendingWakes.set(`${goalId}:${agent}`, { reason,
|
|
686
|
+
if (store.openWake(goalId, agent)) { pendingWakes.set(`${goalId}:${agent}`, { reason, asker, go: go || Boolean(pendingWakes.get(`${goalId}:${agent}`)?.go), pick: pick ?? pendingWakes.get(`${goalId}:${agent}`)?.pick ?? null }); return; }
|
|
644
687
|
if (store.wakeCount(goalId, agent) >= goal.max_wakes) { pauseGoal(goalId, `${agent} was woken ${goal.max_wakes} times`); return; }
|
|
645
688
|
const brief = swarmBrief(task, agent, reason, { review });
|
|
646
689
|
// A coding thread in a repository works in its own worktree, so threads never share a checkout; one whose agents the
|
|
647
690
|
// person uses in T3 Code works in T3 (inT3), where T3 makes and keeps the checkout.
|
|
648
691
|
const t3 = inT3(task, agent, review);
|
|
692
|
+
// The person uses this agent in T3 Code but this turn runs here: the thread says why, once for each reason.
|
|
693
|
+
const gap = t3 || review ? null : t3Gap(agent, goal.workspace);
|
|
694
|
+
if (gap && !store.meta(`t3-gap-note:${goalId}:${agent}:${gap}`)) {
|
|
695
|
+
store.meta(`t3-gap-note:${goalId}:${agent}:${gap}`, "1");
|
|
696
|
+
store.roomPost({ goalId, author: "harness", kind: "note", text: `${nameOf(agent)} is working on this computer, not in T3 Code: ${gap}.` });
|
|
697
|
+
}
|
|
649
698
|
let folder = goal.workspace;
|
|
650
699
|
if (!t3) try { folder = worktrees.folderFor(task); } catch (error) { store.roomPost({ goalId, author: "harness", kind: "note", text: `${agent} cannot start: ${error.message}` }); notifyBoard(store.getTask(goalId)); return; }
|
|
651
|
-
|
|
700
|
+
// Whose words the turn carries: whoever wrote the thread when an agent did, then the agent that asked. The person's
|
|
701
|
+
// own ask in their own thread names no one; Harness posts every wake.
|
|
702
|
+
const writers = task.trust === "agent" ? task.origin_chain : [];
|
|
703
|
+
const chain = asker === "user" || writers.at(-1) === asker ? writers : [...writers, asker];
|
|
704
|
+
const wake = store.postTask({ objective: `Room: ${task.objective}`.slice(0, 500), brief, originAgent: "harness", provenance: { trust: "agent", chain: chain.slice(-16) }, targetAgent: agent, parentId: goalId, runWorkspace: folder, kind: "wake" });
|
|
652
705
|
if (t3) store.meta(`t3-run:${wake.id}`, "1");
|
|
706
|
+
if (pick) setRunPick(store, wake.id, pick, asker);
|
|
653
707
|
// A review runs with the agent's own read-only switch (runner.mjs); only Claude Code and Codex have one.
|
|
654
708
|
if (review) store.meta(`review:${wake.id}`, "1");
|
|
655
709
|
if (threadMayRun(task)) {
|
|
656
|
-
if (
|
|
710
|
+
if (go) { store.meta(`person-wake:${wake.id}`, "1"); store.authorizeGoalRun(wake.id); }
|
|
657
711
|
else if (restrictable(agent, folder)) store.authorizeGoalRun(wake.id);
|
|
658
712
|
else if (!store.meta(`restricted-note:${goalId}:${agent}`)) {
|
|
659
713
|
store.meta(`restricted-note:${goalId}:${agent}`, "1");
|
|
@@ -662,9 +716,13 @@ export async function startDaemon() {
|
|
|
662
716
|
}
|
|
663
717
|
notifyBoard(store.getTask(wake.id));
|
|
664
718
|
};
|
|
719
|
+
// Harness asked by name answers from the person's open Harness window with its own tools (src/main/agents/harnessInbox.ts):
|
|
720
|
+
// the words whole and where to read the rest. With no window open the ask waits on the board.
|
|
721
|
+
const harnessAsk = (task, words) => `Asked in the thread "${task.objective}" (thread ${task.id}):\n\n${words}\n\n`
|
|
722
|
+
+ `Read the thread with read_goal_room (id ${task.id}) before you answer. Your answer here goes back into the thread.`;
|
|
665
723
|
// Ask an agent that is not started here: the ask is a task in the thread, posted in the asker's name, so its
|
|
666
724
|
// authority is the asker's (an agent's ask to Bankr waits for the person). One open ask per agent per thread.
|
|
667
|
-
const askAgent = (goalId, agent, reason, asker) => {
|
|
725
|
+
const askAgent = (goalId, agent, reason, asker, words = reason) => {
|
|
668
726
|
const task = store.getTask(goalId);
|
|
669
727
|
if (!task || task.status !== "open") return;
|
|
670
728
|
if (store.threadRuns(goalId).some((run) => run.target_agent === agent && ["open", "claimed", "running", "needs_input"].includes(run.status))) return;
|
|
@@ -673,18 +731,48 @@ export async function startDaemon() {
|
|
|
673
731
|
// with money (Bankr, Quotient) waits for the person under its own rules.
|
|
674
732
|
const written = from === "user" ? {} : { provenance: { trust: "agent", chain: [...(task.origin_chain || []), from].slice(-16) } };
|
|
675
733
|
const posted = boardCommand({ command: "board.post", agent: from, objective: `Asked in "${task.objective}": ${reason}`.slice(0, 500),
|
|
676
|
-
brief: swarmBrief(task, agent, reason, { ask: true }), targetAgent: agent, parentId: goalId, ...written });
|
|
734
|
+
brief: agent === "harness" ? harnessAsk(task, words) : swarmBrief(task, agent, reason, { ask: true }), targetAgent: agent, parentId: goalId, ...written });
|
|
677
735
|
const failed = (error) => { store.roomPost({ goalId, author: task.goal.facilitator, kind: "note", text: `${nameOf(agent)} could not be asked: ${error}` }); notifyBoard(store.getTask(goalId)); };
|
|
678
736
|
if (posted?.pending) posted.pending.catch((error) => failed(error.message));
|
|
679
737
|
else if (posted && !posted.ok) failed(posted.error);
|
|
738
|
+
else if (agent === "harness" && posted?.task) offerToServer(task, posted.task.id, from, words);
|
|
680
739
|
};
|
|
681
|
-
|
|
740
|
+
// Harness's server takes a task given to Harness in a thread while no Harness window is open (src/server/swarm/harnessTask.ts),
|
|
741
|
+
// only while this computer's coordinator runs on Harness: on the privacy setting the thread's words stay here and the task
|
|
742
|
+
// waits for a window. Harness holds it meanwhile, so a window that opens does not take it too; what the server leaves for
|
|
743
|
+
// the browser goes back on the board, and the window takes it over when it next opens (owner, October 14).
|
|
744
|
+
const offerToServer = (goal, taskId, asker, words) => {
|
|
745
|
+
if (coordinatorMode() !== "harness" || !state.connected || !serverFeatures.has("harness_task")) return;
|
|
746
|
+
if (!boardCommand({ command: "board.claim", agent: "harness", id: taskId })?.ok) return;
|
|
747
|
+
const transcript = store.roomPosts(goal.id, 0, 500).filter((post) => !["note", "held", "round"].includes(post.kind)).slice(-30)
|
|
748
|
+
.map((post) => ({ author: post.author, kind: post.kind, text: post.text.slice(0, 20_000) }));
|
|
749
|
+
// Back to the board for a window, as the person would put it back, with what the thread should hear.
|
|
750
|
+
const back = (post) => {
|
|
751
|
+
if (post) store.roomPost({ goalId: goal.id, author: "harness", ...post });
|
|
752
|
+
boardCommand({ command: "board.release", agent: "user", id: taskId });
|
|
753
|
+
notifyBoard(store.getTask(goal.id));
|
|
754
|
+
};
|
|
755
|
+
const waits = (why) => back({ kind: "note", text: `This waits for your Harness window: ${why}.` });
|
|
756
|
+
askHarness("harness_task", { taskId, thread: { id: goal.id, title: goal.objective.slice(0, 500) }, asker, words: String(words).slice(0, 20_000), transcript }, null).then((reply) => {
|
|
757
|
+
// askHarness spreads Harness's answer into the reply.
|
|
758
|
+
const outcome = reply?.ok ? reply : null;
|
|
759
|
+
if (!outcome) return waits(`Harness could not take it here (${reply?.error || "no answer"})`);
|
|
760
|
+
if (!outcome.taken) return outcome.why === "window_open" ? back(null) : waits(outcome.detail || "Harness could not take it here");
|
|
761
|
+
if (outcome.status === "done") return boardCommand({ command: "board.report", agent: "harness", id: taskId, status: "done", result: outcome.text });
|
|
762
|
+
if (outcome.status === "window") return back({ kind: "post", text: `${outcome.text}\n\nThe rest needs your browser: ${outcome.windowOpen ? "your Harness window is taking it over" : "Harness takes it over when your Harness window next opens"}.` });
|
|
763
|
+
waits(`Harness could not finish it here${outcome.text ? ` (${outcome.text.slice(0, 300)})` : ""}`);
|
|
764
|
+
}, (error) => waits(`Harness could not take it here (${error.message})`));
|
|
765
|
+
};
|
|
766
|
+
const wakeFor = (goalId, post, author, names = post.mentions, { hosted = false } = {}) => {
|
|
682
767
|
const goal = store.goal(goalId);
|
|
768
|
+
const { picks } = picksIn(store, post.text, names);
|
|
683
769
|
for (const agent of names) {
|
|
684
770
|
if (agent === author) continue;
|
|
685
771
|
const reason = `${author} mentioned you: ${post.text.slice(0, 300)}`;
|
|
686
|
-
if (RUNNERS.has(agent)) wakeAgent(goalId, agent, reason, {
|
|
772
|
+
if (RUNNERS.has(agent)) wakeAgent(goalId, agent, reason, { go: personsGo(author, agent, { hosted }), asker: author, pick: picks.get(agent) });
|
|
687
773
|
else if (ASKABLE.has(agent)) askAgent(goalId, agent, reason, author);
|
|
774
|
+
// Harness by name: the person's @harness or an agent's ask. A message naming no one falls to Harness as coordinator.
|
|
775
|
+
else if (agent === "harness" && post.kind === "post" && (author !== "user" || mentionsIn(post.text, [agent]).length)) askAgent(goalId, agent, reason, author, post.text);
|
|
688
776
|
}
|
|
689
777
|
// Harness follows through the board change; it is woken by mentions, results and questions, not by every note.
|
|
690
778
|
if (goal && names.includes(goal.facilitator)) notifyBoard(store.getTask(goalId));
|
|
@@ -698,7 +786,7 @@ export async function startDaemon() {
|
|
|
698
786
|
const stopped = kind === "failed" && store.getTask(goalId)?.status !== "open";
|
|
699
787
|
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}` });
|
|
700
788
|
const pending = pendingWakes.get(`${goalId}:${task.target_agent}`);
|
|
701
|
-
if (pending) { pendingWakes.delete(`${goalId}:${task.target_agent}`); wakeAgent(goalId, task.target_agent, pending.reason, {
|
|
789
|
+
if (pending) { pendingWakes.delete(`${goalId}:${task.target_agent}`); wakeAgent(goalId, task.target_agent, pending.reason, { go: pending.go, asker: pending.asker, pick: pending.pick }); }
|
|
702
790
|
notifyBoard(store.getTask(goalId));
|
|
703
791
|
};
|
|
704
792
|
// A job under a goal changed: its result or question lands in the room and wakes whoever posted it.
|
|
@@ -709,8 +797,10 @@ export async function startDaemon() {
|
|
|
709
797
|
const who = task.claimed_by || task.target_agent || "an agent";
|
|
710
798
|
// A task's own job reports to whoever posted it the way any task does; it does not wake them.
|
|
711
799
|
const root = store.meta(`task-room:${goal.id}`) === task.id;
|
|
800
|
+
// An answer to an ask (askAgent) follows the ask in the thread, so it stands as said; a job's result names the job.
|
|
801
|
+
const answer = task.status !== "failed" && String(task.result || "").trim() && task.objective.startsWith(`Asked in "${goal.objective}": `);
|
|
712
802
|
const post = store.roomPost({ goalId: goal.id, author: who, kind: task.status === "needs_input" ? "question" : "result",
|
|
713
|
-
text: `${task.status === "done" ? "Finished" : task.status === "failed" ? "Could not finish" : "Asks about"} job "${task.objective}": ${task.result || ""}
|
|
803
|
+
text: (answer ? task.result : `${task.status === "done" ? "Finished" : task.status === "failed" ? "Could not finish" : "Asks about"} job "${task.objective}": ${task.result || ""}`).slice(0, 20_000),
|
|
714
804
|
mentions: root ? [] : [task.origin_agent].filter((agent) => goal.goal.team.includes(agent)) });
|
|
715
805
|
// A room where only the task's own agent worked closes with the task; one that grew into a conversation stays open.
|
|
716
806
|
if (root && task.status !== "needs_input" && goal.status === "open" && !store.roomPosts(goal.id, 0, 500).some((entry) => entry.kind === "post")
|
|
@@ -766,6 +856,9 @@ export async function startDaemon() {
|
|
|
766
856
|
if (goal.status !== "open") return { ok: false, error: `the goal is ${goal.status}` };
|
|
767
857
|
const text = String(request.text || "").trim().slice(0, 20_000);
|
|
768
858
|
if (!text) return { ok: false, error: "a post needs text" };
|
|
859
|
+
// A model named for an agent (`@codex:gpt-6-luna/high`) must be one it lists, or the post is refused saying why.
|
|
860
|
+
const picked = picksIn(store, text, ROOM_AGENTS);
|
|
861
|
+
if (picked.error) return { ok: false, error: picked.error };
|
|
769
862
|
const flag = secretReason(text);
|
|
770
863
|
if (flag) {
|
|
771
864
|
const held = store.roomPost({ goalId: goal.id, author: agent, kind: "held", text });
|
|
@@ -794,7 +887,7 @@ export async function startDaemon() {
|
|
|
794
887
|
if (sent.ok && sent.message.state === "sent") sendThreadMessage(sent.message);
|
|
795
888
|
}
|
|
796
889
|
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 }; }
|
|
797
|
-
wakeFor(goal.id, post, agent, wakes);
|
|
890
|
+
wakeFor(goal.id, post, agent, wakes, { hosted: request.hostedCaller === true });
|
|
798
891
|
notifyBoard(store.getTask(goal.id));
|
|
799
892
|
const notes = [...(unwoken.length ? [`Named ${unwoken.map((name) => `@${name}`).join(", ")} without asking, so not woken. To ask an agent, put its name in ask.`] : []), ...wakes.map(setupNote).filter(Boolean)];
|
|
800
893
|
return { ok: true, post, ...(notes.length ? { note: notes.join(" ") } : {}) };
|
|
@@ -837,11 +930,10 @@ export async function startDaemon() {
|
|
|
837
930
|
notifyBoard(task);
|
|
838
931
|
return { ok: true, task };
|
|
839
932
|
}
|
|
933
|
+
// The same move as the composer's (swarm.mjs board.swarm.folder): between turns, each agent fresh in the new folder.
|
|
840
934
|
if (request.command === "board.goal.folder") {
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
store.setGoalFolder(goal.id, path.resolve(workspace));
|
|
844
|
-
return { ok: true, task: store.getTask(goal.id) };
|
|
935
|
+
const moved = swarmCommand({ command: "board.swarm.folder", id: goal.id, folder: String(request.workspace || "") }, "user");
|
|
936
|
+
return moved.ok ? { ok: true, task: store.getTask(goal.id) } : moved;
|
|
845
937
|
}
|
|
846
938
|
if (goal.status !== "paused") return { ok: false, error: `the goal is ${goal.status}` };
|
|
847
939
|
const task = store.setGoalState(goal.id, { status: "open", maxPosts: Number.isInteger(request.maxPosts) && request.maxPosts > 0 ? request.maxPosts : undefined });
|
|
@@ -863,10 +955,23 @@ export async function startDaemon() {
|
|
|
863
955
|
// "harness" (default): rounds go to Harness, Jev decides. "local": an agent on this machine decides with its tools off.
|
|
864
956
|
const coordinatorMode = () => (readJson(paths.config, {}).coordinator === "local" ? "local" : "harness");
|
|
865
957
|
const setCoordinatorMode = (mode) => writeJsonAtomic(paths.config, { ...readJson(paths.config, {}), coordinator: mode });
|
|
958
|
+
// The model each run starts with (models.mjs). Auto asks Harness, never on the privacy setting, and only a Harness that
|
|
959
|
+
// said it can pick models: one from before that would leave the run waiting for an answer that never comes.
|
|
960
|
+
// Each agent's own list of models, asked at start, once a day and when the person asks (models.mjs).
|
|
961
|
+
const listModels = () => refreshCatalogs(store, installedAgents.filter((agent) => PICKABLE.has(agent)), log);
|
|
962
|
+
// What Auto reads: the work itself, not the brief's instructions; a thread's turn is its request and its recent posts.
|
|
963
|
+
const workOf = (task) => {
|
|
964
|
+
if (task.kind !== "wake") return { work: `${task.objective}\n\n${task.brief}`.trim(), transcript: [] };
|
|
965
|
+
const thread = store.getTask(task.parent_id);
|
|
966
|
+
return { work: `${thread.objective}${thread.brief ? `\n\n${thread.brief}` : ""}`,
|
|
967
|
+
transcript: store.roomPosts(thread.id, 0, 500).filter((post) => !["note", "held", "round"].includes(post.kind)).slice(-30).map((post) => ({ author: post.author, text: post.text.slice(0, 20_000) })) };
|
|
968
|
+
};
|
|
969
|
+
const modelForRun = createModelRouter({ store, askHarness, canAsk: () => state.connected && serverFeatures.has("route_model"), privacy: () => coordinatorMode() === "local", ownModel: (agent) => modelOf(agent), workOf, log });
|
|
866
970
|
const coordinator = createCoordinator({
|
|
867
|
-
store, mode: coordinatorMode, askHarness, connected: () => state.connected, localAgents: () => installedAgents.filter((agent) => RUNNERS.has(agent)),
|
|
868
|
-
|
|
869
|
-
|
|
971
|
+
store, mode: coordinatorMode, askHarness, connected: () => state.connected, serverHas: (feature) => serverFeatures.has(feature), localAgents: () => installedAgents.filter((agent) => RUNNERS.has(agent)),
|
|
972
|
+
// Harness is one of them (owner, October 14): handed work, its open window or Harness's server takes it (offerToServer).
|
|
973
|
+
roster: () => cardsFor(available().filter((agent) => setupOf(agent)?.state !== "not_set_up")),
|
|
974
|
+
handOn: (goalId, agent, reason, { review, words }) => (RUNNERS.has(agent) ? wakeAgent(goalId, agent, reason, { review, go: personsGo("harness", agent) }) : askAgent(goalId, agent, reason, "harness", words)),
|
|
870
975
|
pauseGoal, notifyBoard, searchContext, log,
|
|
871
976
|
});
|
|
872
977
|
const swarmCommand = createSwarm({
|
|
@@ -875,7 +980,10 @@ export async function startDaemon() {
|
|
|
875
980
|
coordinator: { mode: coordinatorMode, setMode: setCoordinatorMode, state: (id) => coordinator.state(id) },
|
|
876
981
|
roster: () => available().map((id) => ({ id, about: aboutOf(id), setup: setupOf(id) })),
|
|
877
982
|
worktrees,
|
|
983
|
+
folders: createFolders({ store, askHarness, canAsk: () => state.connected && serverFeatures.has("pick_folder"), privacy: () => coordinatorMode() === "local", log }),
|
|
984
|
+
runsIn,
|
|
878
985
|
authorizeRun: (id) => boardCommand({ command: "board.run", agent: "user", id }),
|
|
986
|
+
pickable: () => installedAgents.filter((agent) => PICKABLE.has(agent)), listModels,
|
|
879
987
|
});
|
|
880
988
|
|
|
881
989
|
// The person's own sessions running now (live.mjs), and what they sent one from Harness: it waits here until that
|
|
@@ -925,6 +1033,8 @@ export async function startDaemon() {
|
|
|
925
1033
|
let env = placeEnv(request.env);
|
|
926
1034
|
let host = pids.map((pid) => hostApp(table, pid)).find(Boolean) ?? null;
|
|
927
1035
|
let tty = pids.map((pid) => table.get(pid)?.tty).find(Boolean) ?? null;
|
|
1036
|
+
// The agent's own process (not the shell a hook ran in), kept with the place: the session runs while it does.
|
|
1037
|
+
let agentPid = pids.find((pid) => path.basename(table.get(pid).comm) === PROCESS_NAMES[agentId]) ?? null;
|
|
928
1038
|
if (agentId === "codex" && pids.length && !host && !tty) {
|
|
929
1039
|
const clients = [...table].filter(([, row]) => path.basename(row.comm) === "codex" && row.tty).map(([pid]) => pid);
|
|
930
1040
|
const cwds = workingDirectories(clients);
|
|
@@ -933,8 +1043,11 @@ export async function startDaemon() {
|
|
|
933
1043
|
env = processEnv(client);
|
|
934
1044
|
host = hostApp(table, client);
|
|
935
1045
|
tty = table.get(client).tty;
|
|
1046
|
+
// Codex's shared server outlives its sessions; the terminal's Codex is this one.
|
|
1047
|
+
agentPid = client;
|
|
936
1048
|
}
|
|
937
|
-
|
|
1049
|
+
const place = placeFrom({ env, host, tty, folder });
|
|
1050
|
+
return place && agentPid ? { ...place, pid: agentPid, comm: table.get(agentPid).comm } : place;
|
|
938
1051
|
};
|
|
939
1052
|
// A custom harness may say how one of its sessions opens again: a page it serves on this computer, or a command to type.
|
|
940
1053
|
const reopenOf = (value) => {
|
|
@@ -954,7 +1067,10 @@ export async function startDaemon() {
|
|
|
954
1067
|
};
|
|
955
1068
|
const linksOk = t3LinksOk();
|
|
956
1069
|
const handed = handedToT3();
|
|
957
|
-
|
|
1070
|
+
// The sessions that named their own process when they started (pi's extension, OpenCode's plugin, hooks).
|
|
1071
|
+
const reported = LIVE_AGENTS.flatMap((agentId) => Object.entries(placesOf(agentId)).filter(([, place]) => Number.isInteger(place.pid))
|
|
1072
|
+
.map(([sessionId, place]) => ({ agent: agentId, sessionId, folder: place.folder || null, pid: place.pid, comm: place.comm, at: place.at })));
|
|
1073
|
+
const sessions = liveSessions({ exclude: new Set(runningPids()), recent, reported })
|
|
958
1074
|
// A T3 thread running a turn Harness handed it is Harness's run, shown in its thread.
|
|
959
1075
|
.filter((session) => !handed.has(session.t3Thread))
|
|
960
1076
|
// A T3 Code that cannot open links is brought forward instead, and messages reach its sessions through their hooks.
|
|
@@ -1020,34 +1136,51 @@ export async function startDaemon() {
|
|
|
1020
1136
|
const ownSession = (row) => !String(row.cwd || "").startsWith(paths.home + path.sep);
|
|
1021
1137
|
const short = (name) => String(name || "").replace(/ \(.*\)$/, "");
|
|
1022
1138
|
|
|
1139
|
+
/** Whether one of Harness's runs is still working in this session: it is not resumed beside it. */
|
|
1140
|
+
const runBusy = (sessionId) => Boolean(sessionId) && [...activeRuns.values()].some(({ id }) => store.getTask(id)?.session_id === sessionId);
|
|
1023
1141
|
/** How a session opens in one place (or the agent itself, with no session); null when that place cannot show it. */
|
|
1024
1142
|
const placePlan = (agentId, place, sessionId, folder, act) => {
|
|
1025
1143
|
if (!place) return null;
|
|
1026
1144
|
const name = short(place.name);
|
|
1145
|
+
const busy = runBusy(sessionId)
|
|
1146
|
+
? `${nameOf(agentId)} is still working on this${folder ? ` in ${path.basename(folder)}` : ""}; Open it again once it's done to carry on in the same session.` : null;
|
|
1027
1147
|
if (place.kind === "t3") {
|
|
1028
1148
|
if (!place.app) return null;
|
|
1029
1149
|
const thread = sessionId ? t3Thread(sessionId) : null;
|
|
1030
1150
|
if (thread && t3OpensLinks(place.app)) return { opensIn: "T3 Code", link: t3Link(thread) };
|
|
1031
1151
|
if (thread || !sessionId) return { opensIn: "T3 Code", app: place.app, ...(thread ? { note: T3_OLD } : {}) };
|
|
1032
1152
|
// A session T3 never had (a run Harness started in its own folder, say): T3 comes forward as it is, saying so,
|
|
1033
|
-
// and the session can still be resumed in a terminal instead.
|
|
1153
|
+
// and the session can still be resumed in a terminal instead. Until Harness may send into T3, its threads run in
|
|
1154
|
+
// that copy: approving it is what moves them into T3 (inT3).
|
|
1034
1155
|
return { opensIn: "T3 Code", app: place.app, resumable: true, note: harnessRun(folder)
|
|
1035
|
-
?
|
|
1156
|
+
? `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.${t3Access()?.state === "send" ? "" : " Approve Harness in T3 Code and this thread's next turns run there, where Open finds them."}`
|
|
1036
1157
|
: "T3 Code doesn't list this session, so it is in front as it is." };
|
|
1037
1158
|
}
|
|
1038
1159
|
if (place.kind === "editor") {
|
|
1039
|
-
// Its window
|
|
1160
|
+
// Its window (the folder the editor has open around the session's), first when a panel link is to land there.
|
|
1040
1161
|
const window = editorFolder(place.editor, folder);
|
|
1041
|
-
|
|
1162
|
+
// Not in its panel or a terminal beside a run still working in it: its folder comes up, and the buddy shows the work.
|
|
1163
|
+
if (busy && place.app) { if (act && window) openEditorFolder(place, window); return { opensIn: name, app: place.app, note: busy }; }
|
|
1042
1164
|
const link = sessionId ? panelLink(agentId, place.editor, sessionId) : null;
|
|
1043
|
-
if (link) return { opensIn: name, link };
|
|
1165
|
+
if (link) { if (act && window) openEditorFolder(place, window); return { opensIn: name, link }; }
|
|
1044
1166
|
if (!place.app) return null;
|
|
1045
|
-
// An editor's terminal takes no command from another app: the session's own command is copied to paste there.
|
|
1046
1167
|
const line = sessionId ? resumeLine(agentId, sessionId, folder) : null;
|
|
1168
|
+
// The session itself, resumed in the editor's own terminal in a window on its folder: a workspace whose task runs
|
|
1169
|
+
// when the window opens (the editor asks once). The command is copied too, should the person not allow it.
|
|
1170
|
+
if (line && editorRunsOpenTasks(place.editor)) {
|
|
1171
|
+
if (act) openEditorFolder(place, editorSessionWorkspace(agentId, sessionId, folder, window));
|
|
1172
|
+
return { opensIn: name, app: place.app, copy: line, note: `${name} opens ${path.basename(window || folder)} with this session in its terminal. The first time, it asks you to trust the folder and to allow the task that resumes it; the command is copied too.` };
|
|
1173
|
+
}
|
|
1174
|
+
// Automatic tasks turned off there: the session's own command is copied to paste in its terminal.
|
|
1175
|
+
if (act && window) openEditorFolder(place, window);
|
|
1047
1176
|
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.` } : {}) };
|
|
1048
1177
|
}
|
|
1049
1178
|
if (place.kind === "codex-app") return agentId === "codex" && sessionId ? { opensIn: "Codex", link: `codex://threads/${sessionId}` } : place.app ? { opensIn: "Codex", app: place.app } : null;
|
|
1050
|
-
if (place.kind === "terminal")
|
|
1179
|
+
if (place.kind === "terminal") {
|
|
1180
|
+
if (!CODING_AGENTS.includes(agentId)) return null;
|
|
1181
|
+
if (busy) return place.app ? { opensIn: name, app: place.app, note: busy } : { opensIn: name, note: busy };
|
|
1182
|
+
return { opensIn: name, resume: { agent: agentId, session: sessionId ?? null, cwd: folder ?? null, app: place.app ?? null } };
|
|
1183
|
+
}
|
|
1051
1184
|
return place.app ? { opensIn: name, app: place.app } : null;
|
|
1052
1185
|
};
|
|
1053
1186
|
/** A running session, where it runs: its T3 thread, its panel, its editor's window, its terminal pane and tab. */
|
|
@@ -1123,7 +1256,8 @@ export async function startDaemon() {
|
|
|
1123
1256
|
// A session's latest messages and steps, from its own file (running or not).
|
|
1124
1257
|
if (request.command === "board.live.read") {
|
|
1125
1258
|
const agentId = String(request.to || "");
|
|
1126
|
-
if (!LIVE_AGENTS.includes(agentId)
|
|
1259
|
+
if (!LIVE_AGENTS.includes(agentId)) return { ok: false, error: "only a coding agent's session can be read here" };
|
|
1260
|
+
if (agentId === "opencode") return { ok: true, items: opencodeFeed(opencodeExecutable || "opencode", sessionId, Math.min(200, Number(request.limit) || 80)) };
|
|
1127
1261
|
const file = sessionFile(agentId, sessionId, folderOf(request), store.recentSessions(200));
|
|
1128
1262
|
if (!file) return { ok: true, items: [] };
|
|
1129
1263
|
return { ok: true, items: sessionFeed(agentId, file, Math.min(200, Number(request.limit) || 80)) };
|
|
@@ -1160,6 +1294,8 @@ export async function startDaemon() {
|
|
|
1160
1294
|
// Lid-Closed Mode's Standby: when anything last came in (a phone message, a wake, a new task).
|
|
1161
1295
|
let listenerAt = Date.now();
|
|
1162
1296
|
const touchListener = () => { listenerAt = Date.now(); };
|
|
1297
|
+
// Swarms talking to swarms (network.mjs): set once the relay's helpers below exist.
|
|
1298
|
+
let network = null;
|
|
1163
1299
|
const boardCommand = (request) => {
|
|
1164
1300
|
if (!validAgent(request.agent)) return { ok: false, error: "board requests need an agent name" };
|
|
1165
1301
|
if (request.command === "board.post") touchListener();
|
|
@@ -1171,6 +1307,7 @@ export async function startDaemon() {
|
|
|
1171
1307
|
let outcome;
|
|
1172
1308
|
if (String(request.command).startsWith("board.goal.") || String(request.command).startsWith("board.room.")) return goalCommand(request, agent);
|
|
1173
1309
|
if (String(request.command).startsWith("board.swarm.")) return swarmCommand(request, agent);
|
|
1310
|
+
if (String(request.command).startsWith("board.relay.")) return network ? network.command(request, agent) : { ok: false, error: "the daemon is still starting" };
|
|
1174
1311
|
if (request.command === "board.prompted") {
|
|
1175
1312
|
for (const pid of Array.isArray(request.pids) ? request.pids.slice(0, 2) : []) if (Number.isInteger(pid) && pid > 1) promptedBy.set(pid, agent);
|
|
1176
1313
|
// A session that started before its hook said where it runs says so on its next prompt.
|
|
@@ -1189,9 +1326,12 @@ export async function startDaemon() {
|
|
|
1189
1326
|
if (request.command === "board.post") {
|
|
1190
1327
|
const objective = String(request.objective || "").trim();
|
|
1191
1328
|
if (!objective) return { ok: false, error: "a task needs an objective" };
|
|
1329
|
+
// A thread with another person themselves (not one of their agents) is the person's own: no agent here claims or runs it.
|
|
1330
|
+
const personThread = Boolean(request.thread) && request.targetAgent === "person";
|
|
1331
|
+
if (personThread) request = { ...request, targetAgent: "user" };
|
|
1192
1332
|
// A target is named the way people write it ("Grok", "Claude Code") and resolved through the roster; a name
|
|
1193
1333
|
// several members start ("claude") or none matches is refused with who it could be, never guessed.
|
|
1194
|
-
if (request.targetAgent != null && !String(request.targetAgent).includes("/")) {
|
|
1334
|
+
if (request.targetAgent != null && !personThread && !String(request.targetAgent).includes("/")) {
|
|
1195
1335
|
const named = resolveMember(request.targetAgent, { extra: store.agentsSeen().map((row) => row.agent).filter((seen) => !member(seen)) });
|
|
1196
1336
|
if (!named.ok) {
|
|
1197
1337
|
return { ok: false, code: "unknown_agent", candidates: named.candidates, error: named.candidates.length
|
|
@@ -1202,6 +1342,14 @@ export async function startDaemon() {
|
|
|
1202
1342
|
request = { ...request, targetAgent: named.id };
|
|
1203
1343
|
}
|
|
1204
1344
|
if (request.targetAgent != null && !validAgent(request.targetAgent)) return { ok: false, error: "invalid target agent" };
|
|
1345
|
+
// The model and effort the poster names for the run, checked against the agent's own list (models.mjs).
|
|
1346
|
+
let pick = null;
|
|
1347
|
+
if (request.model) {
|
|
1348
|
+
if (!PICKABLE.has(String(request.targetAgent || ""))) return { ok: false, error: "a model can be named only for one of claude-code, codex, pi or opencode, as target_agent" };
|
|
1349
|
+
const resolved = resolvePick(store, String(request.targetAgent), request.model, request.effort);
|
|
1350
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
1351
|
+
pick = resolved.pick;
|
|
1352
|
+
}
|
|
1205
1353
|
// Provenance is code's: the local parent's label and what Harness sent from above the poster can only tighten it.
|
|
1206
1354
|
const parentTask = request.parentId ? store.getTask(String(request.parentId)) : null;
|
|
1207
1355
|
const claimed = request.provenance && typeof request.provenance === "object" && request.provenance.trust === "agent" ? request.provenance : null;
|
|
@@ -1223,8 +1371,8 @@ export async function startDaemon() {
|
|
|
1223
1371
|
return forwardToHarness({ ...request, provenance: upstream });
|
|
1224
1372
|
}
|
|
1225
1373
|
// Left out, a task from a coding agent for a coding agent runs in the folder the poster works in (its stdio MCP's,
|
|
1226
|
-
// which
|
|
1227
|
-
const posterFolder = !request.thread && !goalParent && request.inSession !== true &&
|
|
1374
|
+
// which each agent starts in the session's folder); never home or above it.
|
|
1375
|
+
const posterFolder = !request.thread && !goalParent && request.inSession !== true && RUNNERS.has(agent) && RUNNERS.has(String(request.targetAgent || ""))
|
|
1228
1376
|
&& typeof request.posterCwd === "string" && path.isAbsolute(request.posterCwd) && !aboveHome(request.posterCwd) ? request.posterCwd : null;
|
|
1229
1377
|
// The person's own task for a coding agent that names no folder runs in that agent's scratch folder, so it starts like
|
|
1230
1378
|
// any covered run (same daily limit, same off switch) instead of waiting for a session to look at the board.
|
|
@@ -1265,14 +1413,18 @@ export async function startDaemon() {
|
|
|
1265
1413
|
originAgent: agent, provenance, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
|
|
1266
1414
|
// Asking for a run in a folder is the person's Start; a scratch folder they never asked for goes by standing permission.
|
|
1267
1415
|
runWorkspace: thread ? null : runWorkspace, runAuthorizedBy: !thread && runWorkspace && !scratch && agent === "user" ? "user" : null });
|
|
1268
|
-
if (thread) store.openThread(task.id, { withAgent: String(thread.with), role: thread.role, peerKey: thread[OPENED] && thread.peerKey ? String(thread.peerKey) : null });
|
|
1269
|
-
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1416
|
+
if (thread) { store.openThread(task.id, { withAgent: String(thread.with), role: thread.role, peerKey: thread[OPENED] && thread.peerKey ? String(thread.peerKey) : null }); network?.opened(task.id); }
|
|
1417
|
+
if (pick) setRunPick(store, task.id, pick, agent);
|
|
1418
|
+
// A job in a goal's room runs on the goal's authority; anywhere else, standing permission decides. The goal covers
|
|
1419
|
+
// jobs from the person, Harness and the coding agents on this machine, with the person's go (personsGo) unless an
|
|
1420
|
+
// agent's words came from above the poster; a job from a member that runs elsewhere (Grok Bot, Muse, a connected
|
|
1421
|
+
// app) waits for the person's Start.
|
|
1272
1422
|
const onGoal = agent === "user" || agent === "harness" || (!request.hostedCaller && member(agent)?.runs === "machine");
|
|
1273
1423
|
const agentWritten = task.trust !== "user";
|
|
1274
|
-
const
|
|
1275
|
-
const
|
|
1424
|
+
const go = onGoal && (!agentWritten || (!claimed && !setToWait(String(task.target_agent))));
|
|
1425
|
+
const mayStart = goalParent && task.run_workspace && threadMayRun(goalParent) && (go || (onGoal && restrictable(task.target_agent, task.run_workspace)));
|
|
1426
|
+
const startOnGoal = () => { if (go) store.meta(`person-wake:${task.id}`, "1"); return store.authorizeGoalRun(task.id); };
|
|
1427
|
+
const standing = thread ? undefined : goalParent ? (!task.run_workspace ? undefined : mayStart && startOnGoal() ? { started: true, by: "goal", ...(go ? {} : { restricted: true }) }
|
|
1276
1428
|
: !threadMayRun(goalParent) ? undefined
|
|
1277
1429
|
: { 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);
|
|
1278
1430
|
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) });
|
|
@@ -1399,7 +1551,8 @@ export async function startDaemon() {
|
|
|
1399
1551
|
outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
|
|
1400
1552
|
// The person's own session that took it, so the task opens there (its MCP server is a child of that session).
|
|
1401
1553
|
const claimed = outcome?.ok ? store.getTask(id) : null;
|
|
1402
|
-
const
|
|
1554
|
+
const reportedBy = (pid) => Object.entries(placesOf(agent)).find(([, place]) => place.pid === pid)?.[0] ?? null;
|
|
1555
|
+
const session = claimed && !claimed.session_id && LIVE_AGENTS.includes(agent) ? sessionOfProcess(agent, request.claimerPid, store.recentSessions(200)) ?? reportedBy(request.claimerPid) : null;
|
|
1403
1556
|
if (session) { store.setRunSession(id, session); outcome = { ...outcome, task: store.getTask(id) }; }
|
|
1404
1557
|
}
|
|
1405
1558
|
else if (request.command === "board.report") outcome = store.reportTask(id, agent, { status: String(request.status || ""), result: String(request.result || "").slice(0, 200_000) }, expected);
|
|
@@ -1452,6 +1605,8 @@ export async function startDaemon() {
|
|
|
1452
1605
|
// One run per folder at a time: each thread's worktree is its own folder, so threads run side by side.
|
|
1453
1606
|
for (const task of store.runnableTasks(installedAgents)) {
|
|
1454
1607
|
const agent = task.target_agent;
|
|
1608
|
+
// Where a task runs is settled once, before its slot: a run T3 carries out holds its thread, not a folder.
|
|
1609
|
+
if (task.kind === "task" && store.meta(`t3-run:${task.id}`) === null) store.meta(`t3-run:${task.id}`, taskInT3(task, agent) ? "1" : "0");
|
|
1455
1610
|
const slot = slotOf(task);
|
|
1456
1611
|
if (activeRuns.has(slot)) continue;
|
|
1457
1612
|
const claimed = store.claimTask(task.id, agent, task.version, null);
|
|
@@ -1487,10 +1642,15 @@ export async function startDaemon() {
|
|
|
1487
1642
|
const resumeSession = wake ? store.goal(task.parent_id)?.sessions[agent] : stoppedFor ? task.session_id || undefined : undefined;
|
|
1488
1643
|
const again = stoppedFor ? `This task was stopped partway because ${stoppedFor}. Continue from where you left off; check the folder for what you already changed.` : "";
|
|
1489
1644
|
const readOnly = store.meta(`review:${task.id}`) === "1";
|
|
1490
|
-
// Another agent's words on a goal's authority,
|
|
1645
|
+
// Another agent's words on a goal's authority, without the person's tap or go (person-wake): restricted (docs/member-runs.md).
|
|
1491
1646
|
const restricted = task.trust !== "user" && task.run_authorized_by === "goal" && store.meta(`person-wake:${task.id}`) !== "1";
|
|
1492
|
-
|
|
1493
|
-
|
|
1647
|
+
// The model and effort it starts with (models.mjs), kept on the run with who chose them; without a choice, the model
|
|
1648
|
+
// the agent's own settings name, so each model's record covers every run.
|
|
1649
|
+
const chosen = await modelForRun(task, agent);
|
|
1650
|
+
const { model = null, effort = null } = chosen ?? {};
|
|
1651
|
+
store.setRunModel(task.id, { ...chosen, model: model ?? modelOf(agent), effort, by: model ? chosen.by : chosen?.by ?? "its settings", ...(model ? {} : { ownSettings: true }) });
|
|
1652
|
+
const t3 = store.meta(`t3-run:${task.id}`) === "1" ? t3Turn(task, agent, { model, effort }) : null;
|
|
1653
|
+
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, ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(stoppedFor ? { again: true } : {}), ...(resumeSession && !t3 ? { resumeSession } : {}), ...(readOnly ? { readOnly } : {}), ...(restricted ? { restricted } : {}), ...(t3 ? { t3 } : {}) }, (kind, text, extra) => {
|
|
1494
1654
|
// The agent's session in a thread is resumed when the thread calls on it again; in T3 it is the agent's T3 thread.
|
|
1495
1655
|
if (kind === "session" && text) { store.setRunSession(task.id, text); if (!t3 && (wake || room?.kind === "goal")) store.setGoalSession(task.parent_id, agent, text); notifyBoard(store.getTask(task.id)); }
|
|
1496
1656
|
// A step the agent took: whoever has the thread open reads it again, at most once a second per run.
|
|
@@ -1600,6 +1760,7 @@ export async function startDaemon() {
|
|
|
1600
1760
|
const opened = await openBound(await relayKey(relayIdentity.privateKey, peer), `request|${sealed.sealId}`, sealed);
|
|
1601
1761
|
return { about: String(opened.about || "").slice(0, 500), fingerprint: await fingerprint(peer) };
|
|
1602
1762
|
};
|
|
1763
|
+
network = createNetwork({ store, askHarness, relayRequest, openRelayRequest, fingerprint, myKey: () => relayIdentity?.publicKey ?? null });
|
|
1603
1764
|
|
|
1604
1765
|
// Once a browser is paired (store.sealedOnly), Harness's plain calls get metadata only: who, which state,
|
|
1605
1766
|
// when, the same as every board_changed already carries, and never the words (objectives, briefs,
|
|
@@ -1891,6 +2052,7 @@ export async function startDaemon() {
|
|
|
1891
2052
|
offlineSince = null;
|
|
1892
2053
|
state.connected = true;
|
|
1893
2054
|
state.lastError = null;
|
|
2055
|
+
serverFeatures = new Set();
|
|
1894
2056
|
saveStatus();
|
|
1895
2057
|
send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.status", "board", "threads", "sealed", "sealed_threads", "sealed_files"], identityKey: relayIdentity?.publicKey });
|
|
1896
2058
|
// Threads that went quiet while Harness was out of reach get their round now.
|
|
@@ -1905,6 +2067,7 @@ export async function startDaemon() {
|
|
|
1905
2067
|
return;
|
|
1906
2068
|
}
|
|
1907
2069
|
if (message.type === "sync") { void sync(); return; }
|
|
2070
|
+
if (message.type === "server_features" && Array.isArray(message.features)) { serverFeatures = new Set(message.features.map(String)); return; }
|
|
1908
2071
|
if (message.type === "agent_setup" && Array.isArray(message.agents)) {
|
|
1909
2072
|
cloudSetup = new Map(message.agents.filter((entry) => entry && typeof entry.agent === "string" && SETUP_STATES.includes(entry.state))
|
|
1910
2073
|
.map((entry) => [entry.agent, { state: entry.state, how: typeof entry.how === "string" ? entry.how : null, detail: typeof entry.detail === "string" ? entry.detail : null }]));
|
|
@@ -1976,7 +2139,7 @@ export async function startDaemon() {
|
|
|
1976
2139
|
store.roomPost({ goalId: guestGoal, author: String(input.from || "guest"), kind: "guest", text: words.text.slice(0, 20_000) });
|
|
1977
2140
|
notifyBoard(store.getTask(guestGoal));
|
|
1978
2141
|
}
|
|
1979
|
-
if (notificationsOn()) notifyDesktop(`${input.from} replied on: ${String(task?.objective || "").slice(0, 80)}`);
|
|
2142
|
+
if (notificationsOn()) notifyDesktop(String(input.from || "").endsWith("/person") ? `${handleOf(input.from)} wrote to you: ${words.text.slice(0, 100)}` : `${input.from} replied on: ${String(task?.objective || "").slice(0, 80)}`);
|
|
1980
2143
|
}
|
|
1981
2144
|
})().catch((error) => send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }));
|
|
1982
2145
|
return;
|
|
@@ -1997,6 +2160,12 @@ export async function startDaemon() {
|
|
|
1997
2160
|
return;
|
|
1998
2161
|
}
|
|
1999
2162
|
if (message.type === "thread_ack") { store.markDelivered(String(message.messageId || "")); return; }
|
|
2163
|
+
// Someone asked to reach this person's swarm (network.mjs lists it): told on the desktop, answered in Swarms.
|
|
2164
|
+
if (message.type === "relay_requested") {
|
|
2165
|
+
const input = message.input || {};
|
|
2166
|
+
if (notificationsOn() && validAgent(input.from)) notifyDesktop(`${input.from} asked to reach ${input.to === "person" ? "you" : `your ${nameOf(String(input.to || ""))}`}. Answer it in Swarms.`);
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2000
2169
|
if (message.type === "bridge_response" && relayWaiters.has(message.requestId)) {
|
|
2001
2170
|
relayWaiters.get(message.requestId)(message.ok ? { ok: true, ...(message.result || {}) } : { ok: false, error: message.error || "Harness refused the request" });
|
|
2002
2171
|
relayWaiters.delete(message.requestId);
|
|
@@ -2089,12 +2258,15 @@ export async function startDaemon() {
|
|
|
2089
2258
|
}
|
|
2090
2259
|
}
|
|
2091
2260
|
setImmediate(() => void sync());
|
|
2261
|
+
setImmediate(() => void listModels());
|
|
2262
|
+
const relist = setInterval(() => void listModels(), 24 * 60 * 60_000);
|
|
2092
2263
|
|
|
2093
2264
|
const shutdown = () => {
|
|
2094
2265
|
clearInterval(heartbeat);
|
|
2095
2266
|
clearInterval(staleCheck);
|
|
2096
2267
|
clearInterval(embedTimer);
|
|
2097
2268
|
clearInterval(rescan);
|
|
2269
|
+
clearInterval(relist);
|
|
2098
2270
|
for (const watcher of watchers) watcher.close();
|
|
2099
2271
|
try { socket?.close(); } catch {}
|
|
2100
2272
|
try { opencodeProcess?.kill(); } catch {}
|