@staix/agent-hub 0.12.23 → 0.12.25
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 +109 -1
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +10 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +8 -0
- package/docs/agent-notes/tests.md +5 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +666 -19
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +2 -2
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +288 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +171 -23
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +914 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +115 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +905 -89
- package/src/hub/text-projection.ts +151 -0
- package/src/hub/tooling.ts +170 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +757 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/extension.ts +40 -5
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +843 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/hub/bus.ts
CHANGED
|
@@ -10,9 +10,12 @@ export type BusEvent =
|
|
|
10
10
|
| { t: "undeliverable"; env: Envelope; peer: PeerId; reason?: string }
|
|
11
11
|
| { t: "stale"; env: Envelope; peer: PeerId; reason: string }
|
|
12
12
|
| { t: "quiet"; env: Envelope; peers: PeerId[]; reason: string }
|
|
13
|
+
| { t: "off_roster"; env: Envelope; peers: PeerId[] }
|
|
13
14
|
| { t: "state"; peer: PeerId; state: PeerState };
|
|
14
15
|
|
|
15
16
|
export interface BusOptions {
|
|
17
|
+
/** Live project membership; absent preserves the existing audience and delivery behaviour. */
|
|
18
|
+
rostered?: (peer: PeerId) => boolean;
|
|
16
19
|
/** Transient owner lifecycle fence: retain new work without changing console/budget pauses. */
|
|
17
20
|
deliveryHeld?: (peer: PeerId) => boolean;
|
|
18
21
|
/** Observe original envelopes immediately before a transport starts its turn. */
|
|
@@ -155,6 +158,7 @@ export class Bus {
|
|
|
155
158
|
if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
|
|
156
159
|
peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
|
|
157
160
|
peer.onMessage = (text, opts) => {
|
|
161
|
+
if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
|
|
158
162
|
const { priority, body } = parseMarker(text);
|
|
159
163
|
if (!body) return;
|
|
160
164
|
// What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
|
|
@@ -167,15 +171,22 @@ export class Bus {
|
|
|
167
171
|
const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
|
|
168
172
|
const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
|
|
169
173
|
let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
|
|
174
|
+
const offRoster = this.offRosterRecipients(env);
|
|
175
|
+
if (offRoster.length && offRoster.length === new Set(env.to?.filter(id => id !== env.from)).size) {
|
|
176
|
+
const reason = `off roster: ${offRoster.join(", ")}`;
|
|
177
|
+
this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${reason}`));
|
|
178
|
+
return reason; // no publication and no limiter spend for an entirely refused audience
|
|
179
|
+
}
|
|
180
|
+
const admittedEnv = offRoster.length ? { ...env, to: env.to!.filter(id => !offRoster.includes(id)) } : env;
|
|
170
181
|
// Limits count what is sent: the envelope as built (a reply goes to its parent's sender, `digest` is resolved,
|
|
171
182
|
// the priority is capped), never the raw `to`.
|
|
172
183
|
const parent = opts?.inReplyTo?.id;
|
|
173
184
|
// Held back from every recipient (issue #107): nothing goes out, so nothing counts against the sender's limits.
|
|
174
185
|
const hushed = this.hushed(env);
|
|
175
186
|
const heldBack = hushed.length > 0 && hushed.length === this.audience(env).length;
|
|
176
|
-
let refused = heldBack ? undefined : this.opts.admit?.(
|
|
187
|
+
let refused = heldBack ? undefined : this.opts.admit?.(admittedEnv, parent);
|
|
177
188
|
// A turn answer has no caller to refuse: over its important budget it goes out as status, not at all.
|
|
178
|
-
if (refused && env.priority === "important" && !this.opts.admit?.({ ...
|
|
189
|
+
if (refused && env.priority === "important" && !this.opts.admit?.({ ...admittedEnv, priority: "status" }, parent)) {
|
|
179
190
|
// It went out, so the advice on how to send it does not apply.
|
|
180
191
|
this.note(peer.id, noteLine(HUB, "decision", `your [IMPORTANT] message went out as status: ${refused.replace(/[;:] (retry after \d+ s, or )?send it without \[IMPORTANT\]$/, "")}`));
|
|
181
192
|
env = { ...env, priority: "status" };
|
|
@@ -186,6 +197,7 @@ export class Bus {
|
|
|
186
197
|
return refused;
|
|
187
198
|
}
|
|
188
199
|
this.publish(env);
|
|
200
|
+
if (offRoster.length) this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
|
|
189
201
|
// The sender's result (issue #107): a turn answer has no caller, so it hears it on its next delivery. It counts as
|
|
190
202
|
// not sent only when nobody got it.
|
|
191
203
|
if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
|
|
@@ -466,6 +478,17 @@ export class Bus {
|
|
|
466
478
|
return (this.queues.get(id) ?? []).map((e) => e.id);
|
|
467
479
|
}
|
|
468
480
|
|
|
481
|
+
queuedBacklogIds(id: PeerId): Set<number> {
|
|
482
|
+
const ids = new Set<number>();
|
|
483
|
+
const retained = [...(this.queues.get(id) ?? []), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
|
|
484
|
+
for (const env of retained) {
|
|
485
|
+
const backlog = env.refs?.backlog;
|
|
486
|
+
if (env.from !== HUB || env.kind !== "task" || !Array.isArray(backlog) || backlog.length > 64) continue;
|
|
487
|
+
for (const task of backlog) if (Number.isSafeInteger(task) && task > 0) ids.add(task);
|
|
488
|
+
}
|
|
489
|
+
return ids;
|
|
490
|
+
}
|
|
491
|
+
|
|
469
492
|
attemptState(): Record<string, number> {
|
|
470
493
|
return Object.fromEntries(this.attempts);
|
|
471
494
|
}
|
|
@@ -541,12 +564,12 @@ export class Bus {
|
|
|
541
564
|
|
|
542
565
|
const hushed = this.hushed(env);
|
|
543
566
|
if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
|
|
544
|
-
const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id));
|
|
567
|
+
const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id) || !this.isRostered(id));
|
|
545
568
|
this.suppressDrain = !!this.journal;
|
|
546
569
|
try { for (const id of targets) {
|
|
547
570
|
const peer = this.peers.get(id);
|
|
548
571
|
if (!peer) { this.enqueue(id, env); continue; }
|
|
549
|
-
if (!this.journal && !this.
|
|
572
|
+
if (!this.journal && !this.held(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
|
|
550
573
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
551
574
|
this.steering++;
|
|
552
575
|
const deliveryId = crypto.randomUUID();
|
|
@@ -566,23 +589,39 @@ export class Bus {
|
|
|
566
589
|
this.onQueues?.();
|
|
567
590
|
for (const id of targets) {
|
|
568
591
|
const peer = this.peers.get(id);
|
|
569
|
-
if (!this.
|
|
592
|
+
if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
570
593
|
else void this.drain(id);
|
|
571
594
|
}
|
|
572
595
|
}
|
|
596
|
+
const offRoster = targets.filter(id => !this.isRostered(id));
|
|
597
|
+
if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
|
|
573
598
|
return targets;
|
|
574
599
|
}
|
|
575
600
|
|
|
601
|
+
isRostered(peer: PeerId): boolean { return this.opts.rostered?.(peer) ?? true; }
|
|
602
|
+
|
|
603
|
+
/** Explicit off-roster recipients, including unattached retained targets; broadcasts have none. */
|
|
604
|
+
offRosterRecipients(env: Envelope): PeerId[] {
|
|
605
|
+
return [...new Set(env.to ?? [])].filter(peer => peer !== env.from && !this.isRostered(peer));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Membership changed externally: reconsider delivery, keeping every independent hold intact. */
|
|
609
|
+
rosterChanged(peer: PeerId): void {
|
|
610
|
+
if (this.closed) return;
|
|
611
|
+
this.onQueues?.();
|
|
612
|
+
void this.drain(peer);
|
|
613
|
+
}
|
|
614
|
+
|
|
576
615
|
/** Who an envelope is queued for: its `to`, or every peer but its sender. */
|
|
577
616
|
audience(env: Envelope): PeerId[] {
|
|
578
617
|
const known = new Set([...this.peers.keys(), ...this.queues.keys(), ...(this.journal?.list().map((d) => d.peer) ?? [])]);
|
|
579
|
-
return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && known.has(id));
|
|
618
|
+
return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && (known.has(id) || !this.isRostered(id)) && (env.to !== undefined || this.isRostered(id)));
|
|
580
619
|
}
|
|
581
620
|
|
|
582
621
|
/** The recipients of `env` that the silence policy holds it back from, each with why (issue #107). */
|
|
583
622
|
hushed(env: Envelope): { peer: PeerId; reason: string }[] {
|
|
584
623
|
if (!this.opts.silence || env.hop > MAX_HOP || env.priority === "fyi") return [];
|
|
585
|
-
return this.audience(env).flatMap((peer) => {
|
|
624
|
+
return this.audience(env).filter(peer => this.isRostered(peer)).flatMap((peer) => {
|
|
586
625
|
const reason = this.opts.silence!(env, peer);
|
|
587
626
|
return reason ? [{ peer, reason }] : [];
|
|
588
627
|
});
|
|
@@ -728,9 +767,9 @@ export class Bus {
|
|
|
728
767
|
finally { this.steering--; this.onQueues?.(); }
|
|
729
768
|
}
|
|
730
769
|
|
|
731
|
-
/** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
770
|
+
/** Holds on a peer's queue, for a drain and a pull alike: off roster, recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
732
771
|
private held(id: PeerId): boolean {
|
|
733
|
-
return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
|
|
772
|
+
return !this.isRostered(id) || this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
|
|
734
773
|
}
|
|
735
774
|
|
|
736
775
|
/** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Bounded Markdown [@key] and BibTeX entry-key example check, no fetching or model verdict. */
|
|
2
|
+
export function citationKeys(markdown: string, bibliography: string): { cited: string[]; missing: string[] } {
|
|
3
|
+
if (Buffer.byteLength(markdown) > 256 * 1024 || Buffer.byteLength(bibliography) > 4 * 1024 * 1024) throw new Error("citation inputs exceed example check limits");
|
|
4
|
+
const known = new Set([...bibliography.matchAll(/@[a-zA-Z]+\s*\{\s*([^,\s{}]+)\s*,/g)].map(m => m[1]!));
|
|
5
|
+
const cited = [...new Set([...markdown.matchAll(/\[@[A-Za-z0-9_:./-]+(?:\s*;\s*@[A-Za-z0-9_:./-]+)*\]/g)].flatMap(m => [...m[0].matchAll(/@([A-Za-z0-9_:./-]+)/g)].map(k => k[1]!)))].sort();
|
|
6
|
+
return { cited, missing: cited.filter(key => !known.has(key)) };
|
|
7
|
+
}
|
package/src/hub/conductor.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import type { Task } from "./board.ts";
|
|
3
3
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
4
4
|
import { HUB, USER } from "./envelope.ts";
|
|
5
|
-
import { OWNERSHIP_EVENTS } from "./tasks.ts";
|
|
5
|
+
import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
|
|
6
6
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
7
7
|
import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
|
|
8
8
|
import type { HubEvent } from "./events.ts";
|
|
@@ -111,7 +111,7 @@ export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer:
|
|
|
111
111
|
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
|
|
112
112
|
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
113
113
|
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
114
|
-
if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local may be started by the conductor");
|
|
114
|
+
if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
|
|
115
115
|
const own = startMode(peer);
|
|
116
116
|
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
117
117
|
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
|
|
@@ -156,7 +156,7 @@ export interface ConductorHooks {
|
|
|
156
156
|
task(id: number): Task | undefined;
|
|
157
157
|
publicView(task: Task): Record<string, unknown>;
|
|
158
158
|
/** These callbacks must call Tasks, never Board.update. */
|
|
159
|
-
assign(actor: string, id: number, peer: string): Promise<unknown>;
|
|
159
|
+
assign(actor: string, id: number, peer: string, role?: TaskAssignmentRole): Promise<unknown>;
|
|
160
160
|
escalate(actor: string, id: number): Promise<unknown>;
|
|
161
161
|
preview(peer: "claude" | "codex" | "pi"): string;
|
|
162
162
|
/** The mode a start of this peer takes: a person's setting. */
|
|
@@ -182,18 +182,20 @@ export class Conductor {
|
|
|
182
182
|
constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
|
|
183
183
|
async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
184
184
|
if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new PreEffectToolRefusal("unknown conductor tool");
|
|
185
|
+
const role = args.role ?? "owner";
|
|
186
|
+
if (tool === "hub_task_assign" && role !== "owner" && role !== "reviewer") throw new PreEffectToolRefusal("role must be owner or reviewer");
|
|
185
187
|
if ((tool === "hub_task_show" || tool === "hub_task_assign") && conductorPeer(this.hooks.roles()) !== actor) {
|
|
186
188
|
const id = taskId(args.id);
|
|
187
189
|
const task = id === undefined ? undefined : this.hooks.task(id);
|
|
188
190
|
// A task's current owner and reviewer read its public view without the role (#208); nobody else does.
|
|
189
|
-
if (task && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
191
|
+
if (task && task.state !== "backlog" && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
190
192
|
// Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
|
|
191
193
|
// worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
|
|
192
194
|
// reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
|
|
193
195
|
// moves are not decisions).
|
|
194
|
-
const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved"));
|
|
196
|
+
const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved" || h.planning?.fields.includes("reserved")));
|
|
195
197
|
const accepted = task?.history.some((h) => h.event === "accepted");
|
|
196
|
-
if (task && tool === "hub_task_assign" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
|
|
198
|
+
if (task && tool === "hub_task_assign" && role === "owner" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
|
|
197
199
|
const peer = peerId(args.peer, true);
|
|
198
200
|
if (peer !== actor) requireAssign(actor, this.hooks.capabilities(), true);
|
|
199
201
|
await this.hooks.assign(actor, task.id, peer);
|
|
@@ -212,10 +214,13 @@ export class Conductor {
|
|
|
212
214
|
if (id === undefined) throw new PreEffectToolRefusal("id must be a positive integer");
|
|
213
215
|
const task = this.hooks.task(id);
|
|
214
216
|
if (!task) throw new PreEffectToolRefusal(`no task #${args.id}`);
|
|
217
|
+
if (task.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools, not conductor task tools");
|
|
215
218
|
if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
|
|
216
219
|
if (tool === "hub_task_assign") {
|
|
217
220
|
const peer = peerId(args.peer, true);
|
|
218
|
-
await this.hooks.assign(actor, task.id, peer
|
|
221
|
+
if (role === "reviewer") await this.hooks.assign(actor, task.id, peer, role);
|
|
222
|
+
else await this.hooks.assign(actor, task.id, peer);
|
|
223
|
+
emit({ task: task.id, peer });
|
|
219
224
|
} else { await this.hooks.escalate(actor, task.id); emit({ task: task.id }); }
|
|
220
225
|
// Task callbacks may return raw objects; always re-read and apply the public view.
|
|
221
226
|
const updated = this.hooks.task(task.id);
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const MACHINE_LOCAL = [
|
|
|
28
28
|
"local.network_allow", // the hosts the egress proxy opens (#65)
|
|
29
29
|
"terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
|
|
30
30
|
] as const;
|
|
31
|
+
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* Why `.agenthub/<name>` must not set machine-local fields, or undefined when git confirms nobody committed it.
|
|
@@ -84,8 +86,8 @@ const drop = (file: Record<string, unknown>, path: string) => {
|
|
|
84
86
|
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
85
87
|
*/
|
|
86
88
|
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
87
|
-
for (const p of
|
|
88
|
-
const set =
|
|
89
|
+
for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
90
|
+
const set = ALL_MACHINE_LOCAL.filter((p) => {
|
|
89
91
|
const v = valueAt(file, p);
|
|
90
92
|
return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
|
|
91
93
|
});
|
package/src/hub/crash.ts
CHANGED
|
@@ -47,6 +47,8 @@ export function removeSessions(stateDir: string, instanceId?: string): void {
|
|
|
47
47
|
if (instanceId === undefined || readSessions(stateDir)?.instanceId === instanceId) rmSync(fileOf(stateDir), { force: true });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export const qwenSessionId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value);
|
|
51
|
+
|
|
50
52
|
const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -62,6 +64,13 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
62
64
|
return { peer, how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
|
|
63
65
|
case "codex":
|
|
64
66
|
return { peer, how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
|
|
67
|
+
case "jcode":
|
|
68
|
+
return { peer, how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
|
|
69
|
+
case "qwen": {
|
|
70
|
+
const sessionId = meta.sessionId;
|
|
71
|
+
if (!qwenSessionId(sessionId)) return { peer, how: "qwen: saved ACP session identity unavailable; start it deliberately with ahub qwen" };
|
|
72
|
+
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: "qwen: the recorded conversation can be loaded again (ACP session/load)" };
|
|
73
|
+
}
|
|
65
74
|
case "kimi": {
|
|
66
75
|
const sessionId = str(meta.sessionId);
|
|
67
76
|
if (!sessionId) return { peer, how: "kimi: no session id was recorded; start it again with ahub kimi" };
|