toolroll 0.9.54 → 0.9.55

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 (199) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-polish.d.ts +5 -4
  19. package/dist/chat-polish.js +9 -10
  20. package/dist/check-levels-ui.d.ts +2 -1
  21. package/dist/check-levels-ui.js +26 -53
  22. package/dist/cli.d.ts +3 -1
  23. package/dist/cli.js +9 -3
  24. package/dist/coding-shipping-ui.d.ts +2 -1
  25. package/dist/coding-shipping-ui.js +6 -6
  26. package/dist/coding-ui.d.ts +3 -1
  27. package/dist/coding-ui.js +30 -29
  28. package/dist/contracts/cli.d.ts +1 -3
  29. package/dist/contracts/console-api.d.ts +1 -1
  30. package/dist/contracts/console-api.js +6 -5
  31. package/dist/contracts/health.d.ts +1 -3
  32. package/dist/control-ui.d.ts +5 -4
  33. package/dist/control-ui.js +9 -34
  34. package/dist/credentials-ui.d.ts +5 -4
  35. package/dist/credentials-ui.js +23 -24
  36. package/dist/demo-chat.d.ts +3 -3
  37. package/dist/demo-chat.js +29 -76
  38. package/dist/discord-settings.d.ts +3 -2
  39. package/dist/discord-settings.js +17 -42
  40. package/dist/envelope.js +10 -0
  41. package/dist/evidence-pack.d.ts +2 -1
  42. package/dist/evidence-pack.js +45 -49
  43. package/dist/export-ui.d.ts +7 -2
  44. package/dist/export-ui.js +4 -10
  45. package/dist/flow-gallery-ui.d.ts +4 -3
  46. package/dist/flow-gallery-ui.js +28 -43
  47. package/dist/flow-starters-ui.d.ts +2 -1
  48. package/dist/flow-starters-ui.js +12 -15
  49. package/dist/flow-triggers.d.ts +1 -0
  50. package/dist/flow-triggers.js +16 -14
  51. package/dist/flows-ui.d.ts +4 -3
  52. package/dist/flows-ui.js +19 -30
  53. package/dist/flows.js +2 -1
  54. package/dist/guides.js +108 -3
  55. package/dist/html.d.ts +76 -0
  56. package/dist/html.js +173 -0
  57. package/dist/integrations-ui.d.ts +2 -1
  58. package/dist/integrations-ui.js +19 -23
  59. package/dist/kits-ui.d.ts +5 -4
  60. package/dist/kits-ui.js +18 -20
  61. package/dist/knowledge-ui.d.ts +6 -5
  62. package/dist/knowledge-ui.js +26 -25
  63. package/dist/lead-context.d.ts +11 -1
  64. package/dist/lead-context.js +6 -8
  65. package/dist/ledger-view.d.ts +2 -1
  66. package/dist/ledger-view.js +27 -29
  67. package/dist/limits-ui.d.ts +2 -1
  68. package/dist/limits-ui.js +7 -7
  69. package/dist/live-bus.d.ts +50 -8
  70. package/dist/live-bus.js +133 -50
  71. package/dist/mcp-oauth.js +35 -36
  72. package/dist/models-ui.d.ts +2 -1
  73. package/dist/models-ui.js +25 -35
  74. package/dist/monitoring-ui.d.ts +4 -3
  75. package/dist/monitoring-ui.js +13 -26
  76. package/dist/openrouter-models.d.ts +2 -1
  77. package/dist/openrouter-models.js +5 -9
  78. package/dist/operate.js +11 -7
  79. package/dist/people-audit-ui.d.ts +2 -1
  80. package/dist/people-audit-ui.js +13 -13
  81. package/dist/policy-ui.d.ts +3 -2
  82. package/dist/policy-ui.js +17 -23
  83. package/dist/project-delete-ui.d.ts +4 -3
  84. package/dist/project-delete-ui.js +15 -27
  85. package/dist/pull-request-ui.d.ts +2 -1
  86. package/dist/pull-request-ui.js +17 -32
  87. package/dist/recipe-ui.d.ts +8 -7
  88. package/dist/recipe-ui.js +39 -32
  89. package/dist/release-adapters.d.ts +60 -0
  90. package/dist/release-adapters.js +435 -0
  91. package/dist/release-cli.d.ts +24 -0
  92. package/dist/release-cli.js +181 -0
  93. package/dist/release-gate.d.ts +80 -0
  94. package/dist/release-gate.js +150 -0
  95. package/dist/release-links.d.ts +29 -0
  96. package/dist/release-links.js +102 -0
  97. package/dist/release.d.ts +331 -0
  98. package/dist/release.js +550 -0
  99. package/dist/reply-shape.d.ts +2 -1
  100. package/dist/reply-shape.js +5 -5
  101. package/dist/repository-context-ui.d.ts +2 -1
  102. package/dist/repository-context-ui.js +2 -7
  103. package/dist/request-budget-ui.d.ts +2 -1
  104. package/dist/request-budget-ui.js +16 -16
  105. package/dist/result-review.d.ts +2 -3
  106. package/dist/result-review.js +3 -9
  107. package/dist/retention-ui.d.ts +7 -1
  108. package/dist/retention-ui.js +7 -11
  109. package/dist/serve.d.ts +11 -2
  110. package/dist/serve.js +69 -58
  111. package/dist/server/chat.d.ts +3 -6
  112. package/dist/server/chat.js +969 -1006
  113. package/dist/server/chrome.d.ts +291 -0
  114. package/dist/server/chrome.js +747 -0
  115. package/dist/server/console.css +2819 -0
  116. package/dist/server/flows.d.ts +2 -6
  117. package/dist/server/flows.js +840 -766
  118. package/dist/server/guards.d.ts +1 -1
  119. package/dist/server/guards.js +5 -2
  120. package/dist/server/handler-context.d.ts +1 -1
  121. package/dist/server/handler-context.js +1 -1
  122. package/dist/server/handler-registry.d.ts +8 -6
  123. package/dist/server/handler-registry.js +14 -2
  124. package/dist/server/http.d.ts +274 -0
  125. package/dist/server/http.js +239 -0
  126. package/dist/server/live.d.ts +34 -0
  127. package/dist/server/live.js +309 -0
  128. package/dist/server/pages.d.ts +2 -6
  129. package/dist/server/pages.js +808 -789
  130. package/dist/server/people-tokens.d.ts +1 -6
  131. package/dist/server/people-tokens.js +580 -572
  132. package/dist/server/remote.d.ts +4 -7
  133. package/dist/server/remote.js +37 -70
  134. package/dist/server/render-chat.d.ts +402 -0
  135. package/dist/server/render-chat.js +927 -0
  136. package/dist/server/render-pages.d.ts +535 -0
  137. package/dist/server/render-pages.js +1277 -0
  138. package/dist/server/render-people.d.ts +32 -0
  139. package/dist/server/render-people.js +121 -0
  140. package/dist/server/render-results.d.ts +744 -0
  141. package/dist/server/render-results.js +2237 -0
  142. package/dist/server/render-settings.d.ts +147 -0
  143. package/dist/server/render-settings.js +577 -0
  144. package/dist/server/render-tasks.d.ts +632 -0
  145. package/dist/server/render-tasks.js +2345 -0
  146. package/dist/server/request-context.d.ts +46 -0
  147. package/dist/server/request-context.js +17 -0
  148. package/dist/server/route-table.d.ts +25 -3
  149. package/dist/server/route-table.js +163 -75
  150. package/dist/server/runtime.d.ts +15 -7
  151. package/dist/server/runtime.js +7 -1
  152. package/dist/server/session.d.ts +138 -0
  153. package/dist/server/session.js +94 -0
  154. package/dist/server/settings.d.ts +1 -5
  155. package/dist/server/settings.js +2181 -2117
  156. package/dist/server/task-views.d.ts +11 -4
  157. package/dist/server/task-views.js +26 -22
  158. package/dist/server/tasks.d.ts +9 -9
  159. package/dist/server/tasks.js +1486 -1418
  160. package/dist/server-telemetry.d.ts +4 -3
  161. package/dist/server-telemetry.js +4 -8
  162. package/dist/skills-ui.d.ts +4 -3
  163. package/dist/skills-ui.js +14 -17
  164. package/dist/skills.d.ts +1 -1
  165. package/dist/skills.js +2 -1
  166. package/dist/slack-settings.d.ts +3 -2
  167. package/dist/slack-settings.js +17 -42
  168. package/dist/spend-ui.d.ts +2 -1
  169. package/dist/spend-ui.js +9 -32
  170. package/dist/sso-ui.d.ts +3 -2
  171. package/dist/sso-ui.js +26 -27
  172. package/dist/storage-ui.d.ts +2 -1
  173. package/dist/storage-ui.js +14 -33
  174. package/dist/surface.js +14 -3
  175. package/dist/task-composer.d.ts +2 -1
  176. package/dist/task-composer.js +7 -7
  177. package/dist/task-status.d.ts +4 -3
  178. package/dist/task-status.js +15 -16
  179. package/dist/team-http.d.ts +5 -9
  180. package/dist/team-http.js +5 -63
  181. package/dist/team-ui.d.ts +2 -1
  182. package/dist/team-ui.js +2 -5
  183. package/dist/teammates-ui.d.ts +5 -4
  184. package/dist/teammates-ui.js +61 -90
  185. package/dist/teams-settings.d.ts +3 -2
  186. package/dist/teams-settings.js +20 -31
  187. package/dist/telegram-settings.d.ts +3 -2
  188. package/dist/telegram-settings.js +14 -25
  189. package/dist/toolroll-update-ui.d.ts +3 -2
  190. package/dist/toolroll-update-ui.js +35 -39
  191. package/dist/tools-ui.d.ts +5 -2
  192. package/dist/tools-ui.js +31 -31
  193. package/dist/when-html.d.ts +11 -3
  194. package/dist/when-html.js +5 -6
  195. package/dist/workspace-ui.d.ts +4 -2
  196. package/dist/workspace-ui.js +10 -10
  197. package/package.json +1 -1
  198. package/dist/server/shared.d.ts +0 -3151
  199. package/dist/server/shared.js +0 -12001
