@staix/agent-hub 0.12.42 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +5 -4
- package/docs/agent-notes/adapters.md +3 -1
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +7 -2
- package/docs/agent-notes/tasks.md +1 -0
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +95 -21
- package/docs/quickstart.md +7 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +49 -9
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -27
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/pi.ts +46 -16
- package/src/cli/completion.ts +173 -0
- package/src/cli/console-state.ts +107 -20
- package/src/cli/console.ts +9 -6
- package/src/cli/help.ts +2 -1
- package/src/cli/identity.ts +2 -2
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +6 -0
- package/src/cli/terminal-recovery.ts +18 -9
- package/src/hub/daemon.ts +138 -33
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/settings.ts +1 -1
- package/src/hub/snapshots.ts +252 -17
- 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
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2919
|
-
const
|
|
2920
|
-
for (const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -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"]),
|
package/src/hub/main-checkout.ts
CHANGED
|
@@ -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 =
|
|
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/`
|
package/src/hub/manager.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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")}`);
|
package/src/hub/settings.ts
CHANGED
|
@@ -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
|
}
|