toolroll 0.9.45 → 0.9.47

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/activity-line.d.ts +26 -0
  3. package/dist/activity-line.js +47 -0
  4. package/dist/api-tokens.d.ts +33 -0
  5. package/dist/api-tokens.js +39 -0
  6. package/dist/audit-cli.d.ts +17 -0
  7. package/dist/audit-cli.js +86 -0
  8. package/dist/browser/workspace.js +25 -25
  9. package/dist/browser-workspace.d.ts +18 -1
  10. package/dist/browser-workspace.js +9 -3
  11. package/dist/builder.js +4 -2
  12. package/dist/cli-http.d.ts +5 -0
  13. package/dist/cli-http.js +12 -2
  14. package/dist/cli.js +7 -1
  15. package/dist/contracts/cli.d.ts +47 -0
  16. package/dist/contracts/cli.js +11 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/mcp-oauth.d.ts +53 -0
  20. package/dist/contracts/mcp-oauth.js +40 -0
  21. package/dist/credentials-ui.d.ts +5 -0
  22. package/dist/credentials-ui.js +16 -4
  23. package/dist/export.js +4 -3
  24. package/dist/flow-card-task.d.ts +2 -0
  25. package/dist/flow-card-task.js +57 -0
  26. package/dist/flow-live.d.ts +2 -2
  27. package/dist/flow-live.js +69 -8
  28. package/dist/flows-ui.js +15 -1
  29. package/dist/live.d.ts +14 -0
  30. package/dist/live.js +71 -22
  31. package/dist/mcp-http.d.ts +6 -1
  32. package/dist/mcp-http.js +22 -4
  33. package/dist/mcp-oauth.d.ts +82 -0
  34. package/dist/mcp-oauth.js +479 -0
  35. package/dist/mcp-person.js +1 -1
  36. package/dist/operate-remote-arguments.js +2 -0
  37. package/dist/operate-remote.d.ts +1 -1
  38. package/dist/operate-remote.js +7 -1
  39. package/dist/operate.d.ts +3 -0
  40. package/dist/operate.js +58 -2
  41. package/dist/people-audit-ui.d.ts +14 -0
  42. package/dist/people-audit-ui.js +38 -0
  43. package/dist/planner.js +4 -3
  44. package/dist/public-access.d.ts +50 -0
  45. package/dist/public-access.js +87 -0
  46. package/dist/public-check.d.ts +37 -0
  47. package/dist/public-check.js +170 -0
  48. package/dist/remote-audit.d.ts +79 -0
  49. package/dist/remote-audit.js +161 -0
  50. package/dist/request-budget-ui.d.ts +11 -0
  51. package/dist/request-budget-ui.js +31 -0
  52. package/dist/request-budget.d.ts +96 -0
  53. package/dist/request-budget.js +227 -0
  54. package/dist/serve.d.ts +9 -1
  55. package/dist/serve.js +211 -52
  56. package/dist/setup-guide.d.ts +2 -2
  57. package/dist/store.d.ts +134 -2
  58. package/dist/store.js +270 -7
  59. package/dist/surface.js +19 -2
  60. package/dist/task-activity.d.ts +27 -0
  61. package/dist/task-activity.js +53 -0
  62. package/dist/task-live.d.ts +17 -0
  63. package/dist/task-live.js +144 -0
  64. package/dist/token-notices.d.ts +22 -0
  65. package/dist/token-notices.js +52 -0
  66. package/dist/tokens-cli.d.ts +21 -0
  67. package/dist/tokens-cli.js +184 -0
  68. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.47 — 2026-10-07