@@ -2,9 +2,10 @@ import type { BudgetLimit } from './request-budget.js';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  export declare const TELEMETRY_WINDOW_MS = 300000;
4
4
  export declare const LATENCY_BUCKETS: number[];
5
- export declare const ROUTE_FAMILIES: readonly ['cli', 'mcp', 'team', 'team-stream', 'chat', 'chat-stream', 'flows', 'flow-stream', 'tasks', 'results', 'decisions', 'sessions', 'settings', 'projects', 'assets', 'auth', 'metrics', 'health', 'console', 'other'];
5
+ export declare const ROUTE_FAMILIES: readonly ['cli', 'mcp', 'team', 'live', 'chat', 'flows', 'tasks', 'results', 'decisions', 'sessions', 'settings', 'projects', 'assets', 'auth', 'metrics', 'health', 'console', 'other'];
6
6
  export type RouteFamily = typeof ROUTE_FAMILIES[number];
7
- export type StreamFamily = 'team-stream' | 'chat-stream' | 'flow-stream' | 'other';
7
+ /** Every page's live updates are one stream (GET /live). */
8
+ export type StreamFamily = 'live' | 'other';
8
9
  export declare const STREAM_FAMILIES: readonly StreamFamily[];
9
10
  export declare const QUEUE_BUCKETS: number[];
10
11
  /** Only literals from the allowlist can become labels, including for unknown or malformed paths. */
