@staix/agent-hub 0.12.41 → 0.12.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -4
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +2 -0
  5. package/docs/agent-notes/daemon.md +7 -2
  6. package/docs/agent-notes/tasks.md +1 -0
  7. package/docs/agent-notes/tests.md +4 -2
  8. package/docs/operations.md +98 -24
  9. package/docs/quickstart.md +11 -3
  10. package/docs/sdlc.md +1 -1
  11. package/docs/security.md +3 -3
  12. package/docs/smoke.md +1 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +51 -9
  14. package/docs/verification/2026-10-20-0.12.42.md +48 -0
  15. package/docs/verification/2026-10-21-0.12.43.md +50 -0
  16. package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
  17. package/docs/verification/2026-10-21-pii-screen.md +14 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +79 -29
  22. package/src/adapters/agy/entry.ts +11 -1
  23. package/src/adapters/agy/hooks.ts +7 -2
  24. package/src/adapters/pi.ts +46 -16
  25. package/src/cli/completion.ts +173 -0
  26. package/src/cli/console-state.ts +107 -20
  27. package/src/cli/console.ts +9 -6
  28. package/src/cli/help.ts +3 -2
  29. package/src/cli/identity.ts +2 -2
  30. package/src/cli/main.js +5 -1
  31. package/src/cli/main.ts +6 -0
  32. package/src/cli/terminal-recovery.ts +18 -9
  33. package/src/hub/daemon.ts +138 -33
  34. package/src/hub/hub-tools.ts +1 -1
  35. package/src/hub/main-checkout.ts +2 -2
  36. package/src/hub/manager.ts +10 -2
  37. package/src/hub/settings.ts +1 -1
  38. package/src/hub/snapshots.ts +252 -17
  39. package/src/hub/tasks.ts +16 -2
package/src/hub/daemon.ts CHANGED
@@ -80,7 +80,7 @@ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, sta
80
80
  import { basename, isAbsolute } from "node:path";
81
81
  import { fileURLToPath } from "node:url";
82
82
  import { realPath, hubHome } from "./project.ts";
83
- import { mainCheckoutWarning } from "./main-checkout.ts";
83
+ import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
84
84
  import type { BusEvent, PublishResult } from "./bus.ts";
85
85
  import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
86
86
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
@@ -110,7 +110,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
110
110
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
111
111
  import type { JournalDelivery } from "./delivery-journal.ts";
112
112
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
113
- import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
113
+ import { changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
114
114
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
115
115
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
116
116
  import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
@@ -465,6 +465,10 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
465
465
  }
466
466
 