4
+
5
+ - **Live tasks.** Task pages update live and show who else is viewing.
6
+ - **Last activity.** Running tasks say what the agent did last and when
7
+ ("Ran the tests · 40 s ago"). After 5 minutes of silence they turn amber,
8
+ and they say "Worker offline" when it is.
9
+ - **Flow cards** show their task's state: "Building · step 3 of 6",
10
+ "Stuck: …", "Waiting on you".
11
+ - **Teammates.** People limited to some projects now get live flows and
12
+ presence.
13
+ - **Refresh.** The task list, task pages and home refresh on their own,
14
+ with or without the chat open.
15
+ - **One badge.** The Needs-you count is the same everywhere, and says which
16
+ projects it covers.
17
+
18
+ ## 0.9.46 — 2026-10-07
19
+
20
+ - **Tokens from the terminal.** `toolroll tokens create|list|revoke|rotate`.
21
+ Creating or rotating asks for your password and shows the secret once.
22
+ Tokens can be limited to projects and always expire; rotating never
23
+ extends one. You're warned 7 days and 1 day before expiry.
24
+ - **Request limits per token.** Too many requests get a 429 with
25
+ Retry-After. Owners change the limits in the console.
26
+ - **`toolroll audit`** and the People page show who did what remotely, with
27
+ which token, and how it went. People see only their own.
28
+ - **A real domain, safely.** HSTS only on the https public host. Tokens are
29
+ refused over plain HTTP from outside this machine or your tailnet.
30
+ `toolroll serve check-public` checks the setup; see docs/team-server.md.
31
+ - **Agents sign in instead of pasting a token.** OAuth with PKCE and a
32
+ consent screen, short-lived tokens, and reuse detection. Pasted tokens
33
+ still work.
34
+ - Still to come: `brief` and `spend` remotely for limited access.
35
+
3
36
  ## 0.9.45 — 2026-10-07
4
37
 
