@staix/agent-hub 0.12.21 → 0.12.23
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 +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +32 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +483 -83
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +7 -5
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +43 -4
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
|
|
3
|
+
* `ahub <peer>` gets the same default and can override it with a flag.
|
|
4
|
+
*/
|
|
5
|
+
/** Shared terminal-start guard and dashboard retry hold after an unconfirmed response. */
|
|
6
|
+
export const PEER_START_RETRY_MS = 30_000;
|
|
7
|
+
export const START_MODES = ["tui", "headless"] as const;
|
|
8
|
+
export type StartMode = typeof START_MODES[number];
|
|
9
|
+
/** The peers with both forms. Claude has only a TUI; Kimi (ACP) and the local worker have none the hub attaches to. */
|
|
10
|
+
export const START_MODE_PEERS = ["pi", "codex"] as const;
|
|
11
|
+
/** Every peer the hub can start itself. */
|
|
12
|
+
export const STARTABLE_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
13
|
+
export type StartablePeer = typeof STARTABLE_PEERS[number];
|
|
14
|
+
export const isStartablePeer = (value: unknown): value is StartablePeer => typeof value === "string" && (STARTABLE_PEERS as readonly string[]).includes(value);
|
|
15
|
+
|
|
16
|
+
export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
|
|
17
|
+
/** The checked `peers` block of the config; throws on anything but `{ pi|codex: { start_mode: "tui"|"headless" } }`. */
|
|
18
|
+
export function peerStartConfig(value: unknown): PeerStartConfig {
|
|
19
|
+
if (value === undefined) return {};
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
|
|
21
|
+
const result: PeerStartConfig = {};
|
|
22
|
+
for (const [peer, block] of Object.entries(value)) {
|
|
23
|
+
if (!(START_MODE_PEERS as readonly string[]).includes(peer)) throw new Error(`peers.${peer}: only ${START_MODE_PEERS.join(" and ")} have a start mode`);
|
|
24
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
|
|
25
|
+
const mode = (block as { start_mode?: unknown }).start_mode;
|
|
26
|
+
if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
|
|
27
|
+
result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
|
|
33
|
+
export function startModeOf(peers: PeerStartConfig | undefined, peer: string): StartMode {
|
|
34
|
+
if (peer === "claude") return "tui";
|
|
35
|
+
if ((START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? "tui";
|
|
36
|
+
return "headless";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The checked `terminal.open` template: an argv in which exactly one element contains `{command}` (replaced by the
|
|
41
|
+
* shell-quoted command the hub assembled); `{title}` and `{cwd}` are replaced too. Empty means no template.
|
|
42
|
+
*/
|
|
43
|
+
export function terminalTemplate(value: unknown): string[] {
|
|
44
|
+
if (value === undefined || value === null) return [];
|
|
45
|
+
if (!Array.isArray(value) || value.some((part) => typeof part !== "string" || !part)) throw new Error("terminal.open must be a list of non-empty strings (an argv with a {command} placeholder)");
|
|
46
|
+
if (value.length && value.filter((part: string) => part.includes("{command}")).length !== 1) throw new Error("terminal.open needs {command} in exactly one element");
|
|
47
|
+
return [...value];
|
|
48
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -92,6 +92,24 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
|
92
92
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
93
93
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
97
|
+
* fact, the delivery is unconfirmed. Callers decide by this type, never by comparing owners or reading history.
|
|
98
|
+
* It stays a plain Error subclass, so the preEffect rule and the #231 ready-offer contract are unchanged.
|
|
99
|
+
* `task` and `cause` are non-enumerable: a PII task's title and detail must not reach a JSON.stringify of the error.
|
|
100
|
+
*/
|
|
101
|
+
export class AssignmentUndeliveredError extends Error {
|
|
102
|
+
/** The task as the board has it after the write. */
|
|
103
|
+
declare readonly task: Task;
|
|
104
|
+
declare readonly cause: unknown;
|
|
105
|
+
constructor(task: Task, cause: unknown) {
|
|
106
|
+
super(`task #${task.id}: the assignment to ${task.owner} is saved, but its delivery is not confirmed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
107
|
+
this.name = "AssignmentUndeliveredError";
|
|
108
|
+
Object.defineProperty(this, "task", { value: task, enumerable: false });
|
|
109
|
+
Object.defineProperty(this, "cause", { value: cause, enumerable: false });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
96
114
|
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
97
115
|
|
|
@@ -242,7 +260,13 @@ export class Tasks {
|
|
|
242
260
|
this.passedOver(task, a);
|
|
243
261
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
244
262
|
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
245
|
-
|
|
263
|
+
try {
|
|
264
|
+
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown, and
|
|
267
|
+
// the sweep goes on (#297).
|
|
268
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
269
|
+
}
|
|
246
270
|
}
|
|
247
271
|
}
|
|
248
272
|
}
|
|
@@ -569,7 +593,12 @@ export class Tasks {
|
|
|
569
593
|
this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
|
|
570
594
|
const still = this.waitsFor(lifted);
|
|
571
595
|
if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
572
|
-
|
|
596
|
+
try {
|
|
597
|
+
await this.assignOwner(lifted, HUB);
|
|
598
|
+
} catch (e) {
|
|
599
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown (#297).
|
|
600
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
601
|
+
}
|
|
573
602
|
}
|
|
574
603
|
|
|
575
604
|
/**
|
|
@@ -757,7 +786,12 @@ export class Tasks {
|
|
|
757
786
|
// The gone owner hears it on its next delivery, if it comes back mid-work.
|
|
758
787
|
if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
|
|
759
788
|
} catch (e) {
|
|
760
|
-
|
|
789
|
+
if (e instanceof AssignmentUndeliveredError) {
|
|
790
|
+
// The move is fact for the board, so it is fact here too: record it, and tell the gone owner to stop
|
|
791
|
+
// (the console notice was printed where the error was thrown) (#297).
|
|
792
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner });
|
|
793
|
+
if (e.task.owner && e.task.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${e.task.owner} while you were offline; stop working on it`));
|
|
794
|
+
} else this.d.notify(`task ${this.publicTitle(task)}: could not be released from ${peer}: ${(e as Error).message}`);
|
|
761
795
|
}
|
|
762
796
|
}
|
|
763
797
|
} finally {
|
|
@@ -766,9 +800,8 @@ export class Tasks {
|
|
|
766
800
|
return moved;
|
|
767
801
|
}
|
|
768
802
|
|
|
769
|
-
/** Same code as assignment, without doing it. */
|
|
770
|
-
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
771
|
-
const routing = this.d.routing();
|
|
803
|
+
/** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
|
|
804
|
+
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
|
|
772
805
|
if (typeof target === "number") {
|
|
773
806
|
const task = this.d.board.get(target);
|
|
774
807
|
if (!task) throw new Error(`no task #${target}`);
|
|
@@ -784,8 +817,8 @@ export class Tasks {
|
|
|
784
817
|
}
|
|
785
818
|
|
|
786
819
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
787
|
-
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
788
|
-
return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
820
|
+
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
|
|
821
|
+
return this.explain(target, routing).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
789
822
|
}
|
|
790
823
|
|
|
791
824
|
/**
|
|
@@ -801,7 +834,8 @@ export class Tasks {
|
|
|
801
834
|
*/
|
|
802
835
|
private passedOver(task: Task, a: Assignment): string {
|
|
803
836
|
if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
|
|
804
|
-
|
|
837
|
+
// Before the write, but after the caller's own (a decline's, say): a throwing console fails nothing (#297).
|
|
838
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
|
|
805
839
|
return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
|
|
806
840
|
}
|
|
807
841
|
|
|
@@ -826,12 +860,12 @@ export class Tasks {
|
|
|
826
860
|
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
|
|
827
861
|
|
|
828
862
|
if (waits.length) {
|
|
829
|
-
this.
|
|
863
|
+
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
830
864
|
return task;
|
|
831
865
|
}
|
|
832
866
|
const passed = this.passedOver(task, a);
|
|
833
867
|
if (!a.owner) {
|
|
834
|
-
this.
|
|
868
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
835
869
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
836
870
|
if (opts.clearOnFail && task.owner) {
|
|
837
871
|
this.cohorts.leave(task.id);
|
|
@@ -856,19 +890,38 @@ export class Tasks {
|
|
|
856
890
|
// A claim is its own hand-over: the claimant took the task in the turn it is in, so it is taking it, not busy elsewhere.
|
|
857
891
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
858
892
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
893
|
+
try {
|
|
894
|
+
const hits = this.overlapHits(next);
|
|
895
|
+
this.formCohort(next, hits);
|
|
896
|
+
if (hits.length) {
|
|
897
|
+
this.announceOverlap(next, hits);
|
|
898
|
+
this.tellEarlierOwners(next, hits);
|
|
899
|
+
}
|
|
900
|
+
this.announceRouting(next, a);
|
|
901
|
+
if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
902
|
+
} catch (e) {
|
|
903
|
+
// Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
|
|
904
|
+
// delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
|
|
905
|
+
// control flow by the error type.
|
|
906
|
+
let saved = next;
|
|
907
|
+
try {
|
|
908
|
+
saved = this.d.board.get(task.id) ?? next;
|
|
909
|
+
} catch { /* the write's row is still true */ }
|
|
910
|
+
// Superseded means a later entry that moves or settles the task: another assignment, an unassign, the
|
|
911
|
+
// owner's accept, done, approval, close. Entries that do neither (the hub's quiet bookkeeping, a check
|
|
912
|
+
// result landing during the brief) leave the assignment exactly as undelivered as before: the notice and
|
|
913
|
+
// the typed error stand (#297 Decisions).
|
|
914
|
+
const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
|
|
915
|
+
if (moves(saved.history).length !== moves(next.history).length) throw e;
|
|
916
|
+
this.releaseNotice(this.undeliveredNotice(saved, e));
|
|
917
|
+
throw new AssignmentUndeliveredError(saved, e);
|
|
864
918
|
}
|
|
865
|
-
|
|
919
|
+
// A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
|
|
866
920
|
if (opts.claim && a.owner === by) {
|
|
867
921
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
868
|
-
this.
|
|
922
|
+
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
869
923
|
return claimed;
|
|
870
924
|
}
|
|
871
|
-
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
872
925
|
return next;
|
|
873
926
|
}
|
|
874
927
|
|
|
@@ -920,9 +973,30 @@ export class Tasks {
|
|
|
920
973
|
}
|
|
921
974
|
}
|
|
922
975
|
|
|
923
|
-
/**
|
|
924
|
-
|
|
925
|
-
|
|
976
|
+
/**
|
|
977
|
+
* The one guard for a publish or notify after a board write (#231, #243): the write took effect, so a failure
|
|
978
|
+
* never throws back to the caller. A failed publish becomes a console notice naming what was lost; a failed notify
|
|
979
|
+
* is dropped (a second attempt would fail the same way). A lost publish is not retried here: the delivery
|
|
980
|
+
* journal's own recovery covers a storage error.
|
|
981
|
+
*/
|
|
982
|
+
private releaseNotice(line: string, run?: () => unknown): void {
|
|
983
|
+
if (!run) {
|
|
984
|
+
try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
try { run(); } catch (e) {
|
|
988
|
+
try { this.d.notify(`${line}: ${e instanceof Error ? e.message : String(e)}`); } catch { /* best effort */ }
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* The one text for a saved assignment whose delivery is unconfirmed (#297), built through publicTitle (a PII task
|
|
994
|
+
* shows nothing more than it does today): what to check, and how to send it again. No automatic resend: a blind
|
|
995
|
+
* resend can duplicate a publish that did leave (the #276 decision).
|
|
996
|
+
*/
|
|
997
|
+
undeliveredNotice(task: Task, cause: unknown): string {
|
|
998
|
+
const owner = task.owner ?? "its owner";
|
|
999
|
+
return `task ${this.publicTitle(task)}: the move to ${owner} is saved, but its assignment's delivery is not confirmed (${cause instanceof Error ? cause.message : String(cause)}); look for a queued or needs_review delivery with ahub queue list --peer ${owner} (then ahub queue show <id>), or the accepted entry in ahub task show ${task.id} if the journal is down; send it again with: ahub task assign ${task.id} ${owner}`;
|
|
926
1000
|
}
|
|
927
1001
|
|
|
928
1002
|
private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
|
|
@@ -947,7 +1021,7 @@ export class Tasks {
|
|
|
947
1021
|
|
|
948
1022
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
949
1023
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
950
|
-
let offering = false;
|
|
1024
|
+
let offering = false, undelivered: AssignmentUndeliveredError | undefined;
|
|
951
1025
|
try {
|
|
952
1026
|
const t = this.d.board.get(stale.id);
|
|
953
1027
|
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
@@ -957,9 +1031,12 @@ export class Tasks {
|
|
|
957
1031
|
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
958
1032
|
await this.assignOwner(ready, HUB);
|
|
959
1033
|
} catch (e) {
|
|
960
|
-
|
|
1034
|
+
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
1035
|
+
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
1036
|
+
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
1037
|
+
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
961
1038
|
} finally {
|
|
962
|
-
if (offering) {
|
|
1039
|
+
if (offering && !undelivered) {
|
|
963
1040
|
try {
|
|
964
1041
|
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
965
1042
|
} catch (e) {
|
|
@@ -1125,7 +1202,7 @@ export class Tasks {
|
|
|
1125
1202
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1126
1203
|
this.checking.set(task.id, task.owner);
|
|
1127
1204
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
1128
|
-
this.
|
|
1205
|
+
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
1129
1206
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
1130
1207
|
this.pendingChecks++;
|
|
1131
1208
|
this.checkQueue = this.checkQueue
|
|
@@ -1255,11 +1332,11 @@ export class Tasks {
|
|
|
1255
1332
|
this.tellCompleted(next, summary, withheld);
|
|
1256
1333
|
if (!reviewer) {
|
|
1257
1334
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1258
|
-
this.
|
|
1335
|
+
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
1259
1336
|
await this.releaseDependents(next);
|
|
1260
1337
|
}
|
|
1261
|
-
else if (reviewer === USER) this.
|
|
1262
|
-
else this.sendReview(next, reviewer);
|
|
1338
|
+
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
|
|
1339
|
+
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
|
|
1263
1340
|
return next;
|
|
1264
1341
|
}
|
|
1265
1342
|
|
|
@@ -1279,7 +1356,8 @@ export class Tasks {
|
|
|
1279
1356
|
cohort.held.add(task.id);
|
|
1280
1357
|
continue;
|
|
1281
1358
|
}
|
|
1282
|
-
this.
|
|
1359
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${hit.task.owner}`, () =>
|
|
1360
|
+
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld)));
|
|
1283
1361
|
}
|
|
1284
1362
|
}
|
|
1285
1363
|
|
|
@@ -1357,7 +1435,8 @@ export class Tasks {
|
|
|
1357
1435
|
}
|
|
1358
1436
|
}
|
|
1359
1437
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
1360
|
-
this.
|
|
1438
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
1439
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
1361
1440
|
await this.releaseDependents(next);
|
|
1362
1441
|
return next;
|
|
1363
1442
|
}
|
|
@@ -1367,14 +1446,27 @@ export class Tasks {
|
|
|
1367
1446
|
this.contradict(rejected);
|
|
1368
1447
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
1369
1448
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
1370
|
-
|
|
1449
|
+
let moved: Task;
|
|
1450
|
+
try {
|
|
1451
|
+
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
// The verdict is saved. If the escalation's move was saved too, only its delivery is unconfirmed, and the
|
|
1454
|
+
// error carries the saved task (#297); the console notice was printed where the error was thrown. The
|
|
1455
|
+
// reviewer gets the saved task back, never a failed tool call (#276). Anything else (the task closed
|
|
1456
|
+
// meanwhile (#254), a failed board write, a superseded move) still throws. assignOwner keeps throwing for
|
|
1457
|
+
// the ready offer's retry (#231).
|
|
1458
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
1459
|
+
return e.task;
|
|
1460
|
+
}
|
|
1371
1461
|
if (moved.owner !== rejected.owner) return moved;
|
|
1372
1462
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
1373
|
-
this.
|
|
1463
|
+
this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
|
|
1464
|
+
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii));
|
|
1374
1465
|
return moved;
|
|
1375
1466
|
}
|
|
1376
1467
|
const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
|
|
1377
|
-
this.
|
|
1468
|
+
this.releaseNotice(`task ${this.publicTitle(reopened)}: could not tell ${reopened.owner ?? "its owner"} changes were requested`, () =>
|
|
1469
|
+
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii));
|
|
1378
1470
|
return reopened;
|
|
1379
1471
|
}
|
|
1380
1472
|
|
|
@@ -1389,12 +1481,28 @@ export class Tasks {
|
|
|
1389
1481
|
if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
|
|
1390
1482
|
// Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
|
|
1391
1483
|
if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
|
|
1392
|
-
|
|
1484
|
+
// The guarded tail of a landed move: the console notice, the decision note and the old owner's stop message,
|
|
1485
|
+
// each behind its own releaseNotice. It runs on the success path and on a saved move whose delivery failed.
|
|
1486
|
+
const tail = (moved: Task) => {
|
|
1487
|
+
this.releaseNotice(`task ${this.publicTitle(moved)} escalated from ${from} to ${moved.owner} (${why})`);
|
|
1488
|
+
this.note(moved, by, "decision", `Task #${moved.id} escalated from ${from} to ${moved.owner}: ${why}`);
|
|
1489
|
+
if (from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
|
|
1490
|
+
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
|
|
1491
|
+
};
|
|
1492
|
+
let next: Task;
|
|
1493
|
+
try {
|
|
1494
|
+
next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
|
|
1495
|
+
} catch (e) {
|
|
1496
|
+
// A saved move whose delivery failed arrives as AssignmentUndeliveredError carrying the saved task (#297);
|
|
1497
|
+
// the tail still runs on it (its parts are guarded), and the caller still gets the throw. Anything before
|
|
1498
|
+
// the write gets neither.
|
|
1499
|
+
if (e instanceof AssignmentUndeliveredError) tail(e.task);
|
|
1500
|
+
throw e;
|
|
1501
|
+
}
|
|
1393
1502
|
if (next.owner && next.owner !== from) {
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
} else this.d.notify(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
1503
|
+
// The move is on the board (#243): review() reaches here after its changes_requested write.
|
|
1504
|
+
tail(next);
|
|
1505
|
+
} else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
1398
1506
|
return this.d.board.get(next.id)!;
|
|
1399
1507
|
}
|
|
1400
1508
|
|
|
@@ -1404,7 +1512,7 @@ export class Tasks {
|
|
|
1404
1512
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1405
1513
|
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
|
|
1406
1514
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
1407
|
-
this.
|
|
1515
|
+
this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
1408
1516
|
return next;
|
|
1409
1517
|
}
|
|
1410
1518
|
|
|
@@ -1425,8 +1533,15 @@ export class Tasks {
|
|
|
1425
1533
|
const pii = this.isPii(task);
|
|
1426
1534
|
const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
|
|
1427
1535
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
|
|
1428
|
-
|
|
1429
|
-
|
|
1536
|
+
try {
|
|
1537
|
+
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
|
|
1538
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1539
|
+
} catch (e) {
|
|
1540
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown; the
|
|
1541
|
+
// task counts as moved and the relay goes on (#297). No retry is needed for it: the notice already spoke.
|
|
1542
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
1543
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner, role: "owner" });
|
|
1544
|
+
}
|
|
1430
1545
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
1431
1546
|
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
1432
1547
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
@@ -1475,7 +1590,7 @@ export class Tasks {
|
|
|
1475
1590
|
if (this.isPii(task) || !this.d.memory) return;
|
|
1476
1591
|
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
|
|
1477
1592
|
// not clear (#198), is not sent (#69).
|
|
1478
|
-
if (withheld || !this.nameable(text)) return this.
|
|
1593
|
+
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
1479
1594
|
void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
1480
1595
|
}
|
|
1481
1596
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { personEnv } from "./child-process.ts";
|
|
3
|
+
import { openStartTerminal, orcaExecutable, shellQuote, startTerminalWorktree, type CommandRunner } from "../cli/terminal-recovery.ts";
|
|
4
|
+
|
|
5
|
+
/** #269: where a terminal for a hub-made start comes from, or why there is none. */
|
|
6
|
+
export type TerminalOpened = { ok: true; via: string } | { ok: false; why: string; detail?: string };
|
|
7
|
+
export interface TerminalOpener {
|
|
8
|
+
/** Whether a start could open a terminal now; opens nothing. */
|
|
9
|
+
available(): TerminalOpened;
|
|
10
|
+
/** A terminal running `argv`, which the caller assembled from fixed words: nothing here adds to it. */
|
|
11
|
+
open(title: string, argv: readonly string[]): Promise<TerminalOpened>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ponytail: a template command still running after this long is taken to be the terminal itself, so one that fails
|
|
15
|
+
// later is reported as opened; a provider-specific readiness check (as recovery has for Orca) if that ever misleads.
|
|
16
|
+
const SETTLE_MS = 1500;
|
|
17
|
+
const PLACEHOLDER = /\{(command|title|cwd)\}/g;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The providers, in order: the machine-local `terminal.open` command template, then Orca (the path recovery uses)
|
|
21
|
+
* when this project has an Orca worktree. `run` replaces the Orca CLI in tests.
|
|
22
|
+
*/
|
|
23
|
+
export function terminalOpener(opts: { template: string[]; cwd: string; stateDir: string; env?: NodeJS.ProcessEnv; run?: CommandRunner }): TerminalOpener {
|
|
24
|
+
const env = opts.env ?? process.env;
|
|
25
|
+
const worktree = () => startTerminalWorktree(opts.cwd, opts.stateDir, env);
|
|
26
|
+
const available = (): TerminalOpened => {
|
|
27
|
+
if (opts.template.length) return { ok: true, via: "terminal.open" };
|
|
28
|
+
if (!worktree()) return { ok: false, why: "no terminal.open command is configured and this project has no Orca worktree on record" };
|
|
29
|
+
if (!opts.run && !Bun.which(orcaExecutable())) return { ok: false, why: `the ${orcaExecutable()} command is not on PATH` };
|
|
30
|
+
return { ok: true, via: "orca" };
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
available,
|
|
34
|
+
async open(title, argv) {
|
|
35
|
+
const can = available();
|
|
36
|
+
if (!can.ok) return can;
|
|
37
|
+
// One command line, each word quoted for a POSIX shell: the terminal's shell runs exactly these words.
|
|
38
|
+
const command = argv.map(shellQuote).join(" ");
|
|
39
|
+
try {
|
|
40
|
+
if (can.via === "orca") {
|
|
41
|
+
await openStartTerminal(worktree()!, command, title, opts.run ? { runner: opts.run } : undefined);
|
|
42
|
+
return can;
|
|
43
|
+
}
|
|
44
|
+
// One pass with a function: a placeholder inside a substituted value (a project path that contains "{cwd}")
|
|
45
|
+
// is not expanded again, and "$" in a path is not a replacement pattern. Only {command} is shell-quoted;
|
|
46
|
+
// {title} and {cwd} are raw text for an argv element of their own.
|
|
47
|
+
const values: Record<string, string> = { command, title, cwd: opts.cwd };
|
|
48
|
+
const [bin, ...args] = opts.template.map((part) => part.replace(PLACEHOLDER, (_, name: string) => values[name]!));
|
|
49
|
+
// Detached and unwatched: the terminal is the person's and outlives the hub.
|
|
50
|
+
const child = spawn(bin!, args, { cwd: opts.cwd, env: personEnv(env), stdio: "ignore", detached: true });
|
|
51
|
+
const failed = await new Promise<string | undefined>((resolve) => {
|
|
52
|
+
const timer = setTimeout(() => resolve(undefined), SETTLE_MS); // still running: it is the terminal itself
|
|
53
|
+
child.once("error", (error) => { clearTimeout(timer); resolve(`terminal.open could not start (${(error as NodeJS.ErrnoException).code ?? "error"})`); });
|
|
54
|
+
child.once("exit", (code, signal) => { clearTimeout(timer); resolve(code === 0 ? undefined : `terminal.open exited ${signal ?? code}`); });
|
|
55
|
+
});
|
|
56
|
+
child.unref();
|
|
57
|
+
return failed ? { ok: false, why: failed } : can;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// The provider's own text can name paths and its stderr: it goes to the hub's log, not to the caller.
|
|
60
|
+
return { ok: false, why: can.via === "orca" ? "Orca could not create the terminal" : "terminal.open could not be run", detail: (error as Error).message };
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { PEER_START_RETRY_MS } from "./start-mode.ts";
|
|
1
2
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { readFileSync } from "node:fs";
|
|
4
5
|
|
|
6
|
+
/** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
|
|
7
|
+
export interface DashboardSession { settings: boolean; settingsUntil?: number }
|
|
5
8
|
export interface DashboardOptions {
|
|
6
|
-
snapshot: (after: number, input?: Record<string, unknown
|
|
7
|
-
action: (input: Record<string, unknown
|
|
9
|
+
snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
10
|
+
action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
8
11
|
projects?: () => unknown | Promise<unknown>;
|
|
9
12
|
selectedProjectId?: string;
|
|
10
13
|
/** Injectable clock for expiry tests; production uses wall time. */
|
|
@@ -13,17 +16,20 @@ export interface DashboardOptions {
|
|
|
13
16
|
|
|
14
17
|
const TICKET_MS = 60_000;
|
|
15
18
|
const SESSION_MS = 60 * 60_000;
|
|
19
|
+
/** How long a session opened by `ahub ui --settings` may change settings that raise authority; it stays an ordinary session after. */
|
|
20
|
+
export const SETTINGS_MS = 15 * 60_000;
|
|
16
21
|
const LIMIT = 64;
|
|
17
22
|
const secret = () => randomBytes(32).toString("hex");
|
|
18
23
|
|
|
19
24
|
/** Separate browser surface. Only the authenticated control console may construct it and issue tickets. */
|
|
20
25
|
export function startDashboard(options: DashboardOptions) {
|
|
21
26
|
const now = options.now ?? Date.now;
|
|
22
|
-
const tickets = new Map<string, number>();
|
|
23
|
-
const sessions = new Map<string, number>();
|
|
27
|
+
const tickets = new Map<string, { expires: number; settings: boolean }>();
|
|
28
|
+
const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
|
|
24
29
|
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
25
30
|
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
26
|
-
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
31
|
+
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
32
|
+
.replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`);
|
|
27
33
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
28
34
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|
|
29
35
|
const headers = {
|
|
@@ -36,9 +42,13 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
36
42
|
const json = (body: unknown, status = 200, extra: Record<string, string> = {}) =>
|
|
37
43
|
new Response(JSON.stringify(body), { status, headers: { ...headers, "Content-Type": "application/json", ...extra } });
|
|
38
44
|
const reject = (status: number, error: string) => json({ ok: false, error }, status);
|
|
39
|
-
function prune(map: Map<string, number>) {
|
|
40
|
-
for (const [key,
|
|
45
|
+
function prune(map: Map<string, { expires: number }>) {
|
|
46
|
+
for (const [key, entry] of map) if (entry.expires <= now()) map.delete(key);
|
|
41
47
|
}
|
|
48
|
+
const sessionOf = (cookie: string | undefined): DashboardSession => {
|
|
49
|
+
const until = cookie ? sessions.get(cookie)?.settingsUntil : undefined;
|
|
50
|
+
return until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false };
|
|
51
|
+
};
|
|
42
52
|
const server = Bun.serve({
|
|
43
53
|
hostname: "127.0.0.1",
|
|
44
54
|
port: 0,
|
|
@@ -75,10 +85,11 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
75
85
|
if (path !== "/session" && (!cookie || !sessions.has(cookie))) return reject(401, "session expired; run ahub ui");
|
|
76
86
|
if (path === "/session") {
|
|
77
87
|
const ticket = typeof input.ticket === "string" ? input.ticket : "";
|
|
78
|
-
|
|
88
|
+
const issued = tickets.get(ticket);
|
|
89
|
+
if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
|
|
79
90
|
if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
|
|
80
91
|
const session = secret();
|
|
81
|
-
sessions.set(session, now() + SESSION_MS);
|
|
92
|
+
sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}) });
|
|
82
93
|
return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
|
|
83
94
|
}
|
|
84
95
|
if (path === "/projects") {
|
|
@@ -97,7 +108,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
97
108
|
const after = input.after ?? 0;
|
|
98
109
|
if (typeof after !== "number" || !Number.isSafeInteger(after) || after < 0) return reject(400, "invalid cursor");
|
|
99
110
|
try {
|
|
100
|
-
const result = await options.snapshot(after, input);
|
|
111
|
+
const result = await options.snapshot(after, input, sessionOf(cookie));
|
|
101
112
|
if (!result || typeof result !== "object" || Array.isArray(result)) return json(result);
|
|
102
113
|
const response = { ...(result as Record<string, unknown>) };
|
|
103
114
|
if (typeof response.projectId === "string" && typeof input.projectId === "string" && response.projectId !== input.projectId) return reject(409, "snapshot project changed; refresh the dashboard");
|
|
@@ -110,7 +121,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
110
121
|
}
|
|
111
122
|
}
|
|
112
123
|
try {
|
|
113
|
-
return json(await options.action(input));
|
|
124
|
+
return json(await options.action(input, sessionOf(cookie)));
|
|
114
125
|
} catch {
|
|
115
126
|
// Exceptions may quote private task text or backend details. Never echo them to the page.
|
|
116
127
|
return reject(400, "action failed; check its inputs and the terminal");
|
|
@@ -125,11 +136,12 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
125
136
|
const cookieName = `ahub_ui_${server.port}`;
|
|
126
137
|
return {
|
|
127
138
|
origin,
|
|
128
|
-
|
|
139
|
+
/** `settings`: the ticket of `ahub ui --settings`, whose session may raise authority for SETTINGS_MS (#269). */
|
|
140
|
+
issue(settings = false): string {
|
|
129
141
|
prune(tickets);
|
|
130
142
|
if (tickets.size >= LIMIT) tickets.delete(tickets.keys().next().value!);
|
|
131
143
|
const ticket = secret();
|
|
132
|
-
tickets.set(ticket, now() + TICKET_MS);
|
|
144
|
+
tickets.set(ticket, { expires: now() + TICKET_MS, settings });
|
|
133
145
|
return `${origin}/#${ticket}`;
|
|
134
146
|
},
|
|
135
147
|
stop() {
|