@@ -54,7 +55,7 @@ export type HealthSnapshot = {
54
55
  export declare class ServerTelemetry {
55
56
  readonly clock: () => number;
56
57
  readonly requests: TimingHistogram;
57
- readonly routes: Map<"assets" | "auth" | "chat" | "chat-stream" | "cli" | "console" | "decisions" | "flow-stream" | "flows" | "health" | "mcp" | "metrics" | "other" | "projects" | "results" | "sessions" | "settings" | "tasks" | "team" | "team-stream", TimingHistogram>;
58
+ readonly routes: Map<"assets" | "auth" | "chat" | "cli" | "console" | "decisions" | "flows" | "health" | "live" | "mcp" | "metrics" | "other" | "projects" | "results" | "sessions" | "settings" | "tasks" | "team", TimingHistogram>;
58
59
  readonly eventLoop: TimingHistogram;
59
60
  readonly writeWait: TimingHistogram;
60
61
  readonly writeHold: TimingHistogram;
@@ -5,8 +5,8 @@ const SLICE_MS = 10_000;
5
5
  /** monitorEventLoopDelay samples by sleeping this long, so every raw sample includes it. */
6
6
  const EVENT_LOOP_RESOLUTION_MS = 20;
7
7
  export const LATENCY_BUCKETS = [0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.015, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15, 30, 60];
8
- export const ROUTE_FAMILIES = ['cli', 'mcp', 'team', 'team-stream', 'chat', 'chat-stream', 'flows', 'flow-stream', 'tasks', 'results', 'decisions', 'sessions', 'settings', 'projects', 'assets', 'auth', 'metrics', 'health', 'console', 'other'];
9
- export const STREAM_FAMILIES = ['team-stream', 'chat-stream', 'flow-stream', 'other'];
8
+ export const ROUTE_FAMILIES = ['cli', 'mcp', 'team', 'live', 'chat', 'flows', 'tasks', 'results', 'decisions', 'sessions', 'settings', 'projects', 'assets', 'auth', 'metrics', 'health', 'console', 'other'];
9
+ export const STREAM_FAMILIES = ['live', 'other'];
10
10
  export const QUEUE_BUCKETS = [0, 1024, 4096, 16384, 65536, 262144, 1048576];
11
11
  /** Only literals from the allowlist can become labels, including for unknown or malformed paths. */
12
12
  export function routeFamily(raw) {
@@ -21,14 +21,10 @@ export function routeFamily(raw) {
21
21
  return 'cli';
22
22
  if (path === '/mcp' || path.startsWith('/mcp/'))
23
23
  return 'mcp';
24
- if (path === '/api/team/events')
25
- return 'team-stream';
24
+ if (path === '/live')
25
+ return 'live';
26
26
  if (path === '/api/team')
27
27
  return 'team';
28
- if (path === '/chat/stream')
29
- return 'chat-stream';
30
- if (/^\/flows\/[^/]+\/live$/.test(path))
31
- return 'flow-stream';
32
28
  if (path === '/metrics')
33
29
  return 'metrics';
34
30
  if (path === '/health')
@@ -1,3 +1,4 @@
1
+ import { type Html } from "./html.js";
1
2
  import type { SkillsSnapshot, SkillsView, skillTestResult } from "./project-skills.js";
2
3
  export declare const SKILLS_CSS = ".skills{max-width:780px;min-width:0;overflow-wrap:anywhere}.skills h2{font-size:1.1rem}.skills .card{padding:18px;margin:14px 0;min-width:0}.skills form,.skills label{display:grid;gap:10px}.skills input,.skills textarea,.skills select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.skills textarea{resize:vertical;min-height:130px}.skills button{justify-self:start;white-space:nowrap;min-height:44px}.skills summary{cursor:pointer;min-height:44px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.skills pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;font-size:.9rem}.skills .skill-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.skills .skill-heading h2{margin:0}.skills .skill-state{font-size:.85rem;flex-shrink:0}.skills .skill-source{font-size:.85rem}.skills .skill-controls{display:flex;gap:12px;flex-wrap:wrap}.skills .skill-controls form{margin:0}.skills details details{margin-top:10px}.skills [hidden]{display:none!important}.skills .skill-files{max-height:200px;overflow:auto}.skills .problem{padding:12px}@media(max-width:600px){.skills input,.skills textarea,.skills select{font-size:16px}.skills .card{padding:14px}.skills .skill-heading{align-items:flex-start}}";
3
4
  export declare function skillsHtml(view: SkillsView, csrf: string, canManage: boolean, options?: {
@@ -5,7 +6,7 @@ export declare function skillsHtml(view: SkillsView, csrf: string, canManage: bo
5
6
  error?: string;
6
7
  draft?: Record<string, string>;
7
8
  agent?: string;
8
- }): string;
9
- export declare function skillsSnapshotHtml(snapshot: SkillsSnapshot | null): string;
9
+ }): Html;
10
+ export declare function skillsSnapshotHtml(snapshot: SkillsSnapshot | null): Html | "";
10
11
  export declare function skillsScript(): string;
11
- export declare function skillTestFeedbackHtml(test: NonNullable<ReturnType<typeof skillTestResult>>, csrf: string, canManage: boolean, error?: string, draft?: string): string;
12
+ export declare function skillTestFeedbackHtml(test: NonNullable<ReturnType<typeof skillTestResult>>, csrf: string, canManage: boolean, error?: string, draft?: string): Html;
package/dist/skills-ui.js CHANGED
@@ -1,28 +1,26 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { whenUtc } from "./when-html.js";
3
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
4
- const hidden = (data) => Object.entries(data)
5
- .map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`)
6
- .join("");
3
+ import { html, joinHtml, postForm } from "./html.js";
4
+ // Hidden fields as they always were: a missing value is an empty field.
5
+ const fields = (data) => Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v ?? "")]));
7
6
  export const SKILLS_CSS = `.skills{max-width:780px;min-width:0;overflow-wrap:anywhere}.skills h2{font-size:1.1rem}.skills .card{padding:18px;margin:14px 0;min-width:0}.skills form,.skills label{display:grid;gap:10px}.skills input,.skills textarea,.skills select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.skills textarea{resize:vertical;min-height:130px}.skills button{justify-self:start;white-space:nowrap;min-height:44px}.skills summary{cursor:pointer;min-height:44px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.skills pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;font-size:.9rem}.skills .skill-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.skills .skill-heading h2{margin:0}.skills .skill-state{font-size:.85rem;flex-shrink:0}.skills .skill-source{font-size:.85rem}.skills .skill-controls{display:flex;gap:12px;flex-wrap:wrap}.skills .skill-controls form{margin:0}.skills details details{margin-top:10px}.skills [hidden]{display:none!important}.skills .skill-files{max-height:200px;overflow:auto}.skills .problem{padding:12px}@media(max-width:600px){.skills input,.skills textarea,.skills select{font-size:16px}.skills .card{padding:14px}.skills .skill-heading{align-items:flex-start}}`;
8
7
  function source(skill) {
9
- return `<p class="skill-source">${e(skill.source)} · ${skill.sha.slice(0, 10)}</p><p>${skill.files.length} files · ${Math.ceil(skill.files.reduce((n, f) => n + Buffer.from(f.base64, "base64").length, 0) / 1024)} KB</p><pre>${e(Buffer.from(skill.files.find((f) => f.path === "SKILL.md").base64, "base64").toString("utf8"))}</pre><details><summary>Package files</summary><ul class="skill-files">${skill.files.map((f) => `<li>${e(f.path)}</li>`).join("")}</ul></details>`;
8
+ return html `<p class="skill-source">${skill.source} · ${skill.sha.slice(0, 10)}</p><p>${skill.files.length} files · ${Math.ceil(skill.files.reduce((n, f) => n + Buffer.from(f.base64, "base64").length, 0) / 1024)} KB</p><pre>${Buffer.from(skill.files.find((f) => f.path === "SKILL.md").base64, "base64").toString("utf8")}</pre><details><summary>Package files</summary><ul class="skill-files">${skill.files.map((f) => html `<li>${f.path}</li>`)}</ul></details>`;
10
9
  }
11
10
  export function skillsHtml(view, csrf, canManage, options = {}) {
12
11
  const manage = canManage && !!csrf, base = {
13
- csrf,
14
12
  repo: view.repo,
15
13
  identity: view.identity,
16
14
  revision: view.revision,
17
15
  };
18
- const form = (action, contents, extra = {}) => `<form method="post" action="/settings/skills/change">${hidden({ ...base, action, ...extra })}${contents}</form>`;
16
+ const form = (action, contents, extra = {}) => postForm("/settings/skills/change", contents, { hidden: fields({ ...base, action, ...extra }) });
19
17
  const active = Object.values(view.selection).filter((c) => c.enabled).length;
20
18
  const renderSkill = (skill) => {
21
19
  const enabled = view.selection[skill.name]?.sha === skill.sha &&
22
20
  view.selection[skill.name]?.enabled === true;
23
21
  const replaced = view.selection[skill.name]?.enabled &&
24
22
  view.selection[skill.name]?.sha !== skill.sha;
25
- return `<article class="card" id="skill-${skill.sha}"><div class="skill-heading"><h2>${e(skill.name)}</h2><span class="skill-state">${enabled ? "Enabled" : "In library"}</span></div><p>${e(skill.description)}</p>${skill.requirements ? `<p><strong>Requires:</strong> ${e(skill.requirements)} <span class="meta">Availability is checked when the agent tests or uses it.</span></p>` : ""}${skill.warnings.map((w) => `<p class="meta">${e(w)}</p>`).join("")}<details${options.focus === skill.sha ? " open" : ""}><summary>Review skill</summary>${source(skill)}${manage ? form(enabled ? "disable" : "enable", `${replaced ? "<p>Replaces the enabled version of this skill for new tasks.</p>" : ""}<button>${enabled ? "Disable skill" : "Enable skill"}</button>`, { sha: skill.sha }) : ""}</details>${manage ? `<details${options.draft?.["sha"] === skill.sha ? " open" : ""}><summary>Test skill</summary>${form("test", `<p>${e(options.agent ?? "Uses this project’s configured report agent")}. Runs a read-only sample through the normal task and approval flow.</p><label>Sample request<textarea name="sample" required maxlength="800" placeholder="Review this project’s home page and suggest three clearer button labels.">${e(options.draft?.["sha"] === skill.sha ? (options.draft?.["sample"] ?? "") : "")}</textarea></label><p class="meta">The report shows what worked and what needs tools or permission. Enabled status alone does not prove use.</p><button>Create test</button>`, { sha: skill.sha, nonce: randomUUID() })}</details>` : ""}</article>`;
23
+ return html `<article class="card" id="skill-${skill.sha}"><div class="skill-heading"><h2>${skill.name}</h2><span class="skill-state">${enabled ? "Enabled" : "In library"}</span></div><p>${skill.description}</p>${skill.requirements ? html `<p><strong>Requires:</strong> ${skill.requirements} <span class="meta">Availability is checked when the agent tests or uses it.</span></p>` : ""}${skill.warnings.map((w) => html `<p class="meta">${w}</p>`)}<details${options.focus === skill.sha ? html ` open` : ""}><summary>Review skill</summary>${source(skill)}${manage ? form(enabled ? "disable" : "enable", html `${replaced ? html `<p>Replaces the enabled version of this skill for new tasks.</p>` : ""}<button>${enabled ? "Disable skill" : "Enable skill"}</button>`, { sha: skill.sha }) : ""}</details>${manage ? html `<details${options.draft?.["sha"] === skill.sha ? html ` open` : ""}><summary>Test skill</summary>${form("test", html `<p>${options.agent ?? "Uses this project’s configured report agent"}. Runs a read-only sample through the normal task and approval flow.</p><label>Sample request<textarea name="sample" required maxlength="800" placeholder="Review this project’s home page and suggest three clearer button labels.">${options.draft?.["sha"] === skill.sha ? (options.draft?.["sample"] ?? "") : ""}</textarea></label><p class="meta">The report shows what worked and what needs tools or permission. Enabled status alone does not prove use.</p><button>Create test</button>`, { sha: skill.sha, nonce: randomUUID() })}</details>` : ""}</article>`;
26
24
  };
27
25
  const names = [...new Set(view.library.map((s) => s.name))];
28
26
  const cards = names
@@ -31,29 +29,28 @@ export function skillsHtml(view, csrf, canManage, options = {}) {
31
29
  versions.find((s) => s.sha === view.selection[name]?.sha) ??
32
30
  versions.at(-1);
33
31
  const older = versions.filter((s) => s !== primary);
34
- return `<div data-skill-group data-skill-query="${e(name + " " + primary.description)}">${renderSkill(primary)}${older.length ? `<details><summary>Other versions (${older.length})</summary>${older.map(renderSkill).join("")}</details>` : ""}</div>`;
35
- })
36
- .join("");
32
+ return html `<div data-skill-group data-skill-query="${name + " " + primary.description}">${renderSkill(primary)}${older.length ? html `<details><summary>Other versions (${older.length})</summary>${older.map(renderSkill)}</details>` : ""}</div>`;
33
+ });
37
34
  const search = names.length > 5
38
- ? '<label>Search library<input type="search" data-skill-search placeholder="Name or description"></label><p data-skill-no-matches hidden>No matching skills.</p>'
35
+ ? html `<label>Search library<input type="search" data-skill-search placeholder="Name or description"></label><p data-skill-no-matches hidden>No matching skills.</p>`
39
36
  : "";
40
37
  const d = options.draft ?? {};
41
38
  const add = manage
42
- ? `<details class="card"${options.error && options.draft?.["method"] ? " open" : ""}><summary>Add skill</summary><form method="post" action="/settings/skills/import" data-skill-import>${hidden(base)}<label>Add from<select name="method" data-skill-method><option value="paste"${d["method"] === "paste" ? " selected" : ""}>Paste SKILL.md</option><option value="github"${d["method"] === "github" ? " selected" : ""}>GitHub folder</option><option value="folder"${d["method"] === "folder" ? " selected" : ""}>Local folder</option></select></label><div data-skill-input="paste"><label>SKILL.md<textarea name="content" rows="8" maxlength="24576" placeholder="---&#10;name: design-review&#10;description: Review interface copy and layout.&#10;---&#10;Write your instructions here.">${e(d["content"])}</textarea></label></div><div data-skill-input="github"><label>Public GitHub folder<input name="url" type="url" value="${e(d["url"])}" placeholder="https://github.com/owner/repo/tree/main/skills/review"></label></div><div data-skill-input="folder"><label>Skill folder<input type="file" webkitdirectory multiple data-skill-folder></label><input type="hidden" name="files" value=""><p class="meta">Choose the folder containing SKILL.md. Up to 64 files, 1 MB total.</p><noscript>Folder uploads require JavaScript. You can paste SKILL.md or use a GitHub folder.</noscript></div><p class="meta">Added to your library first. Review its source and requirements before enabling it.</p><p data-skill-error role="alert" hidden></p><button>Add to library</button></form></details>`
39
+ ? html `<details class="card"${options.error && options.draft?.["method"] ? html ` open` : ""}><summary>Add skill</summary>${postForm("/settings/skills/import", html `<label>Add from<select name="method" data-skill-method><option value="paste"${d["method"] === "paste" ? html ` selected` : ""}>Paste SKILL.md</option><option value="github"${d["method"] === "github" ? html ` selected` : ""}>GitHub folder</option><option value="folder"${d["method"] === "folder" ? html ` selected` : ""}>Local folder</option></select></label><div data-skill-input="paste"><label>SKILL.md<textarea name="content" rows="8" maxlength="24576" placeholder="---&#10;name: design-review&#10;description: Review interface copy and layout.&#10;---&#10;Write your instructions here.">${d["content"]}</textarea></label></div><div data-skill-input="github"><label>Public GitHub folder<input name="url" type="url" value="${d["url"]}" placeholder="https://github.com/owner/repo/tree/main/skills/review"></label></div><div data-skill-input="folder"><label>Skill folder<input type="file" webkitdirectory multiple data-skill-folder></label><input type="hidden" name="files" value=""><p class="meta">Choose the folder containing SKILL.md. Up to 64 files, 1 MB total.</p><noscript>Folder uploads require JavaScript. You can paste SKILL.md or use a GitHub folder.</noscript></div><p class="meta">Added to your library first. Review its source and requirements before enabling it.</p><p data-skill-error role="alert" hidden></p><button>Add to library</button>`, { attrs: { "data-skill-import": true }, hidden: fields(base) })}</details>`
43
40
  : "";
44
41
  const history = view.history.length
45
- ? `<details><summary>Change history</summary>${view.history.map((h) => `<div class="card"><p>Selection ${h.revision} · ${e(h.actor)} · ${whenUtc(h.at)}</p><ul>${h.enabled.length ? h.enabled.map((s) => `<li>${e(s.name)} · ${e(s.version)}</li>`).join("") : "<li>No skills enabled</li>"}</ul>${manage && h.revision !== view.revision ? form("restore", "<button>Restore selection</button>", { restore: h.revision }) : '<span class="meta">Current selection</span>'}</div>`).join("")}</details>`
42
+ ? html `<details><summary>Change history</summary>${view.history.map((h) => html `<div class="card"><p>Selection ${h.revision} · ${h.actor} · ${whenUtc(h.at)}</p><ul>${h.enabled.length ? h.enabled.map((s) => html `<li>${s.name} · ${s.version}</li>`) : html `<li>No skills enabled</li>`}</ul>${manage && h.revision !== view.revision ? form("restore", html `<button>Restore selection</button>`, { restore: h.revision }) : html `<span class="meta">Current selection</span>`}</div>`)}</details>`
46
43
  : "";
47
- return `<section class="skills">${options.error ? `<p class="problem" role="alert">${e(options.error)}</p>` : ""}<p>${active ? `${active} skill${active === 1 ? "" : "s"} enabled for new tasks.` : "No skills enabled for this project."} Active tasks keep their saved versions.</p>${add}${search}${cards || "<p>Your library is empty. Add a skill to review and enable it here.</p>"}${history}</section>`;
44
+ return html `<section class="skills">${options.error ? html `<p class="problem" role="alert">${options.error}</p>` : ""}<p>${active ? `${active} skill${active === 1 ? "" : "s"} enabled for new tasks.` : "No skills enabled for this project."} Active tasks keep their saved versions.</p>${add}${search}${cards.length ? cards : html `<p>Your library is empty. Add a skill to review and enable it here.</p>`}${history}</section>`;
48
45
  }
49
46
  export function skillsSnapshotHtml(snapshot) {
50
47
  if (!snapshot || !snapshot.packages.length)
51
48
  return "";
52
- return `<details class="skills"><summary>Skills supplied (${snapshot.packages.length})</summary><p class="meta">Exact versions supplied to this run. This does not confirm the agent read or applied them.</p>${snapshot.packages.map((s) => `<details><summary>${e(s.name)} · ${s.sha.slice(0, 10)}</summary>${source(s)}</details>`).join("")}</details>`;
49
+ return html `<details class="skills"><summary>Skills supplied (${snapshot.packages.length})</summary><p class="meta">Exact versions supplied to this run. This does not confirm the agent read or applied them.</p>${snapshot.packages.map((s) => html `<details><summary>${s.name} · ${s.sha.slice(0, 10)}</summary>${source(s)}</details>`)}</details>`;
53
50
  }
54
51
  export function skillsScript() {
55
52
  return `(()=>{const search=document.querySelector('[data-skill-search]');if(search)search.addEventListener('input',()=>{let found=0;document.querySelectorAll('[data-skill-group]').forEach(el=>{el.hidden=!el.dataset.skillQuery.toLowerCase().includes(search.value.trim().toLowerCase());if(!el.hidden)found++;});document.querySelector('[data-skill-no-matches]').hidden=found>0;});const form=document.querySelector('[data-skill-import]');if(!form)return;const method=form.querySelector('[data-skill-method]'),error=form.querySelector('[data-skill-error]');function sync(){form.querySelectorAll('[data-skill-input]').forEach(el=>{el.hidden=el.dataset.skillInput!==method.value;el.querySelectorAll('input,textarea').forEach(i=>i.disabled=el.hidden);});}method.addEventListener('change',sync);sync();form.addEventListener('submit',async event=>{if(method.value!=='folder')return;if(form.dataset.prepared==='yes'){delete form.dataset.prepared;return;}event.preventDefault();error.hidden=true;const button=form.querySelector('button');button.disabled=true;try{const files=Array.from(form.querySelector('[data-skill-folder]').files);if(!files.length||files.length>64||files.reduce((n,f)=>n+f.size,0)>1048576||files.some(f=>f.size>262144))throw Error('Choose one skill folder with up to 64 files and 1 MB total.');const packed=[];for(const file of files){const parts=file.webkitRelativePath.split('/');parts.shift();const data=new Uint8Array(await file.arrayBuffer());let binary='';for(const byte of data)binary+=String.fromCharCode(byte);packed.push({path:parts.join('/'),base64:btoa(binary)});}form.querySelector('[name=files]').value=JSON.stringify(packed);form.dataset.prepared='yes';button.disabled=false;form.requestSubmit();}catch(e){error.textContent=e.message||'The folder could not be read. Select it again.';error.hidden=false;button.disabled=false;}});})();`;
56
53
  }
57
54
  export function skillTestFeedbackHtml(test, csrf, canManage, error = "", draft = "") {
58
- return `<section class="skills" id="skill-test-feedback"><h2>Improve this test</h2>${test.sourceRun ? `<p>Revises test run #${test.sourceRun}.</p>` : ""}${test.feedback ? `<details><summary>Feedback for this test</summary><p>${e(test.feedback)}</p></details>` : ""}${test.revisions.length ? `<p>${test.revisions.map((id, i) => `<a href="/t/${encodeURIComponent(id)}">Test revision ${i + 1}</a>`).join(" · ")}</p>` : ""}${error ? `<p role="alert">${e(error)}</p>` : ""}${canManage && csrf ? `<form method="post" action="/settings/skills/revise">${hidden({ csrf, repo: test.repo, run: test.run, nonce: randomUUID() })}<label>What should change?<textarea name="feedback" required maxlength="500" rows="3" placeholder="Try a clearer action label and explain why it fits the user’s goal.">${e(draft)}</textarea></label><p class="meta">Creates another report task with this skill version and your feedback. Review its scope before it runs.</p><button>Create revision</button></form>` : ""}</section>`;
55
+ return html `<section class="skills" id="skill-test-feedback"><h2>Improve this test</h2>${test.sourceRun ? html `<p>Revises test run #${test.sourceRun}.</p>` : ""}${test.feedback ? html `<details><summary>Feedback for this test</summary><p>${test.feedback}</p></details>` : ""}${test.revisions.length ? html `<p>${joinHtml(test.revisions.map((id, i) => html `<a href="/t/${encodeURIComponent(id)}">Test revision ${i + 1}</a>`), " · ")}</p>` : ""}${error ? html `<p role="alert">${error}</p>` : ""}${canManage && csrf ? postForm("/settings/skills/revise", html `<label>What should change?<textarea name="feedback" required maxlength="500" rows="3" placeholder="Try a clearer action label and explain why it fits the user’s goal.">${draft}</textarea></label><p class="meta">Creates another report task with this skill version and your feedback. Review its scope before it runs.</p><button>Create revision</button>`, { hidden: fields({ repo: test.repo, run: test.run, nonce: randomUUID() }) }) : ""}</section>`;
59
56
  }
package/dist/skills.d.ts CHANGED
@@ -67,7 +67,7 @@ export declare function applyInstall(repo: string, writeContext: boolean): Insta
67
67
  * project setup flow.
68
68
  */
69
69
  export declare const CLAUDE_CODE_MANAGED_MARK = "<!-- standing-orders:claude-code-skill:v1 -->";
70
- export declare const CLAUDE_CODE_GUIDES: readonly ["console", "operating", "runner"];
70
+ export declare const CLAUDE_CODE_GUIDES: readonly ["console", "operating", "runner", "release"];
71
71
  /** The thin user-level skill `toolroll onboard` writes (agent-onboard.ts). */
72
72
  export declare const OPERATOR_SKILL_MARK = "<!-- toolroll:operator-skill:v1";
73
73
  export type ClaudeCodeSkillFile = {
package/dist/skills.js CHANGED
@@ -161,7 +161,7 @@ export function applyInstall(repo, writeContext) {
161
161
  * project setup flow.
162
162
  */
163
163
  export const CLAUDE_CODE_MANAGED_MARK = "<!-- standing-orders:claude-code-skill:v1 -->";
164
- export const CLAUDE_CODE_GUIDES = ["console", "operating", "runner"];
164
+ export const CLAUDE_CODE_GUIDES = ["console", "operating", "runner", "release"];
165
165
  /** The thin user-level skill `toolroll onboard` writes (agent-onboard.ts). */
166
166
  export const OPERATOR_SKILL_MARK = "<!-- toolroll:operator-skill:v1";
167
167
  export function defaultClaudeCodeSkillDir(home = homedir()) {
@@ -213,6 +213,7 @@ Use the installed \`toolroll\` command. Its live help is authoritative.
213
213
  - [Console guide](console.md) — where a person can review and act.
214
214
  - [Operating guide](operating.md) — the command contract and safe workflow.
215
215
  - [Runner guide](runner.md) — claims, leases, heartbeats, and fencing.
216
+ - [Release guide](release.md) — releasing Toolroll itself with \`toolroll release <branch>\`.
216
217
  `;
217
218
  }
218
219
  /** One linked file, generated from the guide embedded in this binary. */
@@ -1,7 +1,8 @@
1
1
  import type { Store } from "./store.js";
2
- export declare function slackSettingsHtml(store: Store, dir: string, csrf: string, options?: {
2
+ import { type Html } from "./html.js";
3
+ export declare function slackSettingsHtml(store: Store, dir: string, options?: {
3
4
  code?: string;
4
5
  problem?: string;
5
6
  now?: Date;
6
7
  who?: string;
7
- }): string;
8
+ }): Html;
@@ -1,66 +1,41 @@
1
1
  import { loadPrimary } from "./webhooks.js";
2
+ import { html, postForm } from "./html.js";
2
3
  import { loadSlackCredentials } from "./slack-api.js";
3
4
  import { ChatState } from "./chat-delivery-state.js";
4
- const escape = (text) => text
5
- .replaceAll("&", "&amp;")
6
- .replaceAll("<", "&lt;")
7
- .replaceAll(">", "&gt;")
8
- .replaceAll('"', "&quot;")
9
- .replaceAll("'", "&#39;");
10
- export function slackSettingsHtml(store, dir, csrf, options = {}) {
5
+ export function slackSettingsHtml(store, dir, options = {}) {
11
6
  const credentials = loadSlackCredentials(dir), state = new ChatState(store, "slack"), bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [], binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null, others = bindings.length - (binding === null ? 0 : 1);
12
7
  const runtime = credentials ? state.runtime(credentials.installation) : null;
13
8
  const now = options.now ?? new Date(), live = runtime &&
14
9
  runtime.leaseUntil !== null &&
15
10
  runtime.leaseUntil > now.toISOString() &&
16
11
  runtime.connected;
17
- const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">`;
18
- const password = '<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>';
19
- const post = (action, content) => `<form method="post" action="/settings/slack/${action}" class="card">${hidden}${content}</form>`;
20
- let content = "";
12
+ const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
13
+ const post = (action, content) => postForm(`/settings/slack/${action}`, content, { attrs: { class: "card" } });
14
+ const parts = [];
21
15
  if (!credentials) {
22
- content =
23
- "<p>Manage projects and review results in a private Slack conversation.</p>" +
24
- '<details open><summary>Create your Slack app</summary><ol><li>Download the <a href="/settings/slack/manifest">app manifest</a>, then <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">create an app</a> in your workspace using that manifest.</li><li>Under Basic Information, create an app-level token with <code>connections:write</code>.</li><li>Under OAuth &amp; Permissions, install the app and copy its bot token.</li></ol></details>' +
25
- post("connect", '<label>App token<input style="min-height:44px" type="password" name="app-token" autocomplete="off" placeholder="xapp-…" required></label><label>Bot token<input style="min-height:44px" type="password" name="bot-token" autocomplete="off" placeholder="xoxb-…" required></label>' +
26
- password +
27
- '<p class="meta">Tokens stay on this installation. Next, pair your own Slack account.</p><button type="submit">Connect Slack</button>');
16
+ parts.push(html `<p>Manage projects and review results in a private Slack conversation.</p>`, html `<details open><summary>Create your Slack app</summary><ol><li>Download the <a href="/settings/slack/manifest">app manifest</a>, then <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">create an app</a> in your workspace using that manifest.</li><li>Under Basic Information, create an app-level token with <code>connections:write</code>.</li><li>Under OAuth &amp; Permissions, install the app and copy its bot token.</li></ol></details>`, post("connect", html `<label>App token<input style="min-height:44px" type="password" name="app-token" autocomplete="off" placeholder="xapp-…" required></label><label>Bot token<input style="min-height:44px" type="password" name="bot-token" autocomplete="off" placeholder="xoxb-…" required></label>${password}<p class="meta">Tokens stay on this installation. Next, pair your own Slack account.</p><button type="submit">Connect Slack</button>`));
28
17
  }
29
18
  else {
30
- content = `<p><strong>${escape(credentials.workspace)}</strong> · ${live ? "Connected" : "Waiting for connection"}</p>`;
19
+ parts.push(html `<p><strong>${credentials.workspace}</strong> · ${live ? "Connected" : "Waiting for connection"}</p>`);
31
20
  if (runtime?.problem)
32
- content += `<p role="status">${escape(String(runtime.problem))}</p>`;
21
+ parts.push(html `<p role="status">${String(runtime.problem)}</p>`);
33
22
  if (!live && !runtime?.problem)
34
- content +=
35
- "<p>Keep the Toolroll worker running to receive Slack messages.</p>";
23
+ parts.push(html `<p>Keep the Toolroll worker running to receive Slack messages.</p>`);
36
24
  if (options.code) {
37
- content +=
38
- "<h2>Pair your account</h2><p>Send this in a direct message to your Toolroll Slack app. It expires in 10 minutes.</p>" +
39
- `<label>Pairing message<input type="text" readonly value="pair ${escape(options.code)}" autocomplete="off" style="width:100%;max-width:100%;font-size:14px;min-height:44px"></label>` +
40
- '<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/slack">Check connection</a>';
25
+ parts.push(html `<h2>Pair your account</h2><p>Send this in a direct message to your Toolroll Slack app. It expires in 10 minutes.</p>`, html `<label>Pairing message<input type="text" readonly value="pair ${options.code}" autocomplete="off" style="width:100%;max-width:100%;font-size:14px;min-height:44px"></label>`, html `<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/slack">Check connection</a>`);
41
26
  }
42
27
  else if (binding && state.live(binding)) {
43
28
  const { pending, dropped: failed } = state.partCounts(binding.id);
44
- content +=
45
- `<p>Your Slack account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}${pending ? ` ${pending} replies waiting to send.` : ""}${failed ? ` ${failed} replies could not be delivered. Open the saved chat to recover them.` : ""}</p>` +
46
- "<p>Try “What needs my attention?” or “Show the evidence for the latest result.”</p>" +
47
- '<p><a href="/chat">Open saved chat</a></p>' +
48
- (loadPrimary(process.env, dir) === "slack"
49
- ? "<p>Task updates are sent here.</p>"
50
- : post("alerts", '<button type="submit">Send task updates here</button>'));
29
+ parts.push(html `<p>Your Slack account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}${pending ? ` ${pending} replies waiting to send.` : ""}${failed ? ` ${failed} replies could not be delivered. Open the saved chat to recover them.` : ""}</p>`, html `<p>Try “What needs my attention?” or “Show the evidence for the latest result.”</p>`, html `<p><a href="/chat">Open saved chat</a></p>`, loadPrimary(process.env, dir) === "slack"
30
+ ? html `<p>Task updates are sent here.</p>`
31
+ : post("alerts", html `<button type="submit">Send task updates here</button>`));
51
32
  }
52
33
  else {
53
- content += post("pair", "<h2>Pair your account</h2><p>Your paired Slack account can read your connected projects and confirm proposed changes. Password approvals still open in Toolroll." +
54
- (others ? ` ${others} teammate${others === 1 ? " is" : "s are"} already paired.` : "") + "</p>" +
55
- password +
56
- '<button type="submit">Create pairing code</button>');
34
+ parts.push(post("pair", html `<h2>Pair your account</h2><p>Your paired Slack account can read your connected projects and confirm proposed changes. Password approvals still open in Toolroll.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} already paired.` : ""}</p>${password}<button type="submit">Create pairing code</button>`));
57
35
  }
58
36
  if (binding && state.live(binding) && !options.code)
59
- content += post("unpair", password + '<p class="meta">Unpairing ends every open button in your chat. Teammates are unaffected.</p><button type="submit">Unpair my account</button>');
60
- content +=
61
- "<details><summary>Connection settings</summary>" +
62
- post("disconnect", password + '<button type="submit">Disconnect Slack</button>') +
63
- "</details>";
37
+ parts.push(post("unpair", html `${password}<p class="meta">Unpairing ends every open button in your chat. Teammates are unaffected.</p><button type="submit">Unpair my account</button>`));
38
+ parts.push(html `<details><summary>Connection settings</summary>${post("disconnect", html `${password}<button type="submit">Disconnect Slack</button>`)}</details>`);
64
39
  }
65
- return `<section style="max-width:42rem;overflow-wrap:anywhere"><p><a href="/settings">Settings</a></p><h1>Slack</h1>${options.problem ? `<p class="problem" role="alert">${escape(options.problem)}</p>` : ""}${content}<details><summary>Access and setup</summary><p>Private messages and threads use your Toolroll permissions. Shared channels are not supported. Secure review links use this installation’s configured HTTPS console address.</p><p>Socket Mode connects out to Slack. It does not need a public webhook or a Tailscale account.</p></details></section>`;
40
+ return html `<section style="max-width:42rem;overflow-wrap:anywhere"><p><a href="/settings">Settings</a></p><h1>Slack</h1>${options.problem ? html `<p class="problem" role="alert">${options.problem}</p>` : ""}${parts}<details><summary>Access and setup</summary><p>Private messages and threads use your Toolroll permissions. Shared channels are not supported. Secure review links use this installation’s configured HTTPS console address.</p><p>Socket Mode connects out to Slack. It does not need a public webhook or a Tailscale account.</p></details></section>`;
66
41
  }
@@ -4,6 +4,7 @@
4
4
  * only) stop new work at 100 %. An instance operator's page; budgets change
5
5
  * with a step-up.
6
6
  */
7
+ import { type Html } from "./html.js";
7
8
  import { type BudgetState, type SpendItem } from "./spend.js";
8
9
  export declare const SPEND_CSS: string;
9
10
  export type SpendView = {
@@ -23,6 +24,6 @@ export type SpendView = {
23
24
  export declare function spendHtml(view: SpendView, notice: {
24
25
  said?: string | null;
25
26
  problem?: string | null;
26
- }): string;
27
+ }): Html;
27
28
  /** One row per piece of spend, for a spreadsheet. */
28
29
  export declare function spendCsv(items: readonly SpendItem[], teammateNames: Map<number, string>): string;
package/dist/spend-ui.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * only) stop new work at 100 %. An instance operator's page; budgets change
5
5
  * with a step-up.
6
6
  */
7
+ import { html, postForm } from "./html.js";
7
8
  import { budgetLabel, usd } from "./spend.js";
8
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9
9
  const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
10
10
  export const SPEND_CSS = `.spend{max-width:960px;min-width:0}.spend-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.spend-head h1{margin:0}` +
11
11
  `.spend-month{display:flex;gap:10px;align-items:baseline;font-size:.875rem}.spend-total{font-size:1.5rem;font-weight:600;margin:12px 0 2px;font-variant-numeric:tabular-nums}` +
@@ -35,10 +35,10 @@ function breakdown(items, keyOf, top = 12) {
35
35
  function table(title, rows, name) {
36
36
  if (rows.length === 0)
37
37
  return "";
38
- return `<section><h2>${e(title)}</h2><table><tbody>${rows.map(row => `<tr><td>${e(name(row.key))}</td><td class="n">${row.count}</td><td class="n">${usd(row.microusd)}${row.unpriced > 0 ? ` <span class="meta">+${row.unpriced} unpriced</span>` : ""}</td></tr>`).join("")}</tbody></table></section>`;
38
+ return html `<section><h2>${title}</h2><table><tbody>${rows.map(row => html `<tr><td>${name(row.key)}</td><td class="n">${row.count}</td><td class="n">${usd(row.microusd)}${row.unpriced > 0 ? html ` <span class="meta">+${row.unpriced} unpriced</span>` : ""}</td></tr>`)}</tbody></table></section>`;
39
39
  }
40
40
  export function spendHtml(view, notice) {
41
- const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
41
+ const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
42
42
  const total = view.items.reduce((sum, item) => sum + (item.microusd ?? 0), 0);
43
43
  const unpriced = view.items.filter(item => item.microusd === null && (item.tokensIn !== null || item.kind !== "run")).length;
44
44
  const counts = { run: view.items.filter(item => item.kind === "run").length, teammate: view.items.filter(item => item.kind === "teammate").length, chat: view.items.filter(item => item.kind === "chat").length, sort: view.items.filter(item => item.kind === "sort").length };
@@ -48,38 +48,15 @@ export function spendHtml(view, notice) {
48
48
  const width = Math.min(100, Math.max(0, budget.percent));
49
49
  const over = budget.spentMicrousd >= budget.limitMicrousd;
50
50
  const label = budgetLabel(budget, budget.scope === "teammate" ? nameOfTeammate(budget.key) : undefined).replace(/'s$/, "");
51
- return `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${e(label)}</span>` +
52
- `<span class="figures">${usd(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span>` +
53
- `<span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span>` +
54
- `<span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher budget." : budget.hardStop ? "Stops API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} unpriced` : ""}</span>` +
55
- `<details><summary>Change</summary><form method="post" action="/spend/budget" class="budget-form"><input type="hidden" name="csrf" value="${e(view.csrf)}"><input type="hidden" name="target" value="${e(`${budget.scope}:${budget.key}`)}">` +
56
- `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label>` +
57
- `<label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? " checked" : ""}> Stop new work at 100%</label>` +
58
- `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>` +
59
- `<button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button></form></details></div>`;
60
- }).join("");
51
+ return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${usd(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher budget." : budget.hardStop ? "Stops API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} unpriced` : ""}</span><details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Stop new work at 100%</label><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label><button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details></div>`;
52
+ });
61
53
  const groups = ["Everything", "Projects", "People", "Teammates"];
62
54
  const options = groups.map(group => {
63
55
  const members = view.targets.filter(one => one.group === group);
64
- return members.length === 0 ? "" : `<optgroup label="${group}">${members.map(one => `<option value="${e(one.value)}">${e(one.label)}</option>`).join("")}</optgroup>`;
65
- }).join("");
66
- const add = `<details${view.budgets.length === 0 ? " open" : ""}><summary>Add a budget</summary><form method="post" action="/spend/budget" class="budget-form"><input type="hidden" name="csrf" value="${e(view.csrf)}">` +
67
- `<label>For<select name="target" required>${options}</select></label>` +
68
- `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label>` +
69
- `<label class="choice"><input type="checkbox" name="stop" value="1" checked> Stop new work at 100%</label>` +
70
- `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>` +
71
- `<button type="submit" name="action" value="save">Add budget</button></form></details>`;
72
- const csv = `/spend?month=${e(view.month)}&amp;format=csv`;
73
- return `<article class="spend">` +
74
- `<div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${e(view.previous)}">← ${e(view.previous)}</a><strong>${e(view.month)}</strong>${view.next === null ? "" : `<a href="/spend?month=${e(view.next)}">${e(view.next)} →</a>`}<a href="${csv}" download>CSV</a></p></div>${note}` +
75
- `<p class="spend-total" data-spend-total="${total}">${usd(total)}</p><p class="meta">${parts.join(" · ")}${unpriced > 0 ? ` · ${unpriced} unpriced (no reported cost or catalogue price)` : ""} · UTC month</p>` +
76
- `<section><h2>Budgets</h2>${budgets === "" ? `<p class="meta">No budgets yet.</p>` : budgets}${add}</section>` +
77
- `<div class="spend-grid">` +
78
- table("By project", breakdown(view.items, item => item.project), projectName) +
79
- table("By person", breakdown(view.items, item => item.person), key => key) +
80
- table("By teammate", breakdown(view.items, item => item.teammate === null ? null : String(item.teammate)), nameOfTeammate) +
81
- table("By model", breakdown(view.items, item => `${item.provider}${item.model === null ? "" : ` · ${item.model}`}`), key => key) +
82
- `</div><p class="meta">Subscription work is $0; its limits are on Tasks. API work is what the provider reported, or its tokens at the prices in Settings → Models.</p></article>`;
56
+ return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}">${one.label}</option>`)}</optgroup>`;
57
+ });
58
+ const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a budget</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Stop new work at 100%</label><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label><button type="submit" name="action" value="save">Add budget</button>`, { attrs: { class: "budget-form" } })}</details>`;
59
+ return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${view.previous}">← ${view.previous}</a><strong>${view.month}</strong>${view.next === null ? "" : html `<a href="/spend?month=${view.next}">${view.next} →</a>`}<a href="/spend?month=${view.month}&amp;format=csv" download>CSV</a></p></div>${note}<p class="spend-total" data-spend-total="${total}">${usd(total)}</p><p class="meta">${parts.join(" · ")}${unpriced > 0 ? ` · ${unpriced} unpriced (no reported cost or catalogue price)` : ""} · UTC month</p><section><h2>Budgets</h2>${budgets.length === 0 ? html `<p class="meta">No budgets yet.</p>` : budgets}${add}</section><div class="spend-grid">${table("By project", breakdown(view.items, item => item.project), projectName)}${table("By person", breakdown(view.items, item => item.person), key => key)}${table("By teammate", breakdown(view.items, item => item.teammate === null ? null : String(item.teammate)), nameOfTeammate)}${table("By model", breakdown(view.items, item => `${item.provider}${item.model === null ? "" : ` · ${item.model}`}`), key => key)}</div><p class="meta">Subscription work is $0; its limits are on Tasks. API work is what the provider reported, or its tokens at the prices in Settings → Models.</p></article>`;
83
60
  }
84
61
  /** One row per piece of spend, for a spreadsheet. */
85
62
  export function spendCsv(items, teammateNames) {
package/dist/sso-ui.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * operator's page; changes take their password.
5
5
  */
6
6
  import type { OidcSettings } from "./oidc.js";
7
+ import { type Html } from "./html.js";
7
8
  export declare const SSO_CSS: string;
8
9
  export type SsoSettingsView = {
9
10
  settings: OidcSettings | null;
@@ -16,7 +17,7 @@ export type SsoSettingsView = {
16
17
  /** Whether the person looking is linked to the provider already. */
17
18
  linked: boolean;
18
19
  };
19
- export declare function ssoSettingsHtml(view: SsoSettingsView, csrf: string, notice: {
20
+ export declare function ssoSettingsHtml(view: SsoSettingsView, notice: {
20
21
  said?: string | null;
21
22
  problem?: string | null;
22
- }): string;
23
+ }): Html;
package/dist/sso-ui.js CHANGED
@@ -1,39 +1,38 @@
1
- const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1
+ import { html, postForm } from "./html.js";
2
2
  export const SSO_CSS = `.sign-in{max-width:780px;min-width:0}.sign-in form{display:grid;gap:12px;margin:0}.sign-in label{display:grid;gap:6px}.sign-in input,.sign-in select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}` +
3
3
  `.sign-in .card{padding:16px 18px;margin:12px 0}.sign-in .card h2{font-size:1.05rem;margin:0 0 6px}.sign-in .redirect code{user-select:all;overflow-wrap:anywhere}.sign-in button{justify-self:start;min-height:44px}` +
4
4
  `.sign-in .rules{display:grid;gap:8px}.sign-in .rule{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,2fr);gap:8px;align-items:start}.sign-in .rule select[multiple]{min-height:5.5rem}` +
5
5
  `.sign-in .row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.sign-in fieldset{border:0;padding:0;margin:0;display:grid;gap:6px}.sign-in fieldset label{display:flex;gap:8px;align-items:center}.sign-in fieldset input{width:auto}` +
6
6
  `@media(max-width:600px){.sign-in .rule{grid-template-columns:1fr}.sign-in input,.sign-in select{font-size:16px}}`;
7
- const password = '<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>';
7
+ const password = html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`;
8
8
  function ruleRow(index, rule, projects) {
9
9
  const chosen = rule === null ? [] : rule.projects === "all" ? ["all"] : rule.projects;
10
- return `<div class="rule"><label>Group<input type="text" name="group-${index}" value="${e(rule?.group ?? "")}" placeholder="${index === 0 ? "for example: engineering" : ""}" autocomplete="off"></label>` +
11
- `<label>Role<select name="role-${index}"><option value="operator"${rule?.role === "operator" ? " selected" : ""}>Operator</option><option value="viewer"${rule?.role === "viewer" ? " selected" : ""}>Viewer</option></select></label>` +
12
- `<label>Projects<select name="projects-${index}" multiple><option value="all"${chosen.includes("all") ? " selected" : ""}>All projects</option>${projects.map(one => `<option value="${e(one.path)}"${chosen.includes(one.path) ? " selected" : ""}>${e(one.name)}</option>`).join("")}</select></label></div>`;
10
+ return html `<div class="rule"><label>Group<input type="text" name="group-${index}" value="${rule?.group ?? ""}" placeholder="${index === 0 ? "for example: engineering" : ""}" autocomplete="off"></label>\
11
+ <label>Role<select name="role-${index}"><option value="operator"${rule?.role === "operator" ? " selected" : ""}>Operator</option><option value="viewer"${rule?.role === "viewer" ? " selected" : ""}>Viewer</option></select></label>\
12
+ <label>Projects<select name="projects-${index}" multiple><option value="all"${chosen.includes("all") ? " selected" : ""}>All projects</option>${projects.map(one => html `<option value="${one.path}"${chosen.includes(one.path) ? " selected" : ""}>${one.name}</option>`)}</select></label></div>`;
13
13
  }
14
- export function ssoSettingsHtml(view, csrf, notice) {
14
+ export function ssoSettingsHtml(view, notice) {
15
15
  const s = view.settings;
16
- const hidden = (fields) => Object.entries(fields).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join("");
17
- const rows = [...(s?.rules ?? []), null, null].slice(0, Math.max(3, (s?.rules.length ?? 0) + 1)).map((rule, index) => ruleRow(index, rule, view.projects)).join("");
18
- const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
16
+ const rows = [...(s?.rules ?? []), null, null].slice(0, Math.max(3, (s?.rules.length ?? 0) + 1)).map((rule, index) => ruleRow(index, rule, view.projects));
17
+ const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
19
18
  const redirect = view.redirect === null
20
- ? `<p class="meta">Open this page from the address people use (your https address) to see the redirect address to register.</p>`
21
- : `<p class="redirect">Register this redirect address with the provider: <code>${e(view.redirect)}</code></p>`;
22
- const form = `<form method="post" action="/settings/sign-in">${hidden({ csrf, action: "save" })}` +
23
- `<label>Provider address (issuer)<input type="url" name="issuer" value="${e(s?.issuer ?? "")}" placeholder="https://acme.okta.com/oauth2/default" required inputmode="url" autocomplete="off"></label>` +
24
- `<label>Client ID<input type="text" name="client-id" value="${e(s?.clientId ?? "")}" required autocomplete="off"></label>` +
25
- `<label>Client secret<input type="password" name="client-secret" autocomplete="off" placeholder="${s?.clientSecret ? "Saved. Leave blank to keep it" : "Leave blank for a public client"}"></label>` +
26
- `<details><summary>More</summary><label>Button says “Sign in with …”<input type="text" name="label" value="${e(s?.label ?? "")}" placeholder="Okta"></label>` +
27
- `<label>Scopes<input type="text" name="scopes" value="${e(s?.scopes ?? "openid email profile")}"></label><label>Groups claim<input type="text" name="groups-claim" value="${e(s?.groupsClaim ?? "groups")}"></label></details>` +
28
- `<h2>Who may sign in</h2><p class="meta">A person's first matching group sets their role and projects, again at every sign-in. No match, no entry. Use * for everyone.</p><div class="rules">${rows}</div>` +
29
- `<fieldset><legend>Passwords</legend><label><input type="radio" name="passwords" value="everyone"${s?.passwords !== "operators" ? " checked" : ""}> Everyone may still use a password</label>` +
30
- `<label><input type="radio" name="passwords" value="operators"${s?.passwords === "operators" ? " checked" : ""}> Only instance operators (a way in if the provider is down)</label></fieldset>` +
31
- `${password}<button>${s === null ? "Turn on sign-in with the provider" : "Save"}</button></form>`;
32
- const test = s === null ? "" : `<form method="post" action="/settings/sign-in" class="row">${hidden({ csrf, action: "test" })}<button class="secondary">Check the provider</button></form>`;
19
+ ? html `<p class="meta">Open this page from the address people use (your https address) to see the redirect address to register.</p>`
20
+ : html `<p class="redirect">Register this redirect address with the provider: <code>${view.redirect}</code></p>`;
21
+ const form = postForm("/settings/sign-in", html `\
22
+ <label>Provider address (issuer)<input type="url" name="issuer" value="${s?.issuer ?? ""}" placeholder="https://acme.okta.com/oauth2/default" required inputmode="url" autocomplete="off"></label>\
23
+ <label>Client ID<input type="text" name="client-id" value="${s?.clientId ?? ""}" required autocomplete="off"></label>\
24
+ <label>Client secret<input type="password" name="client-secret" autocomplete="off" placeholder="${s?.clientSecret ? "Saved. Leave blank to keep it" : "Leave blank for a public client"}"></label>\
25
+ <details><summary>More</summary><label>Button says “Sign in with …”<input type="text" name="label" value="${s?.label ?? ""}" placeholder="Okta"></label>\
26
+ <label>Scopes<input type="text" name="scopes" value="${s?.scopes ?? "openid email profile"}"></label><label>Groups claim<input type="text" name="groups-claim" value="${s?.groupsClaim ?? "groups"}"></label></details>\
27
+ <h2>Who may sign in</h2><p class="meta">A person's first matching group sets their role and projects, again at every sign-in. No match, no entry. Use * for everyone.</p><div class="rules">${rows}</div>\
28
+ <fieldset><legend>Passwords</legend><label><input type="radio" name="passwords" value="everyone"${s?.passwords !== "operators" ? " checked" : ""}> Everyone may still use a password</label>\
29
+ <label><input type="radio" name="passwords" value="operators"${s?.passwords === "operators" ? " checked" : ""}> Only instance operators (a way in if the provider is down)</label></fieldset>\
30
+ ${password}<button>${s === null ? "Turn on sign-in with the provider" : "Save"}</button>`, { hidden: { action: "save" } });
31
+ const test = s === null ? "" : postForm("/settings/sign-in", html `<button class="secondary">Check the provider</button>`, { attrs: { class: "row" }, hidden: { action: "test" } });
33
32
  const link = s === null ? "" : view.linked
34
- ? `<p class="meta">You sign in with ${e(s.label)}.</p>`
35
- : `<section class="card"><h2>Your account</h2><p>Sign in with ${e(s.label)} as this account from now on.</p><p><a class="button-link" href="/login/sso?link=1">Link to ${e(s.label)}</a></p></section>`;
36
- const off = s === null ? "" : `<details class="card"><summary>Turn off</summary><form method="post" action="/settings/sign-in">${hidden({ csrf, action: "remove" })}<p>People who signed in with ${e(s.label)} can't sign in until it's on again, unless they have a password.</p>${password}<button class="danger">Turn off sign-in with ${e(s.label)}</button></form></details>`;
37
- return `<section class="sign-in">${note}<p>People sign in with your organisation's identity provider (Okta, Microsoft Entra, Google, anything that speaks OpenID Connect), and its groups decide what they may do.</p>` +
38
- `${redirect}<section class="card">${form}</section>${test}${link}${off}</section>`;
33
+ ? html `<p class="meta">You sign in with ${s.label}.</p>`
34
+ : html `<section class="card"><h2>Your account</h2><p>Sign in with ${s.label} as this account from now on.</p><p><a class="button-link" href="/login/sso?link=1">Link to ${s.label}</a></p></section>`;
35
+ const off = s === null ? "" : html `<details class="card"><summary>Turn off</summary>${postForm("/settings/sign-in", html `<p>People who signed in with ${s.label} can't sign in until it's on again, unless they have a password.</p>${password}<button class="danger">Turn off sign-in with ${s.label}</button>`, { hidden: { action: "remove" } })}</details>`;
36
+ return html `<section class="sign-in">${note}<p>People sign in with your organisation's identity provider (Okta, Microsoft Entra, Google, anything that speaks OpenID Connect), and its groups decide what they may do.</p>\
37
+ ${redirect}<section class="card">${form}</section>${test}${link}${off}</section>`;
39
38
  }
@@ -1,3 +1,4 @@
1
+ import { type Html } from "./html.js";
1
2
  import { type CheckoutPlan } from "./checkout-cleanup.js";
2
3
  import { type SweepRecord } from "./storage-sweep.js";
3
4
  export declare const STORAGE_CSS: string;
@@ -12,4 +13,4 @@ export type StorageView = {
12
13
  export declare function storageHtml(view: StorageView, notice: {
13
14
  said?: string | null;
14
15
  problem?: string | null;
15
- }): string;
16
+ }): Html;