5
38
  - **Your projects, your lists.** People with access to some projects can now
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What a running agent did last, in one line (live tasks): "Ran a command ·
3
+ * 40 s ago". Pure and shared: the server sends the facts, and each page words
4
+ * them against its own clock, so the age keeps counting between reads.
5
+ *
6
+ * After five minutes with no word the line turns amber and says so; a worker
7
+ * whose heartbeat stopped says that instead, because a quiet agent and a
8
+ * machine that went away need different help.
9
+ */
10
+ /** The facts behind the line: our own words for the last thing done (never a tool's arguments), when, and whether
11
+ * the worker running it still answers. */
12
+ export type RunActivity = {
13
+ what: string;
14
+ at: string;
15
+ worker: "online" | "offline";
16
+ };
17
+ export declare const QUIET_AFTER_MS: number;
18
+ /** The words for each fixed activity kind (live.ts's vocabulary) and for a progress checkpoint. */
19
+ export declare const ACTIVITY_WORDS: Readonly<Record<string, string>>;
20
+ /** "just now", "40 s ago", "3 min ago", "2 h ago". */
21
+ export declare function agoWords(ms: number): string;
22
+ /** The line itself, and whether it is quiet (amber): silent five minutes or more, or the worker is offline. */
23
+ export declare function activityLine(activity: RunActivity, now: number): {
24
+ text: string;
25
+ quiet: boolean;
26
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What a running agent did last, in one line (live tasks): "Ran a command ·
3
+ * 40 s ago". Pure and shared: the server sends the facts, and each page words
4
+ * them against its own clock, so the age keeps counting between reads.
5
+ *
6
+ * After five minutes with no word the line turns amber and says so; a worker
7
+ * whose heartbeat stopped says that instead, because a quiet agent and a
8
+ * machine that went away need different help.
9
+ */
10
+ export const QUIET_AFTER_MS = 5 * 60_000;
11
+ /** The words for each fixed activity kind (live.ts's vocabulary) and for a progress checkpoint. */
12
+ export const ACTIVITY_WORDS = {
13
+ session: "Started work",
14
+ message: "Wrote an update",
15
+ edit: "Edited files",
16
+ command: "Ran a command",
17
+ read: "Read the code",
18
+ search: "Searched the code",
19
+ lookup: "Looked something up",
20
+ plan: "Updated its plan",
21
+ delegate: "Handed a step to a helper",
22
+ tool: "Used a tool",
23
+ progress: "Reported progress",
24
+ started: "Started",
25
+ };
26
+ /** "just now", "40 s ago", "3 min ago", "2 h ago". */
27
+ export function agoWords(ms) {
28
+ const seconds = Math.max(0, Math.floor(ms / 1000));
29
+ if (seconds < 10)
30
+ return "just now";
31
+ if (seconds < 60)
32
+ return `${seconds} s ago`;
33
+ const minutes = Math.floor(seconds / 60);
34
+ if (minutes < 60)
35
+ return `${minutes} min ago`;
36
+ return `${Math.floor(minutes / 60)} h ago`;
37
+ }
38
+ /** The line itself, and whether it is quiet (amber): silent five minutes or more, or the worker is offline. */
39
+ export function activityLine(activity, now) {
40
+ const at = Date.parse(activity.at);
41
+ const since = Number.isNaN(at) ? 0 : Math.max(0, now - at);
42
+ if (activity.worker === "offline")
43
+ return { text: `Worker offline · last word ${agoWords(since)}`, quiet: true };
44
+ if (since >= QUIET_AFTER_MS)
45
+ return { text: `No word for ${Math.floor(since / 60_000)} min`, quiet: true };
46
+ return { text: `${activity.what} · ${agoWords(since)}`, quiet: false };
47
+ }
@@ -1,4 +1,11 @@
1
1
  export type TokenAccess = "read" | "act";
2
+ export type TokenPurpose = "api" | "mcp";
3
+ export declare const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this token. You can still revoke it here.";
4
+ /** Missing purpose is an old ordinary token; an unrecognised stored purpose is never treated as ordinary. */
5
+ export declare const tokenPurpose: (value: unknown) => TokenPurpose;
6
+ export declare const tokenRotationProblem: (token: {
7
+ purpose: TokenPurpose;
8
+ }) => string | null;
2
9
  export declare const TOKEN_DAYS: readonly [30, 90, 365];
3
10
  export declare function mintApiToken(): {
4
11
  id: string;
@@ -14,3 +21,29 @@ export declare function parseApiToken(presented: string): {
14
21
  } | null;
15
22
  /** Whether the secret is the one whose hash was kept, in constant time. */
16
23
  export declare function secretMatches(secret: string, keptHash: string): boolean;
24
+ /** v111: how long a rotated token keeps working beside its replacement, by default and at most. */
25
+ export declare const ROTATION_OVERLAP_MINUTES = 10;
26
+ export declare const ROTATION_OVERLAP_MAX_MINUTES = 60;
27
+ /** v111: the person is told this many days before a token expires, once each. */
28
+ export declare const EXPIRY_NOTICE_DAYS: readonly [7, 1];
29
+ type TokenState = {
30
+ revokedAt: string | null;
31
+ expiresAt: string;
32
+ overlapUntil: string | null;
33
+ };
34
+ /** Whether a token signs anyone in at `now`: unrevoked, unexpired, and (once replaced) inside its overlap. */
35
+ export declare function tokenLive(token: TokenState, now: number): boolean;
36
+ /**
37
+ * The projects a token's person may use through it now: the person's current access, narrowed by the token's limit.
38
+ * Null is every project. A limit never widens access, and a project the person has lost stays lost.
39
+ */
40
+ export declare function tokenProjects(account: readonly string[] | null, token: readonly string[] | null): string[] | null;
41
+ /** Whether `projects` (a principal's) stays inside the token's limit. A limited token never stands for every project. */
42
+ export declare function withinTokenLimit(projects: readonly string[] | null, limit: readonly string[] | null): boolean;
43
+ /** Whole days until a token expires, rounded up (0 once it has). */
44
+ export declare const daysLeft: (expiresAt: string, now: number) => number;
45
+ /** The expiry notice a token is due at `now` (7 or 1 days), or null. Replaced and revoked tokens get none. */
46
+ export declare function expiryNoticeDue(token: TokenState & {
47
+ replacedBy: string | null;
48
+ }, now: number): (typeof EXPIRY_NOTICE_DAYS)[number] | null;
49
+ export {};
@@ -8,6 +8,10 @@
8
8
  * stay a person's act in the console.
9
9
  */
10
10
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
11
+ export const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this token. You can still revoke it here.";
12
+ /** Missing purpose is an old ordinary token; an unrecognised stored purpose is never treated as ordinary. */
13
+ export const tokenPurpose = (value) => value === undefined || value === "api" ? "api" : "mcp";
14
+ export const tokenRotationProblem = (token) => token.purpose === "api" ? null : MCP_ROTATION_REFUSAL;
11
15
  export const TOKEN_DAYS = [30, 90, 365];
12
16
  const SHAPE = /^so_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
13
17
  export function mintApiToken() {
@@ -25,3 +29,38 @@ export function secretMatches(secret, keptHash) {
25
29
  const a = Buffer.from(hashSecret(secret), "hex"), b = Buffer.from(keptHash, "hex");
26
30
  return a.length === b.length && timingSafeEqual(a, b);
27
31
  }
32
+ /** v111: how long a rotated token keeps working beside its replacement, by default and at most. */
33
+ export const ROTATION_OVERLAP_MINUTES = 10;
34
+ export const ROTATION_OVERLAP_MAX_MINUTES = 60;
35
+ /** v111: the person is told this many days before a token expires, once each. */
36
+ export const EXPIRY_NOTICE_DAYS = [7, 1];
37
+ /** Whether a token signs anyone in at `now`: unrevoked, unexpired, and (once replaced) inside its overlap. */
38
+ export function tokenLive(token, now) {
39
+ if (token.revokedAt !== null || !(Date.parse(token.expiresAt) > now))
40
+ return false;
41
+ return token.overlapUntil === null || Date.parse(token.overlapUntil) > now;
42
+ }
43
+ /**
44
+ * The projects a token's person may use through it now: the person's current access, narrowed by the token's limit.
45
+ * Null is every project. A limit never widens access, and a project the person has lost stays lost.
46
+ */
47
+ export function tokenProjects(account, token) {
48
+ if (token === null)
49
+ return account === null ? null : [...account];
50
+ return token.filter(repo => account === null || account.includes(repo));
51
+ }
52
+ /** Whether `projects` (a principal's) stays inside the token's limit. A limited token never stands for every project. */
53
+ export function withinTokenLimit(projects, limit) {
54
+ if (limit === null)
55
+ return true;
56
+ return projects !== null && projects.every(repo => limit.includes(repo));
57
+ }
58
+ /** Whole days until a token expires, rounded up (0 once it has). */
59
+ export const daysLeft = (expiresAt, now) => Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 86_400_000));
60
+ /** The expiry notice a token is due at `now` (7 or 1 days), or null. Replaced and revoked tokens get none. */
61
+ export function expiryNoticeDue(token, now) {
62
+ if (token.replacedBy !== null || !tokenLive(token, now))
63
+ return null;
64
+ const left = Date.parse(token.expiresAt) - now;
65
+ return EXPIRY_NOTICE_DAYS.filter(days => left <= days * 86_400_000).at(-1) ?? null;
66
+ }
@@ -0,0 +1,17 @@
1
+ import type { RemoteRun } from "./remote-run.js";
2
+ import { type AuditAction, type AuditReader } from "./remote-audit.js";
3
+ import type { Store } from "./store.js";
4
+ export declare const AUDIT_USAGE = "Use audit [--person <name>] [--token <name>] [--source api|mcp] [--since <days>d] [--limit <n>] [--cursor <id>] [--json].";
5
+ type AuditContext = {
6
+ store: Store;
7
+ write: (s: string) => void;
8
+ json: boolean;
9
+ now: Date;
10
+ principal?: RemoteRun;
11
+ };
12
+ /** The reader a run is: the machine's owner locally, else the remote caller with their role and projects. */
13
+ export declare function readerOf(store: Store, remote: RemoteRun | undefined): AuditReader;
14
+ export declare function auditCommand(positional: readonly string[], flags: Map<string, string | true>, context: AuditContext): number;
15
+ /** One line per action: outcome first, then who, token, source, what and where. */
16
+ export declare function auditLine(action: AuditAction): string;
17
+ export {};
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `toolroll audit`: who did what on this server with their API tokens (remote-audit.ts). On the server's own machine
3
+ * it lists everyone; run remotely it is the caller's own view — an approver sees everyone within their projects, a
4
+ * viewer only themselves. `--token` names a token locally; remotely, where `--token` is always a refused credential
5
+ * flag, the same filter is `--token-name`.
6
+ */
7
+ import { basename } from "node:path";
8
+ import { checkedEnvelopeJson } from "./contracts/cli.js";
9
+ import { AUDIT_LIMIT, AUDIT_SOURCES, cursorOf, OWNER_READER, remoteAudit, sinceOf } from "./remote-audit.js";
10
+ const EXIT = { ok: 0, usage: 2, refused: 3 };
11
+ const COMMAND = "audit";
12
+ const FLAGS = new Set(["person", "token", "token-name", "source", "since", "limit", "cursor", "json", "db"]);
13
+ export const AUDIT_USAGE = "Use audit [--person <name>] [--token <name>] [--source api|mcp] [--since <days>d] [--limit <n>] [--cursor <id>] [--json].";
14
+ /** The reader a run is: the machine's owner locally, else the remote caller with their role and projects. */
15
+ export function readerOf(store, remote) {
16
+ if (remote === undefined)
17
+ return OWNER_READER;
18
+ const role = store.accountOf(remote.account)?.role ?? "viewer";
19
+ const all = remote.allows(null);
20
+ return { self: remote.account, everyone: role === "approver", repos: all ? null : store.knownRepos().filter(repo => remote.allows(repo)), unplaced: all };
21
+ }
22
+ export function auditCommand(positional, flags, context) {
23
+ const fail = (reason, message, code) => {
24
+ context.write(context.json ? checkedEnvelopeJson({ ok: false, command: COMMAND, reason, message }) : message);
25
+ return code;
26
+ };
27
+ const usage = (message) => fail("usage", `${message} ${AUDIT_USAGE}`, EXIT.usage);
28
+ for (const name of flags.keys())
29
+ if (!FLAGS.has(name))
30
+ return usage(`--${name} is not an audit option.`);
31
+ if (positional.length > 0)
32
+ return usage("audit takes no arguments.");
33
+ const value = (name) => {
34
+ const one = flags.get(name);
35
+ return one === undefined ? null : typeof one === "string" && one !== "" ? one : undefined;
36
+ };
37
+ const person = value("person"), source = value("source"), since = value("since"), limit = value("limit"), cursor = value("cursor");
38
+ const tokenFlag = value("token"), tokenName = value("token-name");
39
+ for (const [name, one] of [["person", person], ["token", tokenFlag], ["token-name", tokenName], ["source", source], ["since", since], ["limit", limit], ["cursor", cursor]]) {
40
+ if (one === undefined)
41
+ return usage(`--${name} needs a value.`);
42
+ }
43
+ if (tokenFlag !== null && tokenName !== null && tokenFlag !== tokenName)
44
+ return usage("Name one token.");
45
+ const token = tokenFlag ?? tokenName ?? null;
46
+ if (token !== null && /^so_[a-f0-9]{12}_/.test(token))
47
+ return usage("Name the token (as shown in Settings), never its secret.");
48
+ if (source !== null && !AUDIT_SOURCES.includes(source))
49
+ return usage("--source is api or mcp.");
50
+ const from = since === null ? null : sinceOf(since, context.now);
51
+ if (since !== null && from === null)
52
+ return usage("--since is a number of days, like 7d.");
53
+ const size = limit === null ? AUDIT_LIMIT.default : /^[1-9][0-9]{0,2}$/.test(limit) && Number(limit) <= AUDIT_LIMIT.max ? Number(limit) : null;
54
+ if (size === null)
55
+ return usage(`--limit is 1 to ${AUDIT_LIMIT.max}.`);
56
+ const before = cursor === null ? null : cursorOf(cursor);
57
+ if (cursor !== null && before === null)
58
+ return usage("--cursor is the nextCursor of an earlier page.");
59
+ const filters = { person: person ?? null, token, source: (source ?? null), since: from };
60
+ const page = remoteAudit(context.store, readerOf(context.store, context.principal), filters, { limit: size, before });
61
+ if (!page.ok)
62
+ return fail(page.reason, page.message, EXIT.refused);
63
+ if (context.json) {
64
+ context.write(checkedEnvelopeJson({ ok: true, command: COMMAND, filters, actions: page.actions, limit: page.limit, nextCursor: page.nextCursor }));
65
+ return EXIT.ok;
66
+ }
67
+ const lines = page.actions.length === 0 ? ["No remote actions."] : page.actions.map(auditLine);
68
+ if (page.nextCursor !== null)
69
+ lines.push(`Older: toolroll audit --cursor ${page.nextCursor}${flagsAgain(flags)}`);
70
+ context.write(lines.join("\n"));
71
+ return EXIT.ok;
72
+ }
73
+ /** One line per action: outcome first, then who, token, source, what and where. */
74
+ export function auditLine(action) {
75
+ const outcome = action.outcome === "ok" ? "ok" : `${action.outcome}${action.reason === null ? "" : ` (${action.reason})`}`;
76
+ const where = action.repo === null ? "" : ` ${basename(action.repo)}`;
77
+ return `${action.at.slice(0, 16).replace("T", " ")} ${outcome} ${action.person} ${action.token ?? "unknown token"} ${action.source} ${action.name}${where}`;
78
+ }
79
+ /** The filters again for the next page, quoted when they need it. */
80
+ function flagsAgain(flags) {
81
+ const quote = (one) => /^[A-Za-z0-9_./:@-]+$/.test(one) ? one : `'${one.replaceAll("'", "'\\''")}'`;
82
+ return ["person", "token", "token-name", "source", "since", "limit"].flatMap(name => {
83
+ const one = flags.get(name);
84
+ return typeof one === "string" ? [` --${name} ${quote(one)}`] : [];
85
+ }).join("");
86
+ }