467
467
  export interface DaemonOptions {
468
+ /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
469
+ snapshot?: typeof snapshot;
470
+ snapshotBurstMs?: number;
471
+ checkoutBranch?: typeof mainCheckoutBranch;
468
472
  /** Host-only admission transport; never selected by project config or tools. */
469
473
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
470
474
  projectId?: string;
@@ -954,7 +958,7 @@ export async function startDaemon(opts: DaemonOptions) {
954
958
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
955
959
  // together. Cached until the board changes: it is asked per recipient of every message.
956
960
  let turnFreeNow: boolean | undefined;
957
- const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
961
+ const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; reflog?: HeadMoveCursor; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
958
962
  const turnFree = (): boolean => (config.coordination === "turn-free" || tasks.cohorts.list().some(cohort => cohort.silent)) && ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
959
963
  /**
960
964
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
@@ -1551,7 +1555,7 @@ export async function startDaemon(opts: DaemonOptions) {
1551
1555
  const worktreeFacts = new Map<string, Facts>();
1552
1556
  const worktreeProjections = new Map<string, TextProjections>();
1553
1557
  const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
1554
- const repoFor = (peer: string) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer));
1558
+ const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
1555
1559
  const projectionsFor = (peer: string): TextProjections => {
1556
1560
  const root = executionRoot(peer);
1557
1561
  if (root === opts.cwd) return projections;
@@ -1904,23 +1908,42 @@ export async function startDaemon(opts: DaemonOptions) {
1904
1908
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
1905
1909
  memoryObservations?.cite(peer, text, id, envs);
1906
1910
  };
1907
- /**
1908
- * A snapshot that fails costs the undo record of that turn and nothing else. The peer's prompt waits for it (it
1909
- * runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
1910
- * in snapshots.ts.
1911
- */
1912
- const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; ms: number } => {
1913
- const repo = peer ? repoFor(peer) : factsRepo;
1911
+ // Preserve real synchronous native boundaries, but never spend an unbounded sequence of Git waits
1912
+ // inside a single callback burst. Failed/slow captures are explicitly unknown, never stale baselines.
1913
+ let snapshotBudget: SnapshotBudget | undefined, snapshotBudgetNoticed = false;
1914
+ const reserveSnapshotBudget = (): SnapshotBudget => {
1915
+ if (!snapshotBudget) {
1916
+ const requested = opts.snapshotBurstMs ?? 250;
1917
+ const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
1918
+ snapshotBudget = { deadline: performance.now() + ms };
1919
+ setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
1920
+ }
1921
+ return snapshotBudget;
1922
+ };
1923
+ const snapshotFailure = (what: string, error: unknown) => {
1924
+ if (error instanceof SnapshotBudgetExceeded) {
1925
+ if (!snapshotBudgetNoticed) log(`snapshot unavailable: ${error.message}`);
1926
+ snapshotBudgetNoticed = true;
1927
+ } else log(`snapshot failed at ${what}: ${(error as Error).message}`);
1928
+ };
1929
+ const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
1914
1930
  if (!capture) return { ms: 0 };
1915
- const t0 = performance.now();
1916
- let tree: string | undefined;
1931
+ const t0 = performance.now(), budget = reserveSnapshotBudget();
1932
+ let tree: string | undefined, head: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
1917
1933
  try {
1918
- tree = repo && snapshot(repo);
1934
+ repo = peer ? repoFor(peer, budget) : factsRepo;
1935
+ const before = repo && headState(repo, budget);
1936
+ tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
1937
+ const after = repo && headState(repo, budget);
1938
+ head = after?.tree;
1939
+ if (!before || !after || before.tree !== after.tree) tree = undefined;
1940
+ if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
1919
1941
  if (repo && !tree) log(`snapshot failed at ${what}`);
1920
1942
  } catch (error) {
1921
- log(`snapshot failed at ${what}: ${(error as Error).message}`);
1943
+ tree = undefined; head = undefined; reflog = undefined;
1944
+ snapshotFailure(what, error);
1922
1945
  }
1923
- return { tree, ms: Math.round(performance.now() - t0) };
1946
+ return { tree, head, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
1924
1947
  };
1925
1948
  function privateSnapshotWindow(): boolean {
1926
1949
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
@@ -2294,7 +2317,7 @@ export async function startDaemon(opts: DaemonOptions) {
2294
2317
  if (t.state === "backlog") return line(t);
2295
2318
  const overlap = tasks.overlaps(t, t.owner === by);
2296
2319
  if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
2297
- return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2320
+ return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2298
2321
  }
2299
2322
  case "hub_task_accept": {
2300
2323
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
@@ -2733,6 +2756,14 @@ export async function startDaemon(opts: DaemonOptions) {
2733
2756
  for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
2734
2757
  return [...ids].filter(peer => membershipAllowed(peer) || reservations.includes(peer) || bus.queued(peer) > 0 || (() => { const summary = bus.queueSummary(peer); return summary.needsReview > 0 || !!summary.liveAccepted?.length; })() || open.some(task => task.owner === peer || task.reviewer === peer));
2735
2758
  };
2759
+ let checkoutInTick: { branch: ReturnType<typeof mainCheckoutBranch> } | undefined;
2760
+ const checkoutBranch = () => {
2761
+ if (!checkoutInTick) {
2762
+ checkoutInTick = { branch: (opts.checkoutBranch ?? mainCheckoutBranch)(opts.cwd) };
2763
+ queueMicrotask(() => { checkoutInTick = undefined; });
2764
+ }
2765
+ return checkoutInTick.branch;
2766
+ };
2736
2767
  const status = (includeAttentionItems = false, includeAttentionInput = false) => {
2737
2768
  const taskNeeds = board.statusTasks();
2738
2769
  const taskStalls = tasks.stalled();
@@ -2746,7 +2777,7 @@ export async function startDaemon(opts: DaemonOptions) {
2746
2777
  pluginBundle: SERVER_JS,
2747
2778
  pendingRestart,
2748
2779
  retainedConfig,
2749
- ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText); return warning ? { mainCheckout: warning } : {}; })(),
2780
+ ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
2750
2781
  startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
2751
2782
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2752
2783
  lifecycleStages: [...lifecycleConfig.stages],
@@ -2912,28 +2943,57 @@ export async function startDaemon(opts: DaemonOptions) {
2912
2943
  /** Work a conflict notice is about: open tasks and tasks in review (#91); a notice for anything else is dropped (#106). */
2913
2944
  const CONFLICT_STATES: Task["state"][] = ["proposed", "in_progress", "changes_requested", "in_review"];
2914
2945
  const detectConcurrentConflicts = (record: TurnRecord, mine: Task[]) => {
2946
+ const hits: { ours: Task; theirs: Task; paths: string[]; turn: TurnRecord }[] = [];
2915
2947
  for (const otherTurn of turnLog!.concurrentWith(record)) {
2916
2948
  const otherTasks = (turnTasks.get(otherTurn.id) ?? []).flatMap((id) => { const task = board.get(id); return task ? [task] : []; });
2917
2949
  if (otherTasks.some(tasks.isPii)) continue;
2918
- const shared = record.changed.filter((p) => otherTurn.changed.includes(p));
2919
- const pairs = mine.flatMap((ours) => otherTasks.map((theirs) => ({ ours, theirs })));
2920
- for (const { ours, theirs } of pairs) {
2950
+ const theirs = new Set(otherTurn.changed);
2951
+ const shared = record.changed.filter(path => theirs.has(path));
2952
+ for (const ours of mine) for (const theirs of otherTasks) {
2921
2953
  if (ours.id === theirs.id) continue;
2922
2954
  const pair = [ours.id, theirs.id].sort((a, b) => a - b).join(":");
2923
- const paths = shared.filter((p) => !conflictSeen.has(`concurrent:${pair}:${p}`));
2955
+ const paths = shared.filter(path => !conflictSeen.has(`concurrent:${pair}:${path}`));
2924
2956
  if (!paths.length) continue;
2925
2957
  for (const path of paths) conflictSeen.add(`concurrent:${pair}:${path}`);
2926
- const named = paths.filter(tasks.nameable);
2927
- const files = [...named, ...(paths.length > named.length ? [`${paths.length - named.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
2928
- const together = tasks.silentFor(ours.id) && tasks.silentFor(theirs.id) ? "Check the working tree before continuing; do not message the other owner (you are in one turn-free cohort: the hub shows you its changes)." : "Check the working tree together before continuing.";
2929
- const text = `Concurrent edit: ${record.id} (task #${ours.id}) and ${otherTurn.id} (task #${theirs.id}) both include changes to ${files}. These snapshots do not attribute the changes to either peer. ${together}`;
2930
- notify(`conflict: ${text}`);
2931
- event({ type: "conflict", peer: record.peer, task: ours.id, other: theirs.id, owner: otherTurn.peer, paths: named, concurrent: true, turns: [record.id, otherTurn.id] });
2932
- recordConflictLink(ours.id, theirs.id, [record.peer, otherTurn.peer]);
2933
- // Each owner hears it while its own task is open; once that task is closed the notice is dropped (issue #106).
2934
- for (const [owner, task] of [[record.peer, ours.id], [otherTurn.peer, theirs.id]] as const) if (owner !== USER && owner !== HUB) tasks.whileOpen(owner, task, text, CONFLICT_STATES);
2958
+ hits.push({ ours, theirs, paths, turn: otherTurn });
2935
2959
  }
2936
2960
  }
2961
+ if (!hits.length) return;
2962
+ const paths = [...new Set(hits.flatMap(hit => hit.paths))];
2963
+ const named = paths.filter(tasks.nameable), visible = new Set(named);
2964
+ const files = (shared: string[]) => {
2965
+ const shown = shared.filter(path => visible.has(path));
2966
+ return [...shown, ...(shared.length > shown.length ? [`${shared.length - shown.length} file(s) whose names are withheld (they match a PII pattern)`] : [])].join(", ");
2967
+ };
2968
+ const affected = new Map<string, Map<number, Task>>();
2969
+ const add = (owner: string, task: Task) => {
2970
+ if (!affected.has(owner)) affected.set(owner, new Map());
2971
+ affected.get(owner)!.set(task.id, task);
2972
+ };
2973
+ for (const hit of hits) { add(record.peer, hit.ours); add(hit.turn.peer, hit.theirs); }
2974
+ const labels = (rows: Task[]) => `${rows.length === 1 ? "task" : "tasks"} ${rows.map(task => `#${task.id}`).join(", ")}`;
2975
+ const descriptions = [`${record.id} (${labels([...affected.get(record.peer)!.values()])}): ${files(paths)}`];
2976
+ const byTurn = new Map<string, { tasks: Map<number, Task>; paths: Set<string> }>();
2977
+ for (const hit of hits) {
2978
+ if (!byTurn.has(hit.turn.id)) byTurn.set(hit.turn.id, { tasks: new Map(), paths: new Set() });
2979
+ const group = byTurn.get(hit.turn.id)!;
2980
+ group.tasks.set(hit.theirs.id, hit.theirs);
2981
+ for (const path of hit.paths) group.paths.add(path);
2982
+ }
2983
+ for (const [id, group] of byTurn) descriptions.push(`${id} (${labels([...group.tasks.values()])}): ${files([...group.paths])}`);
2984
+ const rows = [...affected.values()].flatMap(group => [...group.values()]);
2985
+ const together = rows.every(task => tasks.silentFor(task.id)) ? "Check the working tree before continuing; do not message the other owner (you are in one turn-free cohort: the hub shows you its changes)." : rows.some(task => tasks.silentFor(task.id)) ? "Check the working tree before continuing. Do not message another owner for tasks in the same turn-free cohort; the hub shows their changes. Check other work together." : "Check the working tree together before continuing.";
2986
+ const text = `Concurrent edit: shared changes by turn: ${descriptions.join("; ")}. These snapshots do not attribute the changes to any peer. ${together}`;
2987
+ notify(`conflict: ${text}`);
2988
+ // Research retains task-pair evidence. Durable notices are computed once across all overlapping turns,
2989
+ // and each affected task gets only one owner-fenced notice for this change set.
2990
+ for (const hit of hits) {
2991
+ event({ type: "conflict", peer: record.peer, task: hit.ours.id, other: hit.theirs.id, owner: hit.turn.peer, paths: hit.paths.filter(path => visible.has(path)), concurrent: true, turns: [record.id, hit.turn.id] });
2992
+ recordConflictLink(hit.ours.id, hit.theirs.id, [record.peer, hit.turn.peer]);
2993
+ }
2994
+ for (const [owner, group] of affected) {
2995
+ if (owner !== USER && owner !== HUB) for (const task of group.values()) tasks.whileOpen(owner, task.id, text, CONFLICT_STATES);
2996
+ }
2937
2997
  };
2938
2998
  const detectConflicts = (peer: PeerId, turnId: string, since: number, changed: string[]) => {
2939
2999
  const open = board.list().filter((t) => t.owner && CONFLICT_STATES.includes(t.state));
@@ -3102,7 +3162,7 @@ export async function startDaemon(opts: DaemonOptions) {
3102
3162
  const start = capture && !pii ? snap(`the start of ${id}`, config.snapshots.enabled, e.peer) : undefined; // before the peer is handed anything: the tap runs inside setState
3103
3163
  try { if (capture) turnLog?.begin(id, e.peer, start?.tree, declarations[e.peer] ? executionRoot(e.peer) : undefined); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
3104
3164
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
3105
- turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3165
+ turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3106
3166
  event({ type: "turn_start", peer: e.peer, turn: id });
3107
3167
  } else if (!busy && open) {
3108
3168
  turns.delete(e.peer);
@@ -3113,7 +3173,48 @@ export async function startDaemon(opts: DaemonOptions) {
3113
3173
  try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
3114
3174
  } else if (open.capture && turnLog) {
3115
3175
  const end = snap(`the end of ${open.id}`, true, e.peer);
3116
- const changed = open.tree && end.tree ? changedPaths(repoFor(e.peer)!, open.tree, end.tree) : [];
3176
+ let changed: string[] = [];
3177
+ if (open.tree && end.tree) {
3178
+ const turnRepo = end.repo!;
3179
+ try {
3180
+ const observed = knownChangedPaths(turnRepo, open.tree, end.tree, end.budget);
3181
+ if (!observed) throw new Error("turn diff evidence is unavailable");
3182
+ changed = observed;
3183
+ // HEAD movement alone does not identify incoming work: commits/amends/rebases/cherry-picks
3184
+ // are normal peer edits. Only a bounded, append-fenced reflog classifies an incoming move.
3185
+ if (open.head !== end.head || open.reflog || end.reflog) {
3186
+ const paths = open.reflog && end.reflog ? incomingHeadPaths(turnRepo, open.reflog, end.reflog, end.budget) : undefined;
3187
+ if (!paths) throw new Error("HEAD move evidence is ambiguous or unavailable");
3188
+ if (paths.length) {
3189
+ if (!end.head) throw new Error("end HEAD evidence is unavailable");
3190
+ const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
3191
+ if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
3192
+ const checkBudget = () => { if (end.budget && performance.now() >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
3193
+ const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
3194
+ for (const path of dirtyPaths) {
3195
+ checkBudget();
3196
+ for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
3197
+ checkBudget(); dirtyScope.add(path.slice(0, at));
3198
+ }
3199
+ }
3200
+ // Autostash and checkout -m can carry uncommitted edits across an incoming move.
3201
+ // Preserve every still-dirty path (including file/directory overlaps), never subtract it.
3202
+ changed = changed.filter(path => {
3203
+ checkBudget();
3204
+ if (!incoming.has(path) || dirtyScope.has(path)) return true;
3205
+ for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
3206
+ checkBudget(); if (dirty.has(path.slice(0, at))) return true;
3207
+ }
3208
+ return false;
3209
+ });
3210
+ checkBudget();
3211
+ }
3212
+ }
3213
+ } catch (error) {
3214
+ end.tree = undefined; end.head = undefined; changed = [];
3215
+ snapshotFailure(`the diff of ${open.id}`, error);
3216
+ }
3217
+ }
3117
3218
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
3118
3219
  snapshotMs = (snapshotMs ?? 0) + end.ms;
3119
3220
  try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
@@ -5356,6 +5457,10 @@ export async function startDaemon(opts: DaemonOptions) {
5356
5457
  return;
5357
5458
  }
5358
5459
  const offered = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
5460
+ // The decoded native read (agy view_file or read_file, as `Read`) is a peer fact: its target is observed before it runs and the
5461
+ // view moves after it. Nothing is offered here: agy's hook encodes an offer only on SessionStart/PreInvocation.
5462
+ if (factsOn() && native.event === "PreToolUse") factsFor(peer).preTool(peer, native.toolUseId, native.tool, native.input);
5463
+ if (factsOn() && native.event === "PostToolUse") factsFor(peer).postTool(peer, native.toolUseId, native.tool, native.input);
5359
5464
  return void reply({ ok: true, ...(offered ? { text: offered.text } : {}) });
5360
5465
  };
5361
5466
  if (owner.claiming) {
@@ -97,7 +97,7 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
97
97
  export const CONDUCTOR_TOOLS: HubTool[] = [
98
98
  tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
99
99
  tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
100
- tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named or when they claim it. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
100
+ tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named while attached or when they claim it; a peer that cannot take the task is refused with the reason, and the task is unchanged. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
101
101
  tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
102
102
  tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: CONDUCTOR_START_PEERS } }, ["peer"]),
103
103
  tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
@@ -27,8 +27,8 @@ export function mainCheckoutBranch(cwd: string): { branch: string; defaultBranch
27
27
  * whole public-text screen (`Tasks.publicMetadataText`: the configured PII patterns, secret shapes and private paths). A name it refuses, or any text
28
28
  * that is not a plain ref name, is replaced by a generic sentence that carries no part of it (#572 review I2).
29
29
  */
30
- export function mainCheckoutWarning(cwd: string, nameable: (text: string) => boolean = () => false): string | undefined {
31
- const found = mainCheckoutBranch(cwd);
30
+ export function mainCheckoutWarning(cwd: string, nameable: (text: string) => boolean = () => false, read: typeof mainCheckoutBranch = mainCheckoutBranch): string | undefined {
31
+ const found = read(cwd);
32
32
  if (!found) return undefined;
33
33
  const plain = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,99}$/.test(found.branch) && nameable(found.branch);
34
34
  return plain ? `the main checkout is on \`${found.branch}\`; move the work to a worktree under .worktrees/`
@@ -241,8 +241,9 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
241
241
  const f = files(home);
242
242
  mkdirSync(f.dir, { recursive: true, mode: 0o700 });
243
243
  const log = openSync(join(f.dir, "manager.log"), "a");
244
+ let proc: ReturnType<typeof spawn>;
244
245
  try {
245
- const proc = spawn(process.execPath, [options.cli ?? join(import.meta.dir, "../cli/main.ts"), "manager"], {
246
+ proc = spawn(process.execPath, [options.cli ?? join(import.meta.dir, "../cli/main.ts"), "manager"], {
246
247
  cwd: home, detached: true, stdio: ["ignore", log, log], env: { ...scrubEnv(process.env), AGENTHUB_HOME: home },
247
248
  });
248
249
  proc.on("error", () => {}); proc.unref();
@@ -250,7 +251,14 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
250
251
  const deadline = Date.now() + 8000;
251
252
  let error: unknown;
252
253
  while (Date.now() < deadline) {
253
- try { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
254
+ try {
255
+ const answered = await managerRequest(home, "/open", options.liveTest);
256
+ // #584: concurrent opens each spawn a manager and one wins the owner claim. A spawn that lost, or is still booting when
257
+ // another manager answers, would claim the owner row once that manager is stopped and then run with nobody to stop it.
258
+ // Only the manager that answered is wanted; stop the process this open started if it is a different one.
259
+ if (answered.status.pid !== proc.pid) proc.kill("SIGTERM");
260
+ return answered.body.url;
261
+ }
254
262
  catch (caught) { error = caught; await Bun.sleep(100); }
255
263
  }
256
264
  throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
@@ -87,7 +87,7 @@ export function projectSettings(instances?: KindMap): readonly SettingDef[] {
87
87
  path: Object.freeze(base.path.map(part => part === adapter.kind ? peer : part)),
88
88
  ...(base.values ? { values: Object.freeze([...base.values]) } : {}),
89
89
  });
90
- for (const prefix of ["roster", "permission", "permission_modes"]) {
90
+ for (const prefix of ["roster", "permission", "permission_modes", "tasks.wip_limits"]) {
91
91
  const base = SETTINGS.find(def => def.key === `${prefix}.${adapter.kind}`);
92
92
  if (base) extra.push(copy(base, `${prefix}.${peer}`));
93
93
  }