@staix/agent-hub 0.12.21 → 0.12.22
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 +10 -0
- package/README.md +13 -6
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +4 -0
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +13 -4
- package/docs/operations.md +253 -22
- package/docs/quickstart.md +6 -3
- package/docs/security.md +9 -6
- package/docs/smoke.md +61 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -5
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verified.json +21 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/acp.ts +10 -4
- package/src/adapters/codex-appserver.ts +27 -22
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +2 -2
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +137 -18
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +56 -2
- package/src/cli/upgrade.ts +27 -10
- package/src/hub/bus.ts +5 -3
- package/src/hub/daemon.ts +145 -60
- package/src/hub/events.ts +2 -1
- package/src/hub/peers.ts +2 -2
- package/src/hub/recovery-store.ts +38 -0
- package/src/hub/tasks.ts +32 -15
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +25 -6
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +17 -1
- package/src/ui/index.html +15 -11
package/src/hub/peers.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface PeerAdapter {
|
|
|
18
18
|
/** Optional: feed envelopes into the turn that is running now. Only called while `state === "busy"`. */
|
|
19
19
|
steer?(envs: Envelope[], deliveryId?: string): Promise<void>;
|
|
20
20
|
start(): Promise<void>;
|
|
21
|
-
stop(): Promise<void>;
|
|
21
|
+
stop(reason?: string): Promise<void>;
|
|
22
22
|
/** Set by the bus. The peer said something worth sharing. */
|
|
23
23
|
onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
|
|
24
24
|
/** Set by the bus. */
|
|
@@ -84,5 +84,5 @@ export abstract class BasePeer implements PeerAdapter {
|
|
|
84
84
|
|
|
85
85
|
abstract deliver(envs: Envelope[], deliveryId?: string): Promise<void>;
|
|
86
86
|
abstract start(): Promise<void>;
|
|
87
|
-
abstract stop(): Promise<void>;
|
|
87
|
+
abstract stop(reason?: string): Promise<void>;
|
|
88
88
|
}
|
|
@@ -74,6 +74,12 @@ export function recoveryCommand(op: { id: string; sourceRoot?: string }, action:
|
|
|
74
74
|
return `${cli} recovery ${action} ${op.id}${flags ? ` ${flags}` : ""}`;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** #272: the command above as an argv for `bun`, which a screen runs instead of printing; with no coordinator of its own, this release's. */
|
|
78
|
+
export function recoveryArgv(op: { id: string; sourceRoot?: string }, action: "status" | "resume" | "abort" | "dispose", flags: string[] = []): string[] {
|
|
79
|
+
const entry = op.sourceRoot && coordinatorCurrent(op.sourceRoot) ? join(op.sourceRoot, "src/cli/main.js") : join(import.meta.dir, "../cli/main.js");
|
|
80
|
+
return [entry, "recovery", action, op.id, ...flags];
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
/**
|
|
78
84
|
* Whether a coordinator has the #215 recovery commands (dispose, re-preparation, next actions).
|
|
79
85
|
* ponytail: a text sniff of its upgrade.ts; a rename or re-export reads as an older coordinator (the running release is
|
|
@@ -116,6 +122,38 @@ export function recoveryRunner(id: string, home = hubHome()): number | "unknown"
|
|
|
116
122
|
} catch { return "unknown"; } finally { db?.close(); }
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
/**
|
|
126
|
+
* #272: the pid of the runner that holds `id`, only when its claim carries a process signature that still matches.
|
|
127
|
+
* `recoveryRunner` answers a claim without one (a coordinator of 0.12.20 or older, or a `ps` that failed at claim
|
|
128
|
+
* time) from its bare pid, which is enough to wait for it and never enough to signal it.
|
|
129
|
+
*/
|
|
130
|
+
export function signedRunner(id: string, home = hubHome()): number | undefined {
|
|
131
|
+
const path = `${operationPath(id, home)}.runner.db`;
|
|
132
|
+
if (!existsSync(path)) return undefined;
|
|
133
|
+
let db: Database | undefined;
|
|
134
|
+
try {
|
|
135
|
+
db = new Database(path, { readonly: true });
|
|
136
|
+
db.run("PRAGMA busy_timeout = 3000");
|
|
137
|
+
if (!signed(db)) return undefined;
|
|
138
|
+
const row = db.query("SELECT pid, signature FROM runner WHERE slot = 1").get() as { pid: number; signature: string | null } | null;
|
|
139
|
+
if (!row?.signature || !Number.isSafeInteger(row.pid) || row.pid < 1) return undefined;
|
|
140
|
+
return processSignature(row.pid) === row.signature ? row.pid : undefined;
|
|
141
|
+
} catch { return undefined; } finally { db?.close(); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** #272: stop that verified runner and wait until no runner holds the operation. False when one still does, or cannot be verified. */
|
|
145
|
+
export async function stopSignedRunner(id: string, home = hubHome(), waitMs = 5000): Promise<boolean> {
|
|
146
|
+
if (recoveryRunner(id, home) === undefined) return true;
|
|
147
|
+
const pid = signedRunner(id, home);
|
|
148
|
+
if (pid === undefined) return false;
|
|
149
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* gone meanwhile */ }
|
|
150
|
+
for (const deadline = Date.now() + waitMs; Date.now() < deadline;) {
|
|
151
|
+
if (recoveryRunner(id, home) === undefined) return true;
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
/** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
|
|
120
158
|
export function claimRunner(id: string, home = hubHome()): () => void {
|
|
121
159
|
const path = `${operationPath(id, home)}.runner.db`;
|
package/src/hub/tasks.ts
CHANGED
|
@@ -920,9 +920,20 @@ export class Tasks {
|
|
|
920
920
|
}
|
|
921
921
|
}
|
|
922
922
|
|
|
923
|
-
/**
|
|
924
|
-
|
|
925
|
-
|
|
923
|
+
/**
|
|
924
|
+
* The one guard for a publish or notify after a board write (#231, #243): the write took effect, so a failure
|
|
925
|
+
* never throws back to the caller. A failed publish becomes a console notice naming what was lost; a failed notify
|
|
926
|
+
* is dropped (a second attempt would fail the same way). A lost publish is not retried here: the delivery
|
|
927
|
+
* journal's own recovery covers a storage error.
|
|
928
|
+
*/
|
|
929
|
+
private releaseNotice(line: string, run?: () => unknown): void {
|
|
930
|
+
if (!run) {
|
|
931
|
+
try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
try { run(); } catch (e) {
|
|
935
|
+
try { this.d.notify(`${line}: ${e instanceof Error ? e.message : String(e)}`); } catch { /* best effort */ }
|
|
936
|
+
}
|
|
926
937
|
}
|
|
927
938
|
|
|
928
939
|
private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
|
|
@@ -1125,7 +1136,7 @@ export class Tasks {
|
|
|
1125
1136
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1126
1137
|
this.checking.set(task.id, task.owner);
|
|
1127
1138
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
1128
|
-
this.
|
|
1139
|
+
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
1129
1140
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
1130
1141
|
this.pendingChecks++;
|
|
1131
1142
|
this.checkQueue = this.checkQueue
|
|
@@ -1255,11 +1266,11 @@ export class Tasks {
|
|
|
1255
1266
|
this.tellCompleted(next, summary, withheld);
|
|
1256
1267
|
if (!reviewer) {
|
|
1257
1268
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1258
|
-
this.
|
|
1269
|
+
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
1259
1270
|
await this.releaseDependents(next);
|
|
1260
1271
|
}
|
|
1261
|
-
else if (reviewer === USER) this.
|
|
1262
|
-
else this.sendReview(next, reviewer);
|
|
1272
|
+
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]`);
|
|
1273
|
+
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
|
|
1263
1274
|
return next;
|
|
1264
1275
|
}
|
|
1265
1276
|
|
|
@@ -1279,7 +1290,8 @@ export class Tasks {
|
|
|
1279
1290
|
cohort.held.add(task.id);
|
|
1280
1291
|
continue;
|
|
1281
1292
|
}
|
|
1282
|
-
this.
|
|
1293
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${hit.task.owner}`, () =>
|
|
1294
|
+
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld)));
|
|
1283
1295
|
}
|
|
1284
1296
|
}
|
|
1285
1297
|
|
|
@@ -1357,7 +1369,8 @@ export class Tasks {
|
|
|
1357
1369
|
}
|
|
1358
1370
|
}
|
|
1359
1371
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
1360
|
-
this.
|
|
1372
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
1373
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
1361
1374
|
await this.releaseDependents(next);
|
|
1362
1375
|
return next;
|
|
1363
1376
|
}
|
|
@@ -1370,11 +1383,13 @@ export class Tasks {
|
|
|
1370
1383
|
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
1371
1384
|
if (moved.owner !== rejected.owner) return moved;
|
|
1372
1385
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
1373
|
-
this.
|
|
1386
|
+
this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
|
|
1387
|
+
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
1388
|
return moved;
|
|
1375
1389
|
}
|
|
1376
1390
|
const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
|
|
1377
|
-
this.
|
|
1391
|
+
this.releaseNotice(`task ${this.publicTitle(reopened)}: could not tell ${reopened.owner ?? "its owner"} changes were requested`, () =>
|
|
1392
|
+
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
1393
|
return reopened;
|
|
1379
1394
|
}
|
|
1380
1395
|
|
|
@@ -1391,10 +1406,12 @@ export class Tasks {
|
|
|
1391
1406
|
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
1407
|
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
|
|
1393
1408
|
if (next.owner && next.owner !== from) {
|
|
1394
|
-
|
|
1409
|
+
// The move is on the board (#243): review() reaches here after its changes_requested write.
|
|
1410
|
+
this.releaseNotice(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
|
|
1395
1411
|
this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
|
|
1396
|
-
if (from) this.
|
|
1397
|
-
|
|
1412
|
+
if (from) this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${from} it moved to ${next.owner}`, () =>
|
|
1413
|
+
this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next)));
|
|
1414
|
+
} else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
1398
1415
|
return this.d.board.get(next.id)!;
|
|
1399
1416
|
}
|
|
1400
1417
|
|
|
@@ -1475,7 +1492,7 @@ export class Tasks {
|
|
|
1475
1492
|
if (this.isPii(task) || !this.d.memory) return;
|
|
1476
1493
|
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
|
|
1477
1494
|
// not clear (#198), is not sent (#69).
|
|
1478
|
-
if (withheld || !this.nameable(text)) return this.
|
|
1495
|
+
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
1479
1496
|
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
1497
|
}
|
|
1481
1498
|
|
package/src/local/deny.ts
CHANGED
|
@@ -10,7 +10,6 @@ export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/arc
|
|
|
10
10
|
export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
|
|
11
11
|
|
|
12
12
|
const hasSegment = (rel: string, seg: string) => `/${rel}/`.includes(`/${seg}/`);
|
|
13
|
-
export { hasSegment };
|
|
14
13
|
|
|
15
14
|
/** True when a path names something the worker must never read, write or report. `extra`: substrings from `local.deny`. */
|
|
16
15
|
export function isDenied(path: string, extra: string[] = []): boolean {
|
|
@@ -27,6 +26,54 @@ const sbplEscape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
27
26
|
*/
|
|
28
27
|
export const sbplString = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
29
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The 16 code points HFS+ ignores in file names: U+200C-200F, U+202A-202E, U+206A-206F, U+FEFF (a probe found
|
|
31
|
+
* case-insensitive APFS ignores none). `mkdir .g<U+200D>it` on HFS+ makes what everything afterwards opens as
|
|
32
|
+
* `.git` (#270). One source for the seatbelt profile's folded patterns; the file tools' `foldSegment` removes
|
|
33
|
+
* every default-ignorable, a superset of these.
|
|
34
|
+
*/
|
|
35
|
+
export const HFS_IGNORABLE_POINTS = ["\u200C", "\u200D", "\u200E", "\u200F", "\u202A", "\u202B", "\u202C", "\u202D", "\u202E", "\u206A", "\u206B", "\u206C", "\u206D", "\u206E", "\u206F", "\uFEFF"];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One HFS+-ignorable code point as a seatbelt regex atom. A group alternation, not a character class: the
|
|
39
|
+
* seatbelt regex engine is byte-oriented, so a quantifier after a multi-byte class member binds to its last
|
|
40
|
+
* byte and a class never matches the plain name (verified with the real sandbox-exec, #270).
|
|
41
|
+
*/
|
|
42
|
+
const HFS_IGNORABLE = `(${HFS_IGNORABLE_POINTS.join("|")})`;
|
|
43
|
+
|
|
44
|
+
/** A regex source matching `name` with any number of HFS+-ignorable code points interleaved, at the ends included. */
|
|
45
|
+
const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) => sbplEscape(c)).join(`${HFS_IGNORABLE}*`)}${HFS_IGNORABLE}*`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Seatbelt rules for the hub's and git's own names (#270). `.agenthub` is refused anywhere under the project root.
|
|
49
|
+
* `.git` is refused at any depth, the name itself included, so a planted repository cannot arrive by creation,
|
|
50
|
+
* rename, symlink or gitfile, and `config` and `hooks` directly under it are folded too; the cost is that
|
|
51
|
+
* `git init`, `git clone` and `git worktree add` no longer run inside the sandbox. `commondir` is refused at any
|
|
52
|
+
* depth below a `.git` and below each external git dir: that is where git keeps it for linked worktrees and
|
|
53
|
+
* submodules. An external git dir's `config`/`hooks` rules cover only its own children (plus the hooks
|
|
54
|
+
* directory's contents): at any depth they would deny refs named `config` or `hooks` in a worktree or submodule
|
|
55
|
+
* project (`git branch fix/config` fails on `.git/logs/refs/heads/fix/config`). Every pattern keeps its `^`
|
|
56
|
+
* anchor (an unanchored starred alternation does not match in this engine) and no folded literal contains the
|
|
57
|
+
* root: an SBPL string literal dies at 1024 bytes ("Error reading string"), which a long project path would trip.
|
|
58
|
+
*/
|
|
59
|
+
export function hubWriteRegexes(root: string, gitDirs: string[] = []): string[] {
|
|
60
|
+
const git = hfsFoldedName(".git");
|
|
61
|
+
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
62
|
+
const external = gitDirs.flatMap((d) => [
|
|
63
|
+
`(require-all ${re(`^${sbplEscape(d)}/[^/]+$`)} ${re(`^.*/(${hfsFoldedName("config")}|${hfsFoldedName("hooks")})$`)})`,
|
|
64
|
+
`(subpath ${sbplString(`${d}/hooks`)})`,
|
|
65
|
+
`(require-all (subpath ${sbplString(d)}) ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
66
|
+
]);
|
|
67
|
+
return [
|
|
68
|
+
`(require-all (subpath ${sbplString(root)}) ${re(`^.*/${hfsFoldedName(".agenthub")}(/|$)`)})`,
|
|
69
|
+
re(`^.*/${git}$`),
|
|
70
|
+
re(`^.*/${git}/${hfsFoldedName("config")}$`),
|
|
71
|
+
re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
|
|
72
|
+
`(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
73
|
+
...external,
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
/**
|
|
31
78
|
* Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
|
|
32
79
|
* anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
|
package/src/local/sandbox.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { realPath } from "../hub/project.ts";
|
|
6
|
-
import { denyRegexes, sbplString } from "./deny.ts";
|
|
6
|
+
import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
|
|
7
7
|
|
|
8
8
|
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
9
9
|
export const OUTPUT_CAP = 20_000;
|
|
@@ -102,8 +102,11 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
102
102
|
...start,
|
|
103
103
|
"(deny file-write*)",
|
|
104
104
|
`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
|
|
105
|
-
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
|
|
106
|
-
|
|
105
|
+
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
|
|
106
|
+
// .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
|
|
107
|
+
// at any depth (creation, rename, symlink, gitfile), and the external git dirs get folded hooks/config rules
|
|
108
|
+
// (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
|
|
109
|
+
`(deny file-write* ${hubWriteRegexes(root, gitDirs).join(" ")})`,
|
|
107
110
|
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
|
108
111
|
`(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
|
|
109
112
|
// Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
|
package/src/local/tools.ts
CHANGED
|
@@ -7,6 +7,15 @@ import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
|
|
|
7
7
|
|
|
8
8
|
export { isDenied };
|
|
9
9
|
|
|
10
|
+
export type ToolApproval = boolean | "expired" | "aborted";
|
|
11
|
+
export interface ApprovalProvenance { source: "person" | "automatic" | "expired" | "aborted"; answerEpoch: number; eligibleExpiry?: boolean; }
|
|
12
|
+
export type ApprovalObserver = (provenance: ApprovalProvenance) => void;
|
|
13
|
+
export const APPROVAL_EXPIRED_TEXT = "error: approval expired: no person answered; do not retry this call; hand the task off or stop";
|
|
14
|
+
export class ApprovalWaitStop extends Error {}
|
|
15
|
+
const approvalError = (picked: ToolApproval, denied: string): string | undefined => picked === true ? undefined
|
|
16
|
+
: picked === "expired" ? APPROVAL_EXPIRED_TEXT
|
|
17
|
+
: picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
|
|
18
|
+
|
|
10
19
|
export interface ToolContext {
|
|
11
20
|
cwd: string;
|
|
12
21
|
/** Extra denylist entries from config: substrings of the project-relative path. */
|
|
@@ -17,8 +26,10 @@ export interface ToolContext {
|
|
|
17
26
|
sandboxEnv?: Record<string, string>;
|
|
18
27
|
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
19
28
|
signal?: AbortSignal;
|
|
20
|
-
/**
|
|
21
|
-
permit: (title: string, tool?: string, signal?: AbortSignal,
|
|
29
|
+
/** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
|
|
30
|
+
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver) => Promise<ToolApproval>;
|
|
31
|
+
/** Trusted process status, independent of what its stdout says. */
|
|
32
|
+
onProcessExit?: (code: number | null) => void;
|
|
22
33
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
23
34
|
send: (text: string, to?: string[]) => string;
|
|
24
35
|
}
|
|
@@ -131,7 +142,8 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
131
142
|
let file = guardPath(ctx, String(a.path), "write");
|
|
132
143
|
const approvedTarget = file;
|
|
133
144
|
const content = String(a.content ?? "");
|
|
134
|
-
|
|
145
|
+
const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file), "error: the user did not approve this write");
|
|
146
|
+
if (refused) return refused;
|
|
135
147
|
if (ctx.signal?.aborted) return "error: turn cancelled before write";
|
|
136
148
|
file = guardPath(ctx, String(a.path), "write");
|
|
137
149
|
if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
|
|
@@ -145,7 +157,8 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
145
157
|
let text = readFileSync(file, "utf8");
|
|
146
158
|
let count = text.split(String(a.old)).length - 1;
|
|
147
159
|
if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
|
|
148
|
-
|
|
160
|
+
const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file), "error: the user did not approve this edit");
|
|
161
|
+
if (refused) return refused;
|
|
149
162
|
if (ctx.signal?.aborted) return "error: turn cancelled before edit";
|
|
150
163
|
// Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
|
|
151
164
|
file = guardPath(ctx, String(a.path), "write");
|
|
@@ -161,8 +174,10 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
161
174
|
if (!command.trim()) return "error: empty command";
|
|
162
175
|
// The approver sees the whole command, not a prefix: what is hidden cannot be approved.
|
|
163
176
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
164
|
-
|
|
177
|
+
const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal), "error: the user did not approve this command");
|
|
178
|
+
if (refused) return refused;
|
|
165
179
|
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
180
|
+
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
166
181
|
return `${res.output}\n(exit ${res.code})`;
|
|
167
182
|
}
|
|
168
183
|
case "git": {
|
|
@@ -171,9 +186,13 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
171
186
|
if (!GIT_READ.has(sub) && !GIT_WRITE.has(sub)) return `error: git ${sub} is not available to the local worker`;
|
|
172
187
|
const problem = gitArgsProblem(args, ctx.deny);
|
|
173
188
|
if (problem) return `error: git ${sub}: ${problem}`;
|
|
174
|
-
if (GIT_WRITE.has(sub)
|
|
189
|
+
if (GIT_WRITE.has(sub)) {
|
|
190
|
+
const refused = approvalError(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal), "error: the user did not approve this git command");
|
|
191
|
+
if (refused) return refused;
|
|
192
|
+
}
|
|
175
193
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
176
194
|
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
195
|
+
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
177
196
|
return `${res.output}\n(exit ${res.code})`;
|
|
178
197
|
}
|
|
179
198
|
case "hub_send":
|
package/src/pi/extension.ts
CHANGED
|
@@ -19,6 +19,7 @@ let pollStopped = false;
|
|
|
19
19
|
let toolSteps = 0;
|
|
20
20
|
let lastActivity = 0;
|
|
21
21
|
let usageSeq = 0;
|
|
22
|
+
let shellSeq = 0;
|
|
22
23
|
let forcedFailure = "";
|
|
23
24
|
let turnGeneration = 0;
|
|
24
25
|
let sessionId = "";
|
|
@@ -33,9 +34,13 @@ async function post(path: string, body: unknown): Promise<any> {
|
|
|
33
34
|
if (!response.ok) throw new Error(`Pi bridge HTTP ${response.status}`);
|
|
34
35
|
return response.json();
|
|
35
36
|
}
|
|
36
|
-
async function admitBudget(unit: BudgetUnit, idleUserBash = false): Promise<{ allowed: boolean; reservation?: string }> {
|
|
37
|
+
async function admitBudget(unit: BudgetUnit, idleUserBash = false, generation = turnGeneration, callSessionId = sessionId): Promise<{ allowed: boolean; reservation?: string; reason?: string }> {
|
|
38
|
+
const current = () => generation === turnGeneration && callSessionId === sessionId;
|
|
39
|
+
const stale = () => ({ allowed: false, reason: "Pi tool lineage ended during admission; do not retry the old call" });
|
|
40
|
+
if (!current()) return stale();
|
|
37
41
|
try {
|
|
38
|
-
const result = await post("/budget", { unit, generation
|
|
42
|
+
const result = await post("/budget", { unit, generation, ...(idleUserBash ? { idleUserBash: true } : {}) });
|
|
43
|
+
if (!current()) return stale();
|
|
39
44
|
const decisions = Array.isArray(result?.decisions) ? result.decisions : [];
|
|
40
45
|
const denied = decisions.find((item: any) => item?.allowed === false);
|
|
41
46
|
if (!denied) return { allowed: true, ...(idleUserBash && typeof result?.reservation === "string" ? { reservation: result.reservation } : {}) };
|
|
@@ -43,12 +48,14 @@ async function admitBudget(unit: BudgetUnit, idleUserBash = false): Promise<{ al
|
|
|
43
48
|
if (idleUserBash) return { allowed: false };
|
|
44
49
|
forcedFailure = reason;
|
|
45
50
|
} catch (error) {
|
|
51
|
+
if (!current()) return stale();
|
|
46
52
|
if (idleUserBash) return { allowed: false };
|
|
47
53
|
forcedFailure = `execution budget admission unavailable: ${(error as Error).message}`;
|
|
48
54
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
const reason = forcedFailure;
|
|
56
|
+
try { await post("/event", { type: "agent_end", generation, failed: true, error: reason }); } catch { /* abort remains authoritative when the bridge is unavailable */ }
|
|
57
|
+
if (current()) runtimeCtx?.abort?.();
|
|
58
|
+
return { allowed: false, reason };
|
|
52
59
|
}
|
|
53
60
|
async function poll(pi: ExtensionAPI): Promise<void> {
|
|
54
61
|
while (!pollStopped) {
|
|
@@ -77,10 +84,13 @@ async function poll(pi: ExtensionAPI): Promise<void> {
|
|
|
77
84
|
shutdown?.();
|
|
78
85
|
} else if (command.type === "abort_budget") {
|
|
79
86
|
if (Number.isSafeInteger(command.generation) && command.generation === turnGeneration) {
|
|
80
|
-
|
|
87
|
+
if (typeof runtimeCtx?.abort !== "function") throw new Error("Pi runtime cannot abort the current turn");
|
|
88
|
+
const generation = turnGeneration, callSessionId = sessionId;
|
|
89
|
+
const reason = command.cause === "approval" && typeof command.reason === "string" && command.reason
|
|
90
|
+
? command.reason : "execution budget exhausted: elapsed_ms wall cap reached";
|
|
81
91
|
forcedFailure = reason;
|
|
82
|
-
await post("/event", { type: "agent_end", generation
|
|
83
|
-
runtimeCtx?.abort?.();
|
|
92
|
+
await post("/event", { type: "agent_end", generation, failed: true, error: reason });
|
|
93
|
+
if (generation === turnGeneration && callSessionId === sessionId) runtimeCtx?.abort?.();
|
|
84
94
|
}
|
|
85
95
|
}
|
|
86
96
|
await post("/ack", { id: command.id, ok: true });
|
|
@@ -107,7 +117,7 @@ export default function(pi: ExtensionAPI): void {
|
|
|
107
117
|
const state = ctx.sessionManager.getHeader();
|
|
108
118
|
sessionId = String(state?.id ?? "");
|
|
109
119
|
try {
|
|
110
|
-
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
120
|
+
const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function" });
|
|
111
121
|
if (claimed?.ok === false) { ctx.shutdown?.(); return; }
|
|
112
122
|
} catch (error) { ctx.shutdown?.(); throw error; }
|
|
113
123
|
if (!pollStarted) { pollStarted = true; void poll(pi); }
|
|
@@ -140,8 +150,10 @@ export default function(pi: ExtensionAPI): void {
|
|
|
140
150
|
});
|
|
141
151
|
pi.on("before_provider_request", async (_event: any, ctx: any) => {
|
|
142
152
|
if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return undefined; }
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
const generation = turnGeneration, callSessionId = sessionId;
|
|
154
|
+
const current = () => generation === turnGeneration && callSessionId === sessionId;
|
|
155
|
+
try { if (!(await admitBudget("model_calls", false, generation, callSessionId)).allowed && current()) ctx.abort?.(); }
|
|
156
|
+
catch (error) { if (current()) { forcedFailure = `execution budget admission failed: ${(error as Error).message}`; ctx.abort?.(); } }
|
|
145
157
|
return undefined;
|
|
146
158
|
});
|
|
147
159
|
pi.on("agent_settled", async (_event: any, ctx: any) => {
|
|
@@ -151,10 +163,11 @@ export default function(pi: ExtensionAPI): void {
|
|
|
151
163
|
await post("/event", { type: "agent_settled", generation: turnGeneration, ...(text ? { text } : {}) });
|
|
152
164
|
});
|
|
153
165
|
pi.on("user_bash", async (event: any) => {
|
|
154
|
-
const
|
|
166
|
+
const generation = turnGeneration, callSessionId = sessionId;
|
|
167
|
+
const admission = await admitBudget("tool_calls", true, generation, callSessionId);
|
|
155
168
|
if (!admission.allowed || !admission.reservation) return { cancel: true };
|
|
156
|
-
const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}`, purpose: "idle_user_bash", generation:
|
|
157
|
-
return { result: { output: String(result.text ?? result), exitCode:
|
|
169
|
+
const result = await post("/tool", { name: "bash", args: { command: event.command, cwd: event.cwd }, toolCallId: `pi-shell-${Date.now()}-${++shellSeq}`, purpose: "idle_user_bash", generation, sessionId: callSessionId, reservation: admission.reservation });
|
|
170
|
+
return { result: { output: String(result.text ?? result), exitCode: typeof result.exitCode === "number" ? result.exitCode : undefined, cancelled: result.exitCode === null, truncated: false } };
|
|
158
171
|
});
|
|
159
172
|
// Managed sessions may only be handed over by PiPeer after it has fenced the
|
|
160
173
|
// owner and verified the replacement identity. User /fork and /resume would
|
|
@@ -164,8 +177,11 @@ export default function(pi: ExtensionAPI): void {
|
|
|
164
177
|
pi.on("session_shutdown", async () => { pollStopped = true; await post("/event", { type: "session_shutdown" }); });
|
|
165
178
|
for (const raw of (() => { try { return JSON.parse(process.env.AGENTHUB_PI_TOOLS ?? "[]") as any[]; } catch { return []; } })()) {
|
|
166
179
|
if (!raw || typeof raw.name !== "string" || !raw.parameters) continue;
|
|
167
|
-
pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown) {
|
|
168
|
-
|
|
180
|
+
pi.registerTool({ name: raw.name, label: raw.name, description: raw.description ?? raw.name, parameters: raw.parameters, async execute(toolCallId: string, params: unknown, signal?: AbortSignal) {
|
|
181
|
+
const generation = turnGeneration, callSessionId = sessionId;
|
|
182
|
+
if (signal?.aborted) return { content: [{ type: "text", text: "error: tool call cancelled before execution" }], details: {}, isError: true };
|
|
183
|
+
const admission = await admitBudget("tool_calls", false, generation, callSessionId);
|
|
184
|
+
if (!admission.allowed) return { content: [{ type: "text", text: `error: ${admission.reason ?? forcedFailure}` }], details: {}, isError: true };
|
|
169
185
|
if (toolSteps++ >= maxSteps) {
|
|
170
186
|
const reason = `Pi tool step limit ${maxSteps} reached`;
|
|
171
187
|
forcedFailure = reason;
|
|
@@ -173,12 +189,19 @@ export default function(pi: ExtensionAPI): void {
|
|
|
173
189
|
// event, so the adapter binds it while the turn still owns it. The counter already counts the
|
|
174
190
|
// rejected pre-effect invocation (toolSteps++ above); its side effect never executes. The
|
|
175
191
|
// reason text stays the failure record; the signal adds the validated counts.
|
|
176
|
-
try { await post("/event", { type: "ceiling", kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: toolSteps, limit: maxSteps, sessionId, generation
|
|
177
|
-
await post("/event", { type: "agent_end", generation
|
|
178
|
-
runtimeCtx?.abort?.();
|
|
192
|
+
try { await post("/event", { type: "ceiling", kind: PI_CEILING_KIND, unit: PI_CEILING_UNIT, count: toolSteps, limit: maxSteps, sessionId: callSessionId, generation }); } catch { /* the agent_end failure below remains authoritative */ }
|
|
193
|
+
await post("/event", { type: "agent_end", generation, failed: true, error: reason });
|
|
194
|
+
if (generation === turnGeneration && callSessionId === sessionId) runtimeCtx?.abort?.();
|
|
179
195
|
return { content: [{ type: "text", text: `error: ${reason}` }], details: {}, isError: true };
|
|
180
196
|
}
|
|
181
|
-
const
|
|
197
|
+
const aborted = () => { void post("/event", { type: "tool_abort", sessionId: callSessionId, generation, toolCallId }).catch(() => undefined); };
|
|
198
|
+
if (signal?.aborted) return { content: [{ type: "text", text: "error: tool call cancelled before execution" }], details: {}, isError: true };
|
|
199
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
200
|
+
let result: any;
|
|
201
|
+
try {
|
|
202
|
+
result = await post("/tool", { name: raw.name, args: params, toolCallId, sessionId: callSessionId, generation });
|
|
203
|
+
if (signal?.aborted) aborted();
|
|
204
|
+
} finally { signal?.removeEventListener("abort", aborted); }
|
|
182
205
|
const text = String(result.text ?? result);
|
|
183
206
|
// Pi 1.0.1 reads isError === true alone (#181). The bridge computes `failed` with the
|
|
184
207
|
// managed-tool failure contract (toolResultFailed); the extension never re-parses the text.
|
package/src/pi/tool-receipts.ts
CHANGED
|
@@ -3,6 +3,22 @@ import { Database } from "bun:sqlite";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { PreEffectToolRefusal } from "../hub/tool-refusal.ts";
|
|
5
5
|
|
|
6
|
+
export const PI_TOOL_RESULT_CAP = 100_000;
|
|
7
|
+
/** Bound encoded text before wrapping it so escaping never cuts off trusted exit metadata. */
|
|
8
|
+
export function packPiShellResult(text: string, exitCode: number | null): string {
|
|
9
|
+
const pack = (value: string) => JSON.stringify({ kind: "user-bash-result", text: value, exitCode });
|
|
10
|
+
const full = pack(text);
|
|
11
|
+
if (full.length <= PI_TOOL_RESULT_CAP) return full;
|
|
12
|
+
const suffix = "\n[output truncated]";
|
|
13
|
+
let low = 0, high = text.length;
|
|
14
|
+
while (low < high) {
|
|
15
|
+
const middle = Math.ceil((low + high) / 2);
|
|
16
|
+
if (pack(text.slice(0, middle) + suffix).length <= PI_TOOL_RESULT_CAP) low = middle;
|
|
17
|
+
else high = middle - 1;
|
|
18
|
+
}
|
|
19
|
+
return pack(text.slice(0, low) + suffix);
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/** Private, session-scoped dedupe. An interrupted effect is never automatically repeated. */
|
|
7
23
|
export class PiToolReceipts {
|
|
8
24
|
private readonly db: Database;
|
|
@@ -33,7 +49,7 @@ export class PiToolReceipts {
|
|
|
33
49
|
const claim = this.db.query("INSERT OR IGNORE INTO pi_tool_receipts(session,call_id,fingerprint,state) VALUES(?,?,?,'pending')").run(session, callId, fingerprint);
|
|
34
50
|
if (!claim.changes) return this.execute(session, callId, name, args, run);
|
|
35
51
|
const settle = (result: string): string => {
|
|
36
|
-
const text = result.slice(0,
|
|
52
|
+
const text = result.slice(0, PI_TOOL_RESULT_CAP);
|
|
37
53
|
this.db.query("UPDATE pi_tool_receipts SET state='done',result=? WHERE session=? AND call_id=?").run(text, session, callId);
|
|
38
54
|
return text;
|
|
39
55
|
};
|
package/src/ui/index.html
CHANGED
|
@@ -178,6 +178,20 @@ function renderTaskStage(stage) {
|
|
|
178
178
|
for (const [index, label] of ['Proposed', 'In progress', 'In review', 'Approved'].entries()) row.append(el('span', `${index < stage.stage ? '[x]' : '[ ]'} ${label}${index < 3 ? ' > ' : ''}`, index < stage.stage ? 'reached' : ''));
|
|
179
179
|
row.append(el('span', ` / Stage ${stage.stage}/4: ${stage.label}`)); return row;
|
|
180
180
|
}
|
|
181
|
+
function renderApprovals(permissions) {
|
|
182
|
+
const terminalWaiting = permissions.filter((permission) => permission.peer === 'pi' || permission.peer === 'local').length;
|
|
183
|
+
$('permission-count').textContent = permissions.length + ' waiting' + (terminalWaiting ? ', ' + terminalWaiting + ' Pi/local: answer in the console or ahub tail / ahub permit' : '');
|
|
184
|
+
update('permissions', permissions, (target) => {
|
|
185
|
+
if (!permissions.length) return empty(target, 'No approvals waiting.');
|
|
186
|
+
for (const permission of permissions) {
|
|
187
|
+
const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
|
|
188
|
+
if (permission.terminalOnly) row.append(el('p', 'Review in the console or ahub tail; answer with ahub permit. You can deny here.', 'meta'));
|
|
189
|
+
const actions = el('div', undefined, 'actions');
|
|
190
|
+
for (const option of permission.options) { const deny = option.kind === 'reject_once' || option.kind === 'reject_always'; if (permission.terminalOnly && !deny) continue; actions.append(button(option.name, {action:'permit', id:permission.id, option:option.optionId}, deny ? 'danger' : 'primary')); }
|
|
191
|
+
row.append(actions); target.append(row);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
181
195
|
function render(snapshot) {
|
|
182
196
|
const peers = snapshot.status.peers || {}, ids = Object.keys(peers);
|
|
183
197
|
$('peer-count').textContent = ids.length + ' peers';
|
|
@@ -227,17 +241,7 @@ function render(snapshot) {
|
|
|
227
241
|
target.append(row);
|
|
228
242
|
}
|
|
229
243
|
});
|
|
230
|
-
|
|
231
|
-
update('permissions', snapshot.permissions, (target) => {
|
|
232
|
-
if (!snapshot.permissions.length) return empty(target, 'No approvals waiting.');
|
|
233
|
-
for (const permission of snapshot.permissions) {
|
|
234
|
-
const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
|
|
235
|
-
if (permission.terminalOnly) row.append(el('p', 'Review details and allow in the terminal with ahub tail. You can deny here.', 'meta'));
|
|
236
|
-
const actions = el('div', undefined, 'actions');
|
|
237
|
-
for (const option of permission.options) { const deny = option.kind === 'reject_once' || option.kind === 'reject_always'; if (permission.terminalOnly && !deny) continue; actions.append(button(option.name, {action:'permit', id:permission.id, option:option.optionId}, deny ? 'danger' : 'primary')); }
|
|
238
|
-
row.append(actions); target.append(row);
|
|
239
|
-
}
|
|
240
|
-
});
|
|
244
|
+
renderApprovals(snapshot.permissions);
|
|
241
245
|
update('budget', snapshot.budget, (target) => {
|
|
242
246
|
if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
|
|
243
247
|
for (const [peer, budget] of Object.entries(snapshot.budget)) {
|