@staix/agent-hub 0.12.39 → 0.12.40
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 +26 -0
- package/README.md +15 -10
- package/docs/agent-notes/adapters.md +4 -1
- package/docs/agent-notes/budget.md +2 -0
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +10 -6
- package/docs/agent-notes/models.md +4 -4
- package/docs/agent-notes/tasks.md +3 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +152 -28
- package/docs/quickstart.md +19 -5
- package/docs/security.md +19 -7
- package/docs/smoke.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
- package/docs/verification/2026-10-20-0.12.40.md +63 -0
- package/docs/verified.json +38 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +413 -147
- package/src/adapters/acp.ts +34 -1
- package/src/adapters/claude-channel.ts +35 -4
- package/src/adapters/codex-appserver.ts +12 -3
- package/src/adapters/definitions/builtin-metadata.ts +2 -0
- package/src/adapters/definitions/native-cli-doctor.ts +8 -5
- package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
- package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
- package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
- package/src/adapters/jcode.ts +110 -4
- package/src/adapters/local-worker.ts +21 -37
- package/src/adapters/pi.ts +7 -0
- package/src/cli/console-state.ts +1 -0
- package/src/cli/console.ts +11 -1
- package/src/cli/help.ts +13 -8
- package/src/cli/identity-audit.ts +33 -7
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +134 -24
- package/src/cli/output.ts +5 -2
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +54 -4
- package/src/cli/upgrade-interactive.ts +5 -0
- package/src/cli/upgrade-runtime.ts +13 -1
- package/src/cli/upgrade.ts +67 -1
- package/src/hub/board.ts +4 -2
- package/src/hub/bus.ts +8 -3
- package/src/hub/checks.ts +15 -5
- package/src/hub/conductor.ts +2 -1
- package/src/hub/config-apply.ts +20 -2
- package/src/hub/config-reload.ts +2 -8
- package/src/hub/control-client.ts +26 -3
- package/src/hub/daemon.ts +155 -68
- package/src/hub/delivery-superseded.ts +19 -2
- package/src/hub/events.ts +4 -1
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +4 -8
- package/src/hub/lifecycle.ts +3 -3
- package/src/hub/main-checkout.ts +36 -0
- package/src/hub/memory-profile-check.ts +6 -2
- package/src/hub/memory-profile.ts +18 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +1 -0
- package/src/hub/routing.ts +24 -14
- package/src/hub/settings.ts +1 -1
- package/src/hub/task-repository.ts +136 -0
- package/src/hub/tasks.ts +101 -21
- package/src/hub/terminal-launch.ts +130 -0
- package/src/hub/usage.ts +44 -18
- package/src/models/route/config.ts +1 -1
- package/src/omniroute/client.ts +7 -19
- package/templates/AGENTS.block.md +1 -0
- package/templates/routing.toml +10 -44
- package/src/switchyard/config.ts +0 -45
- package/src/switchyard/sidecar.ts +0 -136
package/src/hub/tasks.ts
CHANGED
|
@@ -27,6 +27,8 @@ import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type A
|
|
|
27
27
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
28
28
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
29
29
|
import { realPath } from "./project.ts";
|
|
30
|
+
import { TaskRepositories } from "./task-repository.ts";
|
|
31
|
+
import { mainCheckoutWarning } from "./main-checkout.ts";
|
|
30
32
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
31
33
|
import { coverageGap, renderReviewBrief, type ReviewBrief } from "./review-brief.ts";
|
|
32
34
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, definitionOf, familyOf } from "../adapters/registry.ts";
|
|
@@ -228,6 +230,16 @@ function cleanRefs(input: unknown): TaskRefs {
|
|
|
228
230
|
return out;
|
|
229
231
|
}
|
|
230
232
|
|
|
233
|
+
/** Acceptance may correct repository metadata, never file intent or work evidence. */
|
|
234
|
+
function acceptRefs(input: unknown): TaskRefs | undefined {
|
|
235
|
+
if (input == null) return undefined;
|
|
236
|
+
if (typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(key => key !== "repo")) throw new PreEffectToolRefusal("accept refs may contain only repo");
|
|
237
|
+
const raw = input as Record<string, unknown>;
|
|
238
|
+
if (!Object.hasOwn(raw, "repo")) return undefined;
|
|
239
|
+
if (typeof raw.repo !== "string" || !raw.repo.trim() || raw.repo.length > 300) throw new PreEffectToolRefusal("accept refs.repo must be a bounded repository slug or path");
|
|
240
|
+
return { repo: text(raw.repo)! };
|
|
241
|
+
}
|
|
242
|
+
|
|
231
243
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
232
244
|
function cleanPlan(input: unknown, profile: ProfileSelection = "dev"): TaskPlan {
|
|
233
245
|
const r = fields(input);
|
|
@@ -248,6 +260,7 @@ const RESCREEN_MAX = 10;
|
|
|
248
260
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
249
261
|
*/
|
|
250
262
|
export class Tasks {
|
|
263
|
+
private readonly repositories: TaskRepositories;
|
|
251
264
|
private readonly incomingBacklog = new Set<number>();
|
|
252
265
|
private readonly plannerBacklog = new Map<PeerId, Set<number>>();
|
|
253
266
|
private readonly plannerBlockedQueue = new Map<PeerId, string>();
|
|
@@ -259,6 +272,8 @@ export class Tasks {
|
|
|
259
272
|
private sweeping = false;
|
|
260
273
|
|
|
261
274
|
constructor(private readonly d: TasksDeps) {
|
|
275
|
+
this.repositories = new TaskRepositories(d.cwd, d.repository);
|
|
276
|
+
for (const task of d.board.list()) this.warmRepository(task);
|
|
262
277
|
if (d.coordination) {
|
|
263
278
|
const mode = d.coordination();
|
|
264
279
|
for (const task of d.board.list()) this.coordinationModes.set(task.id, mode);
|
|
@@ -685,6 +700,7 @@ export class Tasks {
|
|
|
685
700
|
const theirs = this.places(t);
|
|
686
701
|
// Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
|
|
687
702
|
// were stored as written, so both sides are compared in one spelling.
|
|
703
|
+
if (mine.repositoryKey !== theirs.repositoryKey) continue;
|
|
688
704
|
const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
|
|
689
705
|
const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.includes(x));
|
|
690
706
|
if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
|
|
@@ -728,6 +744,24 @@ export class Tasks {
|
|
|
728
744
|
private plannerFull(task: Task): PeerId[] {
|
|
729
745
|
return Object.keys(this.states()).filter(peer => { const queue = this.d.bus.queueSummary(peer); return this.wip(peer, task.id) >= this.wipLimit(peer) || !!queue.heldBy || queue.needsReview > 0; });
|
|
730
746
|
}
|
|
747
|
+
/**
|
|
748
|
+
* Why a planner pull finds no owner (#531), named from what the assignment saw and never from task text: the peers a
|
|
749
|
+
* lasting exclusion keeps out, a peer at its WIP limit (with wip/limit), a held queue and needs_review. Peer ids and counts
|
|
750
|
+
* only, so the text is safe for a PII task too. `route explain` reads the same `excluded()` and `plannerFull` inputs.
|
|
751
|
+
*/
|
|
752
|
+
plannerRefusal(task: Task): string {
|
|
753
|
+
const parts: string[] = [];
|
|
754
|
+
const excluded = [...new Set(this.excluded(task))];
|
|
755
|
+
if (excluded.length) parts.push(`${excluded.join(", ")} declined or was escalated away from this task for good; only a person's ahub task assign lifts that`);
|
|
756
|
+
for (const peer of Object.keys(this.states())) {
|
|
757
|
+
if (excluded.includes(peer)) continue;
|
|
758
|
+
const queue = this.d.bus.queueSummary(peer), wip = this.wip(peer, task.id), limit = this.wipLimit(peer);
|
|
759
|
+
if (wip >= limit) parts.push(`${peer} WIP full (${wip}/${limit}); no eligible planner WIP capacity`);
|
|
760
|
+
else if (queue.heldBy) parts.push(`${peer} has a held queue (a delivery is held; resolve it with ahub queue list)`);
|
|
761
|
+
else if (queue.needsReview > 0) parts.push(`${peer} has ${queue.needsReview} delivery(ies) in needs_review`);
|
|
762
|
+
}
|
|
763
|
+
return parts.length ? `no peer can take the planner pull: ${parts.join("; ")}` : "no peer has eligible planner WIP capacity";
|
|
764
|
+
}
|
|
731
765
|
private plannerAssignment(task: Task, reservedOnly = false): Assignment {
|
|
732
766
|
const states = this.states();
|
|
733
767
|
const full = this.plannerFull(task);
|
|
@@ -758,7 +792,7 @@ export class Tasks {
|
|
|
758
792
|
})) throw new PreEffectToolRefusal("a person's P0 backlog order is retained");
|
|
759
793
|
}
|
|
760
794
|
const assignment = this.plannerAssignment(task);
|
|
761
|
-
if (!assignment.owner) throw new PreEffectToolRefusal(
|
|
795
|
+
if (!assignment.owner) throw new PreEffectToolRefusal(this.plannerRefusal(task));
|
|
762
796
|
if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
763
797
|
return { task, owner: assignment.owner };
|
|
764
798
|
};
|
|
@@ -899,6 +933,8 @@ export class Tasks {
|
|
|
899
933
|
}
|
|
900
934
|
if (patch.reserved && !definitionOf(patch.reserved, this.d.instances?.()) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
901
935
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
936
|
+
// #531 D3: a reservation for a peer routing leaves out for good could never be honoured. Say so, and who can lift it.
|
|
937
|
+
if (patch.reserved && this.excluded(task).includes(patch.reserved)) throw new PreEffectToolRefusal(`${patch.reserved} is excluded from this task (it declined it or was escalated away from it for good); a reservation for it can never be honoured. Only a person's ahub task assign lifts the exclusion`);
|
|
902
938
|
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
903
939
|
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
904
940
|
const pending = [...(patch.deps ?? task.deps ?? [])], seen = new Set<number>();
|
|
@@ -1044,6 +1080,7 @@ export class Tasks {
|
|
|
1044
1080
|
const verdicts = screenedVerdicts.map((verdict, index) => deterministicPrivate[index] ? undefined : verdict);
|
|
1045
1081
|
const inherited = current.drafts.map((_part, index) => (initialParentPrivate || this.isPii(current.parent)) && !deterministicPrivate[index]);
|
|
1046
1082
|
const drafts = current.drafts.map((part, index) => ({ ...part, ...(screening ? { screeningNote: inherited[index] ? "pii: inherited" : this.screenNote(verdicts[index]) } : {}), signals: [...new Set([...initialPrivacy[index]!, ...initialParentPrivacy, ...finalSignals[index]!, ...current.parent.signals.filter(signal => ["pii", "pii_path", "urgent"].includes(signal)), ...(verdicts[index] && verdicts[index]!.label !== "clear" ? ["pii"] : [])])] }));
|
|
1083
|
+
for (const draft of drafts) this.warmRepository(draft);
|
|
1047
1084
|
const result = this.d.board.splitBacklog(id, by, revision, drafts, reason, withheld || initialReasonPrivate || this.isPii(current.parent) || !this.nameable(reason));
|
|
1048
1085
|
if (this.d.coordination) {
|
|
1049
1086
|
const mode = this.d.coordination();
|
|
@@ -1238,7 +1275,7 @@ export class Tasks {
|
|
|
1238
1275
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
1239
1276
|
const plan = cleanPlan(input.plan, this.d.profileData ?? profile);
|
|
1240
1277
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
1241
|
-
const screenDetail = [text.detail, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
|
|
1278
|
+
const screenDetail = [text.detail, text.refs.repo, external?.repo, external?.url].filter(value => value !== undefined && value !== "").join("\n");
|
|
1242
1279
|
const signals = this.signalsFor({ ...text, detail: [screenDetail, planText(plan)].join("\n"), plan });
|
|
1243
1280
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
1244
1281
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
@@ -1268,7 +1305,10 @@ export class Tasks {
|
|
|
1268
1305
|
const draft = { ...text, class: cls, ...(selected ? { role: selected.id } : {}), ...(profile !== "dev" || given !== undefined && given !== cls || this.d.profileData && this.d.profileData !== projectProfile("dev") ? { profile, profileClass: given ?? cls, ...(profile === "dev" ? { customProfile: true as const } : {}) } : {}) };
|
|
1269
1306
|
if (external) { const prior = this.d.board.imported(external.repo, external.issue); if (prior) return prior; }
|
|
1270
1307
|
if (reserved) this.requireCapabilities({ ...draft, signals, ...(external ? { external } : {}) }, reserved);
|
|
1308
|
+
this.warmRepository({ refs: draft.refs, plan });
|
|
1309
|
+
const repositoryMismatch = !this.isPii({ signals }) && this.repositories.mismatch(draft.refs, plan);
|
|
1271
1310
|
let task = this.d.board.propose(by, { ...draft, plan, ...(external ? { external } : {}), ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals, ...(backlog ? { backlog: true } : {}) });
|
|
1311
|
+
if (repositoryMismatch) this.releaseNotice(`task #${task.id}: refs and plan name different repositories; check refs.repo before editing`);
|
|
1272
1312
|
if (this.d.coordination) this.coordinationModes.set(task.id, this.d.coordination());
|
|
1273
1313
|
created?.();
|
|
1274
1314
|
if (screening) task = this.screenedTask(task, screened);
|
|
@@ -1553,9 +1593,15 @@ export class Tasks {
|
|
|
1553
1593
|
* Paths from refs and plan, symbols from the plan: the places a task says it touches (issue #31). Models write paths
|
|
1554
1594
|
* absolute or relative: one inside the project is compared in its project-relative spelling.
|
|
1555
1595
|
*/
|
|
1556
|
-
private
|
|
1557
|
-
const
|
|
1558
|
-
|
|
1596
|
+
private warmRepository(task: Pick<Task, "refs" | "plan">): void {
|
|
1597
|
+
const scope = this.repositories.scope(task.refs);
|
|
1598
|
+
if (scope !== this.repositories.project) for (const path of [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])]) this.repositories.relativePath(scope, path);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
private places(task: Task): { repositoryKey: string; paths: string[]; symbols: string[] } {
|
|
1602
|
+
const scope = this.repositories.scope(task.refs);
|
|
1603
|
+
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => scope === this.repositories.project ? this.projectPath(p) : normPath(this.repositories.relativePath(scope, p)));
|
|
1604
|
+
return { repositoryKey: scope.key, paths: [...new Set(paths)], symbols: [...(task.plan?.symbols ?? []), ...(task.plan?.sections ?? []).map(v => `section ${v}`), ...(task.plan?.sources ?? []).map(v => `source ${v}`)] };
|
|
1559
1605
|
}
|
|
1560
1606
|
|
|
1561
1607
|
private roots?: string[];
|
|
@@ -1579,6 +1625,7 @@ export class Tasks {
|
|
|
1579
1625
|
return this.d.board.list().flatMap((t) => {
|
|
1580
1626
|
if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
|
|
1581
1627
|
const theirs = this.places(t);
|
|
1628
|
+
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
1582
1629
|
const paths = task.profile && t.profile && (task.plan?.sections?.length && t.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1583
1630
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
1584
1631
|
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
@@ -1672,7 +1719,7 @@ export class Tasks {
|
|
|
1672
1719
|
* Peers routing leaves out for good (#207): the owners a task was declined for (the console declines for one; older rows
|
|
1673
1720
|
* name only the decliner) and the owners it was escalated away from, so a reservation never hands it back to them.
|
|
1674
1721
|
*/
|
|
1675
|
-
private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
|
|
1722
|
+
private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" && h.capacity !== true ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
|
|
1676
1723
|
|
|
1677
1724
|
private checkFlowRevision(task: Task, expected: number | undefined, preEffect = false): void {
|
|
1678
1725
|
if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0 || task.history.length !== expected)) {
|
|
@@ -2442,18 +2489,22 @@ export class Tasks {
|
|
|
2442
2489
|
const base = task.sdlc ? stageDefinition(config, task.sdlc.stage)?.base : undefined;
|
|
2443
2490
|
return base === "build" || base === "implement";
|
|
2444
2491
|
}
|
|
2445
|
-
async acceptReviewed(by: PeerId, id: unknown, plan?: unknown): Promise<Task> {
|
|
2492
|
+
async acceptReviewed(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Promise<Task> {
|
|
2493
|
+
const metadata = acceptRefs(refs);
|
|
2446
2494
|
const task = this.need(id, false, true);
|
|
2447
2495
|
this.mine(task, by, "owner", true);
|
|
2448
2496
|
this.acceptanceCapacity(task);
|
|
2449
|
-
if (
|
|
2497
|
+
if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
|
|
2498
|
+
const draft = metadata ? { ...task, refs: { ...task.refs, ...metadata } } : task;
|
|
2499
|
+
this.warmRepository(draft);
|
|
2500
|
+
if ((this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode === "off" || !this.isBuildTask(task)) return this.accept(by, id, plan, metadata);
|
|
2450
2501
|
await this.specGate(by, id);
|
|
2451
|
-
await this.planEntryGate(by,
|
|
2502
|
+
await this.planEntryGate(by, draft);
|
|
2452
2503
|
const current = this.need(id, false, true);
|
|
2453
2504
|
this.mine(current, by, "owner", true);
|
|
2454
2505
|
if (current.history.length !== task.history.length || current.owner !== task.owner) throw new PreEffectToolRefusal("task changed during build admission");
|
|
2455
2506
|
this.reviewedAdmissions.set(current.id, { flow: current.history.length, owner: current.owner });
|
|
2456
|
-
try { return this.accept(by, id, plan); }
|
|
2507
|
+
try { return this.accept(by, id, plan, metadata); }
|
|
2457
2508
|
finally { this.reviewedAdmissions.delete(current.id); }
|
|
2458
2509
|
}
|
|
2459
2510
|
|
|
@@ -2494,7 +2545,8 @@ export class Tasks {
|
|
|
2494
2545
|
* With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
|
|
2495
2546
|
* announced like one found at assignment (issue #31).
|
|
2496
2547
|
*/
|
|
2497
|
-
accept(by: PeerId, id: unknown, plan?: unknown): Task {
|
|
2548
|
+
accept(by: PeerId, id: unknown, plan?: unknown, refs?: unknown): Task {
|
|
2549
|
+
const metadata = acceptRefs(refs);
|
|
2498
2550
|
const task = this.need(id, false, true);
|
|
2499
2551
|
this.mine(task, by, "owner", true);
|
|
2500
2552
|
this.ready(task, true);
|
|
@@ -2521,10 +2573,15 @@ export class Tasks {
|
|
|
2521
2573
|
if (cleaned && !this.isPii(task) && this.isPii({ signals: this.signalsFor({ title: "", detail: planText(cleaned), refs: {}, plan: cleaned }) })) {
|
|
2522
2574
|
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
2523
2575
|
}
|
|
2576
|
+
if (metadata && !this.isPii(task) && (!this.nameable(metadata.repo!) || looksSecret(metadata.repo!) || this.aboutPrivatePath("", metadata.repo!, task))) throw new PreEffectToolRefusal("this repository metadata is private or sensitive and is not kept");
|
|
2577
|
+
const draft = { ...task, refs: { ...task.refs, ...metadata }, ...(cleaned ? { plan: cleaned } : {}) };
|
|
2578
|
+
this.warmRepository(draft);
|
|
2579
|
+
const repositoryChanged = this.repositories.scope(task.refs).key !== this.repositories.scope(draft.refs).key;
|
|
2524
2580
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
2525
|
-
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
|
|
2581
|
+
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}), ...(metadata ? { refs: metadata } : {}) });
|
|
2526
2582
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
2527
|
-
if (
|
|
2583
|
+
if (repositoryChanged) this.cohorts.leave(next.id);
|
|
2584
|
+
if (cleaned || metadata) {
|
|
2528
2585
|
const hits = this.overlapHits(next);
|
|
2529
2586
|
this.formCohort(next, hits);
|
|
2530
2587
|
const fresh = hits.filter((h) => !before.has(h.task.id));
|
|
@@ -2578,16 +2635,24 @@ export class Tasks {
|
|
|
2578
2635
|
this.doneNotes.delete(id);
|
|
2579
2636
|
return note;
|
|
2580
2637
|
};
|
|
2638
|
+
/** #572 AC3: what the owner's `hub_task_done` result adds while the project root checkout is on a task branch. */
|
|
2639
|
+
checkoutNote = (): string | undefined => { const warning = mainCheckoutWarning(this.d.cwd, this.publicMetadataText); return warning ? `Note: ${warning}.` : undefined; };
|
|
2581
2640
|
|
|
2582
|
-
|
|
2641
|
+
/**
|
|
2642
|
+
* `capacity` (#531): the owner has no room right now, so the decline excludes it only from this move's own reroute
|
|
2643
|
+
* (below), never from a later reroute or planner pull. Without it, a decline is a lasting refusal (`excluded()`), and so
|
|
2644
|
+
* is every older row, which has no such field.
|
|
2645
|
+
*/
|
|
2646
|
+
async decline(by: PeerId, id: unknown, reason?: string, capacity?: boolean): Promise<Task> {
|
|
2583
2647
|
const task = this.need(id, true, true);
|
|
2584
2648
|
this.mine(task, by, "owner", true);
|
|
2585
2649
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
2586
2650
|
const planned = this.plannerPullRecord(task)?.pull === true;
|
|
2587
|
-
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner } : {});
|
|
2651
|
+
const back = this.d.board.update(task.id, by, "declined", { ...(["in_progress", "changes_requested"].includes(task.state) ? { state: "proposed" as const } : {}), ...(planned ? { reserved: null } : {}) }, reason, task.owner ? { from: task.owner, ...(capacity === true ? { capacity: true as const } : {}) } : {});
|
|
2588
2652
|
if (planned) this.offered.delete(task.id);
|
|
2589
|
-
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
2590
|
-
|
|
2653
|
+
this.d.notify(`task ${this.publicTitle(back)} declined${capacity === true ? " (capacity only)" : ""} by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
2654
|
+
// A capacity decline is not in excluded(), so this move's own reroute leaves the decliner out explicitly.
|
|
2655
|
+
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true, ...(capacity === true && task.owner ? { exclude: [task.owner] } : {}), ...(planned ? { planner: true } : {}) });
|
|
2591
2656
|
}
|
|
2592
2657
|
|
|
2593
2658
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
@@ -2660,7 +2725,9 @@ export class Tasks {
|
|
|
2660
2725
|
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review", reviewAdmission);
|
|
2661
2726
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
2662
2727
|
this.checking.set(task.id, task.owner);
|
|
2663
|
-
const
|
|
2728
|
+
const checkedRefs = cleanRefs(refs);
|
|
2729
|
+
this.warmRepository({ ...task, refs: { ...task.refs, ...checkedRefs } });
|
|
2730
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: checkedRefs }, summary, { withheld: withheld || undefined });
|
|
2664
2731
|
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
2665
2732
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
2666
2733
|
this.pendingChecks++;
|
|
@@ -2700,6 +2767,7 @@ export class Tasks {
|
|
|
2700
2767
|
const at = [...u.history].reverse().find((h) => h.event === "done");
|
|
2701
2768
|
if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
|
|
2702
2769
|
const theirs = this.places(u);
|
|
2770
|
+
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
2703
2771
|
const paths = t.profile && u.profile && (t.plan?.sections?.length && u.plan?.sections?.length) ? [] : mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
2704
2772
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
2705
2773
|
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
@@ -2809,7 +2877,9 @@ export class Tasks {
|
|
|
2809
2877
|
const reviewCost: ReviewCost | undefined = reviewer && task.plan && (task.plan.review || task.plan.ci)
|
|
2810
2878
|
? { ...cleanCostPlan(task.plan), ...(admission?.ready && admission.head ? { head: admission.head } : {}) } : undefined;
|
|
2811
2879
|
const routedSummary = selection?.reviewSelection?.blocked ? `${summary ?? ""}\nReview routing: ${this.assignmentNote("pool unavailable", selection)}`.trim() : summary;
|
|
2812
|
-
const
|
|
2880
|
+
const completedRefs = cleanRefs(refs);
|
|
2881
|
+
this.warmRepository({ ...task, refs: { ...task.refs, ...completedRefs } });
|
|
2882
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(selection ? { reviewer: reviewer ?? USER } : {}), ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: completedRefs }, checkOutput ? `${routedSummary ?? ""}\n${checkOutput}`.trim() : routedSummary, { withheld: withheld || undefined, ...this.reviewMetadata(selection), ...(reviewCost ? { reviewCost } : {}) });
|
|
2813
2883
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
2814
2884
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
2815
2885
|
this.tellCompleted(next, summary, withheld);
|
|
@@ -2869,7 +2939,9 @@ export class Tasks {
|
|
|
2869
2939
|
const t = this.d.board.get(id);
|
|
2870
2940
|
if (!t || this.isPii(t)) return [];
|
|
2871
2941
|
const done = [...t.history].reverse().find((h) => h.event === "done");
|
|
2872
|
-
|
|
2942
|
+
const mine = this.places(task), theirs = this.places(t);
|
|
2943
|
+
if (mine.repositoryKey !== theirs.repositoryKey) return [];
|
|
2944
|
+
return [this.completedNotice(t, { task, paths: mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note, done?.withheld)];
|
|
2873
2945
|
});
|
|
2874
2946
|
return lines.length ? lines.join("\n") : undefined;
|
|
2875
2947
|
}
|
|
@@ -2956,7 +3028,9 @@ export class Tasks {
|
|
|
2956
3028
|
try { const text = this.d.projectedReview?.(task); if (text) projection = this.screen(task, sanitize(text), "document projection", reviewer); }
|
|
2957
3029
|
catch { projection = "(document projection unavailable)"; }
|
|
2958
3030
|
const brief = this.briefText(task);
|
|
2959
|
-
|
|
3031
|
+
// #572 AC3: advisory only, from git state never from task text, so it is safe for a PII task too.
|
|
3032
|
+
const checkout = mainCheckoutWarning(this.d.cwd, this.publicMetadataText);
|
|
3033
|
+
const body = `Review task #${task.id} [${profileTaskLabel(task)}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${spec}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${guidance ? guidance + "\n" : ""}${projection ? projection + "\n" : ""}${brief ? brief + "\n" : ""}${checkout ? `Note: ${checkout}.\n` : ""}${findings.length ? `Earlier findings (set a disposition for each):\n${findings.join("\n")}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
2960
3034
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, ...(this.reviewHead(task) ? { commit: this.reviewHead(task)! } : {}), task: String(task.id), reviewRevision: String(this.reviewRequestRevision(task)) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
2961
3035
|
}
|
|
2962
3036
|
|
|
@@ -3341,6 +3415,12 @@ export class Tasks {
|
|
|
3341
3415
|
return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
|
|
3342
3416
|
}
|
|
3343
3417
|
|
|
3418
|
+
/**
|
|
3419
|
+
* The whole public-text screen for metadata the hub itself reads from outside the board (a git branch name, #572): a PII
|
|
3420
|
+
* pattern, a secret shape and a path the project marks private. `nameable` alone is the pattern screen only.
|
|
3421
|
+
*/
|
|
3422
|
+
publicMetadataText = (text: string): boolean => this.nameable(text) && !looksSecret(text) && !this.aboutPrivatePath("", text, undefined);
|
|
3423
|
+
|
|
3344
3424
|
private candidateTextAllowed(text: string, task?: Task): boolean {
|
|
3345
3425
|
return (!task || !this.isPii(task)) && this.nameable(text) && !looksSecret(text) && !this.aboutPrivatePath("", text, task);
|
|
3346
3426
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { MAX_COMMAND_MS, orcaExecutable, type CommandResult, type CommandRunner, type RecordedTerminalLaunch } from "../cli/terminal-recovery.ts";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { descendantsOf, readProcessTable, stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
4
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
5
|
+
|
|
6
|
+
export interface TerminalOwnerProof {
|
|
7
|
+
generation: string;
|
|
8
|
+
nativePid?: number;
|
|
9
|
+
isCurrent(): boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Own only the daemon's background read, never the person's native TUI or Orca application. */
|
|
13
|
+
export function terminalOwnerReads(executable = orcaExecutable()): { run: CommandRunner; stop(): Promise<void> } {
|
|
14
|
+
let stopped = false;
|
|
15
|
+
const active = new Set<{ stop(): Promise<void>; done: Promise<CommandResult> }>();
|
|
16
|
+
return {
|
|
17
|
+
run(argv) {
|
|
18
|
+
if (stopped) return Promise.reject(new Error("terminal owner reader stopped"));
|
|
19
|
+
const child = spawn(executable, [...argv], { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
20
|
+
trackGroup(child);
|
|
21
|
+
let termination: Promise<void> | undefined;
|
|
22
|
+
const terminate = () => termination ??= stopOwnedProcess(child, { group: true }).catch(error => { stopped = true; throw error; });
|
|
23
|
+
let cancel!: (reason: Error) => void;
|
|
24
|
+
const cancelled = new Promise<never>((_, reject) => { cancel = reject; });
|
|
25
|
+
let entry!: { stop(): Promise<void>; done: Promise<CommandResult> };
|
|
26
|
+
const chunks: Buffer[] = []; let bytes = 0;
|
|
27
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
28
|
+
bytes += chunk.length;
|
|
29
|
+
// ponytail: show identity must fit 1 MiB; use an identity-only Orca endpoint if terminal payloads exceed it.
|
|
30
|
+
if (bytes > 1024 * 1024) { void entry.stop().catch(() => {}); return; }
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
});
|
|
33
|
+
child.stderr.resume(); // Never retain terminal text from a failed read in daemon diagnostics.
|
|
34
|
+
const result = new Promise<CommandResult>((resolve, reject) => {
|
|
35
|
+
child.once("error", () => reject(new Error("terminal owner query could not start")));
|
|
36
|
+
child.once("close", code => resolve({ status: code ?? 1, stdout: Buffer.concat(chunks).toString("utf8"), stderr: "" }));
|
|
37
|
+
});
|
|
38
|
+
const done = (async () => {
|
|
39
|
+
const timer = setTimeout(() => { void entry.stop().catch(() => {}); }, MAX_COMMAND_MS);
|
|
40
|
+
try { return await Promise.race([result, cancelled]); }
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
try { await terminate(); active.delete(entry); }
|
|
44
|
+
catch { throw new Error("terminal owner query cleanup incomplete"); }
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
entry = { done, stop: () => { cancel(new Error("terminal owner query cancelled")); return terminate(); } };
|
|
48
|
+
active.add(entry);
|
|
49
|
+
return done;
|
|
50
|
+
},
|
|
51
|
+
async stop() {
|
|
52
|
+
stopped = true;
|
|
53
|
+
const jobs = [...active];
|
|
54
|
+
const results = await Promise.allSettled(jobs.map(job => job.stop()));
|
|
55
|
+
await Promise.allSettled(jobs.map(job => job.done));
|
|
56
|
+
if (results.some(result => result.status === "rejected")) throw new Error("terminal owner query cleanup incomplete");
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Pending launch attempts are never recovery ownership. Only a current native owner can publish one. */
|
|
62
|
+
export class TerminalLaunches {
|
|
63
|
+
private readonly candidates = new Map<string, { row: RecordedTerminalLaunch; published?: string }>();
|
|
64
|
+
private running = false;
|
|
65
|
+
private lastCollection: number | undefined;
|
|
66
|
+
private stopped = false;
|
|
67
|
+
private readonly settlementWaiters: Array<() => void> = [];
|
|
68
|
+
constructor(private readonly opts: {
|
|
69
|
+
owner(row: RecordedTerminalLaunch): TerminalOwnerProof | undefined;
|
|
70
|
+
publish(row: RecordedTerminalLaunch, current: () => boolean): Promise<boolean>;
|
|
71
|
+
log?(message: string): void;
|
|
72
|
+
signature?: typeof processSignature;
|
|
73
|
+
now?(): number;
|
|
74
|
+
}) {}
|
|
75
|
+
|
|
76
|
+
register(row: RecordedTerminalLaunch): void {
|
|
77
|
+
if (this.stopped || processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature) !== "live") throw new Error("terminal launcher identity unavailable");
|
|
78
|
+
const previous = this.candidates.get(row.launchId);
|
|
79
|
+
if (previous) {
|
|
80
|
+
if (JSON.stringify(previous.row) !== JSON.stringify(row)) throw new Error("terminal launch identity changed");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (this.candidates.size >= 64) this.collectGone();
|
|
84
|
+
// ponytail: 64 live/unknown launch candidates per daemon. Fail closed at the cap; a durable owner ledger can extend it.
|
|
85
|
+
if (this.candidates.size >= 64) throw new Error("too many pending terminal launches");
|
|
86
|
+
this.candidates.set(row.launchId, { row });
|
|
87
|
+
this.lastCollection ??= (this.opts.now ?? Date.now)();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private collectGone(): void {
|
|
91
|
+
this.lastCollection = (this.opts.now ?? Date.now)();
|
|
92
|
+
const states = new Map<string, ReturnType<typeof processLiveness>>();
|
|
93
|
+
for (const [id, { row }] of this.candidates) {
|
|
94
|
+
const key = `${row.launcherPid}:${row.launcherSignature}`;
|
|
95
|
+
const state = states.get(key) ?? processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature);
|
|
96
|
+
states.set(key, state);
|
|
97
|
+
if (state === "gone") this.candidates.delete(id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async reconcile(): Promise<void> {
|
|
102
|
+
if (this.running || this.stopped) return;
|
|
103
|
+
this.running = true;
|
|
104
|
+
try {
|
|
105
|
+
// Native ownership is in memory. Only publication needs an immediate process probe;
|
|
106
|
+
// collect exited launchers every 30 s (or at admission cap), not on the daemon's 1 s tick.
|
|
107
|
+
if (this.lastCollection !== undefined && (this.opts.now ?? Date.now)() - this.lastCollection >= 30_000) this.collectGone();
|
|
108
|
+
for (const [id, candidate] of this.candidates) {
|
|
109
|
+
const { row } = candidate;
|
|
110
|
+
const proof = this.opts.owner(row);
|
|
111
|
+
if (!proof || !proof.isCurrent() || candidate.published === proof.generation) continue;
|
|
112
|
+
const liveness = processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature);
|
|
113
|
+
if (liveness === "gone") { this.candidates.delete(id); continue; }
|
|
114
|
+
if (liveness !== "live") continue;
|
|
115
|
+
if (proof.nativePid !== undefined) {
|
|
116
|
+
const table = await readProcessTable();
|
|
117
|
+
if (!table || !descendantsOf(table, row.launcherPid).some(child => child.pid === proof.nativePid)) continue;
|
|
118
|
+
}
|
|
119
|
+
const current = () => !this.stopped && this.candidates.get(id) === candidate && proof.isCurrent() &&
|
|
120
|
+
processLiveness(row.launcherPid, row.launcherSignature, this.opts.signature ?? processSignature) === "live";
|
|
121
|
+
try {
|
|
122
|
+
if (current() && await this.opts.publish(row, current)) candidate.published = proof.generation;
|
|
123
|
+
} catch { if (!this.stopped) this.opts.log?.("terminal owner publication unavailable; inspect recovery terminal evidence"); }
|
|
124
|
+
}
|
|
125
|
+
} finally { this.running = false; for (const done of this.settlementWaiters.splice(0)) done(); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
clear(): void { this.stopped = true; this.candidates.clear(); }
|
|
129
|
+
settled(): Promise<void> { return this.running ? new Promise(resolve => this.settlementWaiters.push(resolve)) : Promise.resolve(); }
|
|
130
|
+
}
|
package/src/hub/usage.ts
CHANGED
|
@@ -71,32 +71,58 @@ export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: str
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Fixed backwards I/O chunk: a normal near-EOF completion costs only this tail read. */
|
|
75
|
+
export const COMPLETION_TAIL_BYTES = 4 * 1024 * 1024;
|
|
74
76
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* row last, so this needs >4 MiB written after the turn ended). Upgrade: scan backwards in chunks until an assistant row.
|
|
77
|
+
* ponytail: completion lookup reads at most 64 MiB plus one line-boundary byte. A row beyond that bound, or one
|
|
78
|
+
* whose start is outside it, stays unavailable. Upgrade: a session-bound completion index instead of rescanning.
|
|
78
79
|
*/
|
|
79
|
-
export const
|
|
80
|
+
export const COMPLETION_SCAN_BYTES = 64 * 1024 * 1024;
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
|
-
* The newest completed assistant turn
|
|
83
|
-
*
|
|
84
|
-
*
|
|
83
|
+
* The newest completed assistant turn, scanning complete JSONL rows backwards in fixed chunks. Reassemble bytes
|
|
84
|
+
* before decoding UTF-8; a cut row is never parsed as a valid suffix. The optional lookback budget remains bounded
|
|
85
|
+
* by COMPLETION_SCAN_BYTES, plus one byte to certify a row starting exactly at its boundary.
|
|
85
86
|
*/
|
|
86
|
-
export function readClaudeTranscriptCompletion(sessionId: string, transcriptPath: string,
|
|
87
|
+
export function readClaudeTranscriptCompletion(sessionId: string, transcriptPath: string, maxBytes = COMPLETION_SCAN_BYTES): UsageRecord | undefined {
|
|
88
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) return undefined;
|
|
87
89
|
let fd: number | undefined;
|
|
88
90
|
try {
|
|
89
91
|
fd = openSync(transcriptPath, "r");
|
|
90
|
-
const size = fstatSync(fd).size, start = Math.max(0, size -
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
const size = fstatSync(fd).size, start = Math.max(0, size - Math.min(maxBytes, COMPLETION_SCAN_BYTES));
|
|
93
|
+
const limit = Math.max(0, start - 1);
|
|
94
|
+
let position = size;
|
|
95
|
+
let parts: Buffer[] = [];
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
const completedRow = (): UsageRecord | undefined => {
|
|
98
|
+
const text = Buffer.concat(parts.reverse()).toString("utf8");
|
|
99
|
+
parts = [];
|
|
100
|
+
const record = transcriptRecords(sessionId, text)[0];
|
|
101
|
+
if (!record || seen.has(record.id)) return undefined;
|
|
102
|
+
seen.add(record.id); // A later non-end_turn row supersedes an older completion for the same message.
|
|
103
|
+
return record.completedTurn ? record : undefined;
|
|
104
|
+
};
|
|
105
|
+
while (position > limit) {
|
|
106
|
+
const from = Math.max(limit, position - COMPLETION_TAIL_BYTES), buffer = Buffer.alloc(position - from);
|
|
107
|
+
let read = 0;
|
|
108
|
+
while (read < buffer.length) {
|
|
109
|
+
const n = readSync(fd, buffer, read, buffer.length - read, from + read);
|
|
110
|
+
if (n <= 0) return undefined; // A shrinking transcript cannot certify a contiguous row.
|
|
111
|
+
read += n;
|
|
112
|
+
}
|
|
113
|
+
let end = read;
|
|
114
|
+
while (end > 0) {
|
|
115
|
+
const newline = buffer.lastIndexOf(10, end - 1);
|
|
116
|
+
if (newline < 0) break;
|
|
117
|
+
parts.push(buffer.subarray(newline + 1, end));
|
|
118
|
+
const record = completedRow();
|
|
119
|
+
if (record) return record;
|
|
120
|
+
end = newline;
|
|
121
|
+
}
|
|
122
|
+
if (end > 0) parts.push(buffer.subarray(0, end));
|
|
123
|
+
position = from;
|
|
124
|
+
}
|
|
125
|
+
return position === 0 && start === 0 ? completedRow() : undefined;
|
|
100
126
|
} catch {
|
|
101
127
|
return undefined;
|
|
102
128
|
} finally {
|
package/src/omniroute/client.ts
CHANGED
|
@@ -37,16 +37,11 @@ export interface ChatResult {
|
|
|
37
37
|
servedModel?: string;
|
|
38
38
|
/** `x-omniroute-provider` (direct path) */
|
|
39
39
|
provider?: string;
|
|
40
|
-
/** `x-model-router-selected-model` (through Switchyard) */
|
|
41
|
-
selectedModel?: string;
|
|
42
40
|
}
|
|
43
41
|
export interface ChatOptions {
|
|
44
42
|
/** Refuse a changed/off-campus gateway immediately before transport. */
|
|
45
43
|
onCampusOnly?: boolean;
|
|
46
44
|
signal?: AbortSignal;
|
|
47
|
-
/** Switchyard base (`http://127.0.0.1:<port>/v1`). The sidecar holds the key, so none is sent. */
|
|
48
|
-
via?: string;
|
|
49
|
-
sessionId?: string;
|
|
50
45
|
}
|
|
51
46
|
|
|
52
47
|
const readSecret = (file?: string) => {
|
|
@@ -159,17 +154,13 @@ export class OmniRoute {
|
|
|
159
154
|
}
|
|
160
155
|
|
|
161
156
|
async chat(body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number; temperature?: number; reasoning_effort?: string }, opts: ChatOptions = {}): Promise<ChatResult> {
|
|
162
|
-
const base =
|
|
157
|
+
const base = await this.base();
|
|
163
158
|
if (!base) throw new Error("no model gateway is configured or reachable (omniroute.urls in .agenthub/config.json; see ahub doctor)");
|
|
164
|
-
if (opts.onCampusOnly &&
|
|
159
|
+
if (opts.onCampusOnly && this.isAccessHost(base)) throw new Error("PII model calls require the confirmed campus gateway");
|
|
165
160
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
const key = this.apiKey();
|
|
170
|
-
if (!key) throw new Error("no OmniRoute API key (set OMNIROUTE_API_KEY or omniroute.api_key_file)");
|
|
171
|
-
Object.assign(headers, { authorization: `Bearer ${key}` }, this.accessHeaders(base));
|
|
172
|
-
}
|
|
161
|
+
const key = this.apiKey();
|
|
162
|
+
if (!key) throw new Error("no OmniRoute API key (set OMNIROUTE_API_KEY or omniroute.api_key_file)");
|
|
163
|
+
Object.assign(headers, { authorization: `Bearer ${key}` }, this.accessHeaders(base));
|
|
173
164
|
let res: Response;
|
|
174
165
|
try {
|
|
175
166
|
res = await fetch(`${base}/chat/completions`, {
|
|
@@ -179,7 +170,7 @@ export class OmniRoute {
|
|
|
179
170
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
180
171
|
});
|
|
181
172
|
} catch (e) {
|
|
182
|
-
|
|
173
|
+
this.live = undefined; // probe again next time: the VPN may have dropped
|
|
183
174
|
throw e;
|
|
184
175
|
}
|
|
185
176
|
if (!res.ok) throw new Error(`model call failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
@@ -187,17 +178,14 @@ export class OmniRoute {
|
|
|
187
178
|
const message = json.choices?.[0]?.message;
|
|
188
179
|
if (!message) throw new Error("model call returned no message");
|
|
189
180
|
const provider = res.headers.get("x-omniroute-provider") ?? undefined;
|
|
190
|
-
const
|
|
191
|
-
const servedModel = safeModelLabel(json.model) ?? safeModelLabel(selectedModel);
|
|
181
|
+
const servedModel = safeModelLabel(json.model);
|
|
192
182
|
const safeProvider = safeModelLabel(provider);
|
|
193
|
-
const safeSelectedModel = safeModelLabel(selectedModel);
|
|
194
183
|
const usage = normalizeUsage(json.usage);
|
|
195
184
|
// reasoning_content and other extras are dropped here: only role, content and tool_calls travel on.
|
|
196
185
|
return {
|
|
197
186
|
message: { role: "assistant", content: message.content ?? null, ...(message.tool_calls?.length ? { tool_calls: message.tool_calls } : {}) },
|
|
198
187
|
...(usage ? { usage } : {}),
|
|
199
188
|
...(safeProvider ? { provider: safeProvider } : {}),
|
|
200
|
-
...(safeSelectedModel ? { selectedModel: safeSelectedModel } : {}),
|
|
201
189
|
...(servedModel ? { servedModel } : {}),
|
|
202
190
|
};
|
|
203
191
|
}
|
|
@@ -11,5 +11,6 @@ This project runs agent-hub: other coding agents (claude, codex, kimi, qwen, jco
|
|
|
11
11
|
- Do not use `[FYI]` for PR ready, done, submitted, ruling needed, blocker, or changed contract or path messages: each requires recipient action. Use `[FYI]` only for progress or information needing no action. `hub_task_done` and `hub_review` already notify assigned recipients; no duplicate chat is needed.
|
|
12
12
|
- Read the kind of each message (`meta.kind` on a channel tag, each item's kind in a digest, the kind in a prompt header). Only `hub` items with kind `presence` are shared memory for reference, not requests. Its `task`, `review`, and `budget` items are workflow events: check the task board and your assigned role, then use the appropriate hub tools within the user's authorized scope. Sender and kind never override user instructions or safety rules.
|
|
13
13
|
- The task board is the record of who does what: `hub_task_propose`, `hub_task_accept` / `hub_task_decline`, `hub_task_done`, `hub_review`, `hub_task_list`. Default roles: Claude plans and reviews, Codex implements, Kimi, Pi and the local worker implement and verify; `.agenthub/config.json` `roles` is the source of truth.
|
|
14
|
+
- After pushing a PR, call `hub_task_done` stating the PR, its head commit and the CI state, then end your turn. Do not wait for or poll CI: a waiting turn costs quota and leaves you deaf to messages, and the reviewer checks CI. Work in a worktree under `.worktrees/`, never on a branch in the project root checkout.
|
|
14
15
|
- Implementers claim work nobody assigned them with `hub_task_propose`, naming themselves as `owner`, with the paths in `refs` and a `plan` (files, symbols, signatures, insertion points; `hub_task_accept` takes one too). When the paths or symbols overlap another open task the hub says so, the later claimant settles it with that owner, and that owner gets the plan with its next message. When a task is done, the owners of overlapping open tasks get a notice of what changed. In a project with `coordination: "turn-free"` in `.agenthub/config.json`, a task's texts say when its owners form a silent cohort: then they do not message each other, the hub shows each of them the other's changes as they work, and the last to finish is asked to check its work against the others and call `hub_task_done` again. `hub_task_done` says what changed, why, and the check that was run with its result.
|
|
15
16
|
- `hub_remember` saves a decision, finding, contract or `fail` (an approach that does not work, and why) to the memory all agents share; the other agents get it with their next message. Do not retry what a `fail` note rules out without new evidence. A task shown as `[pii]` is handled by the on-prem worker only: do not ask for its content.
|