chamba 0.7.0 → 0.9.0

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 (134) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +34 -8
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -210
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -261
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -36
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +4 -4
  28. package/templates/Dockerfile +15 -22
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +4 -2
  31. package/templates/context/web-pane-craft.md +1 -1
  32. package/templates/pane-apps/client/assets/api-BBDf_rzz.js +12 -0
  33. package/templates/pane-apps/client/assets/api-DNLbSBpK.css +1 -0
  34. package/templates/pane-apps/client/assets/bee-B7t97l5B.webp +0 -0
  35. package/templates/pane-apps/client/assets/buzz-CUUsbnXH.mp3 +0 -0
  36. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  37. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  38. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  39. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  40. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  41. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  42. package/templates/pane-apps/client/assets/files-BkmyervY.js +1 -0
  43. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  44. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  45. package/templates/pane-apps/client/assets/home-3SeGzySM.css +1 -0
  46. package/templates/pane-apps/client/assets/home-Cu9xOd2i.js +1 -0
  47. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  48. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  49. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  50. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  51. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  52. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  53. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  54. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  55. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  56. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  57. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  58. package/templates/pane-apps/client/assets/pop-D5ZANBC3.mp3 +0 -0
  59. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  60. package/templates/pane-apps/client/assets/reviews-DMrGB-kG.js +153 -0
  61. package/templates/pane-apps/client/assets/reviews-nK_yRav9.css +1 -0
  62. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  63. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  64. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  65. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  66. package/templates/pane-apps/client/assets/specs-Cabb15F0.js +13 -0
  67. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  68. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  69. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  70. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  71. package/templates/pane-apps/client/assets/useNow-BHFKFraS.js +1 -0
  72. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  73. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  74. package/templates/pane-apps/client/home/index.html +16 -0
  75. package/templates/pane-apps/client/reviews/index.html +17 -0
  76. package/templates/pane-apps/client/specs/index.html +4 -2
  77. package/templates/pane-apps/server/home.mjs +12 -0
  78. package/templates/pane-apps/server/reviews.mjs +21 -0
  79. package/templates/pane-apps/server/specs.mjs +5 -1588
  80. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  81. package/templates/skills/dx-review/SKILL.md +136 -0
  82. package/templates/skills/dx-review/references/acts.md +233 -0
  83. package/templates/skills/dx-spec/SKILL.md +230 -30
  84. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  85. package/templates/skills/dx-spec/references/review-guide.md +6 -52
  86. package/templates/skills/dx-spec/references/spec-guide.md +25 -5
  87. package/templates/skills/dx-spec/references/stages.md +149 -60
  88. package/templates/skills/dx-spec-execute/SKILL.md +217 -38
  89. package/templates/startup.mjs +22 -16
  90. package/templates/tool-helper.sh +168 -0
  91. package/templates/webterm/README.md +82 -27
  92. package/templates/webterm/color.js +61 -0
  93. package/templates/webterm/config.js +55 -4
  94. package/templates/webterm/context/claude.md +0 -1
  95. package/templates/webterm/public/app/alerts.js +95 -14
  96. package/templates/webterm/public/app/anchored.js +81 -0
  97. package/templates/webterm/public/app/composer.js +37 -3
  98. package/templates/webterm/public/app/connection.js +23 -0
  99. package/templates/webterm/public/app/dom.js +3 -0
  100. package/templates/webterm/public/app/frames.js +52 -18
  101. package/templates/webterm/public/app/home-host.js +16 -0
  102. package/templates/webterm/public/app/main.js +24 -8
  103. package/templates/webterm/public/app/new-session.js +13 -41
  104. package/templates/webterm/public/app/palette.js +58 -0
  105. package/templates/webterm/public/app/pane-shape.js +169 -0
  106. package/templates/webterm/public/app/pane-shell.js +240 -135
  107. package/templates/webterm/public/app/pane.js +39 -24
  108. package/templates/webterm/public/app/reviews-host.js +19 -0
  109. package/templates/webterm/public/app/specs-host.js +12 -218
  110. package/templates/webterm/public/app/state.js +5 -0
  111. package/templates/webterm/public/app/tabs.js +6 -2
  112. package/templates/webterm/public/app/theme.js +75 -27
  113. package/templates/webterm/public/app/tool-host.js +318 -0
  114. package/templates/webterm/public/app/workspace-color.js +192 -0
  115. package/templates/webterm/public/index.html +22 -4
  116. package/templates/webterm/public/styles.css +195 -21
  117. package/templates/webterm/server.js +198 -150
  118. package/templates/webterm/tool-document.js +3 -2
  119. package/templates/webterm/tools/commands.js +61 -0
  120. package/templates/webterm/tools/home.js +58 -0
  121. package/templates/webterm/tools/index.js +26 -0
  122. package/templates/webterm/tools/mount.js +337 -0
  123. package/templates/webterm/tools/paths.js +34 -0
  124. package/templates/webterm/tools/reviews.js +137 -0
  125. package/templates/webterm/tools/specs.js +154 -0
  126. package/templates/webterm/typed-line.js +70 -7
  127. package/templates/context/context-usage.md +0 -1
  128. package/templates/context-usage.sh +0 -266
  129. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  130. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  131. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  132. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  133. package/templates/specs.sh +0 -106
  134. package/templates/webterm/specs.js +0 -358
@@ -0,0 +1,26 @@
1
+ // =========================================================================================================================================
2
+ // tools/index.js - The tools this pane hosts.
3
+ //
4
+ // One list, and everything about a tool is derived from its row: what is mounted, what is routed, what is broadcast, what a button
5
+ // types and how much of the typing channel one session gets. server.js reads the list and never a tool's name, so a third tool is
6
+ // a row here and a table beside it.
7
+ //
8
+ // The order is the order the tabs are drawn in, which is the pane's own list in `public/app/pane-shell.js`, less Pages: the
9
+ // interface draws that one itself and it has no table here. The two lists are checked against each other by the interface's tests
10
+ // rather than derived from one another, because the pane is drawn in the browser and this file is read in the server.
11
+ // =========================================================================================================================================
12
+
13
+ import { HOME_TABLE } from "./home.js";
14
+ import { REVIEWS_TABLE } from "./reviews.js";
15
+ import { SPECS_TABLE } from "./specs.js";
16
+
17
+ export const TOOLS = [HOME_TABLE, SPECS_TABLE, REVIEWS_TABLE];
18
+
19
+ /** The table of one tool, or undefined. Asked with `find`, so a name that is not a tool's is not one. */
20
+ export function toolTable(id) {
21
+ return TOOLS.find((table) => table.id === id);
22
+ }
23
+
24
+ export { commandFor, commandSkill, commandsOf, TOOL_COMMAND_SKILLS, TOOL_COMMANDS } from "./commands.js";
25
+ export { mountTool } from "./mount.js";
26
+ export { CLIENT_PREFIX, toolPaths } from "./paths.js";
@@ -0,0 +1,337 @@
1
+ // =========================================================================================================================================
2
+ // tools/mount.js - A pane tool behind its credentials, whichever tool it is.
3
+ //
4
+ // A tool's own knowledge - what its root holds, what one piece of work holds, what it writes - is a module built elsewhere and
5
+ // bundled into one file (see ../../pane-apps/). This is the mount around it: the credentials that open it, the rules about which
6
+ // request may reach which part of it, and the shape of every answer. Nothing here is about one tool, and every tool is mounted
7
+ // through it. What each one is sits in a table of its own, beside this file.
8
+ //
9
+ // One function decides all of that. `handle()` takes a request as plain data and answers with a status, headers and a body, and it
10
+ // neither throws nor rejects: a module that fails answers with a status, so a bug in one tool costs its tab the view and nothing
11
+ // else. A module that answers a promise is awaited here rather than by the caller, so one tool computing its answers with git and
12
+ // another reading a directory are the same thing to everything above. The express side is one delegation in server.js, and holds
13
+ // no decision.
14
+ //
15
+ // Three kinds of request reach a tool, each with its own credential, and neither credential opens anything but its own kind:
16
+ // - The tool's client, a document and its assets, served open by server.js like every other static file.
17
+ // It holds nothing secret and drives nothing, and its responses are CORS-readable because the client runs with an opaque
18
+ // origin and fetches its own module scripts cross-origin.
19
+ // - The data routes, gated by a tool-scoped key the shell hands into the frame, presented as a header and never in a URL. These
20
+ // alone answer the cross-origin preflight that header needs.
21
+ // - Workspace-file loads, gated by a raw-route token that rides as a path segment, because a frame and an image cannot send a
22
+ // header.
23
+ // The master key opens none of this, and none of this opens the master key: a rendering bug inside a frame leaks that one tool's
24
+ // reading at worst, never the key that types into terminals.
25
+ // =========================================================================================================================================
26
+
27
+ import { timingSafeEqual } from "node:crypto";
28
+ import { toolPaths } from "./paths.js";
29
+
30
+ // Read by a frame, which has an opaque origin, so every answer it may read says so. It carries no credential of its own - the
31
+ // browser sends none without `Access-Control-Allow-Credentials`, which nothing here sends - so this opens a route to any origin
32
+ // that already holds the key the route asks for.
33
+ //
34
+ // The data routes alone. Workspace files are not readable cross-origin by anybody: a jailed document can read the token out of its
35
+ // own URL, and making the files it opens readable as well would turn one rendering bug into every file the tool serves, posted
36
+ // anywhere. A frame and an image need no permission to render.
37
+ const CORS_OPEN = { "Access-Control-Allow-Origin": "*" };
38
+
39
+ // The jail on workspace bytes, sent whatever the media type. Unconditional on purpose: a per-type list would put the one control
40
+ // between workspace content and this origin at the mercy of an enumeration, and the next scriptable type would walk past it. The
41
+ // sandbox token set is the one /pane/page already sends.
42
+ const JAIL = {
43
+ "Content-Security-Policy": "sandbox allow-scripts allow-forms",
44
+ "X-Content-Type-Options": "nosniff",
45
+ "Cache-Control": "no-store",
46
+ };
47
+
48
+ /** One query or header value as a string. A repeated parameter parses to an array, which is not a value. */
49
+ export function field(value) {
50
+ return typeof value === "string" ? value : "";
51
+ }
52
+
53
+ /** Whether a presented credential is the live one, compared in constant time. */
54
+ function holds(presented, secret) {
55
+ if (typeof presented !== "string" || presented === "") return false;
56
+ // Byte lengths, not string lengths: timingSafeEqual throws on a length mismatch, and a multi-byte character makes those two
57
+ // differ.
58
+ const shown = Buffer.from(presented);
59
+ const live = Buffer.from(secret);
60
+ if (shown.length !== live.length) return false;
61
+ return timingSafeEqual(shown, live);
62
+ }
63
+
64
+ /** A path as the URL spells it, one segment at a time. Throws for a segment that is not a path. */
65
+ function decodeSegments(rest) {
66
+ return rest.split("/").map((segment) => decodeURIComponent(segment));
67
+ }
68
+
69
+ // -----------------------------------------------------------------------------------------------------------------------------------------
70
+ // The route table, as a pattern
71
+ //
72
+ // A pattern is the part of the path after the api prefix, with `:name` for a segment that is the caller's own word:
73
+ // `review/:slug/hunks`. A flat name is a pattern of one literal segment, which is what every Specs route is.
74
+ // -----------------------------------------------------------------------------------------------------------------------------------------
75
+
76
+ /**
77
+ * The route a path names, with whatever its pattern captured, or null.
78
+ *
79
+ * A literal segment beats a captured one, so `review/:slug/hunks` is a page of hunks rather than an act named `hunks`, however the
80
+ * two patterns happen to be ordered in the table.
81
+ *
82
+ * The table is read through `Object.entries`, which is its own entries and never what an object inherits: a request for
83
+ * `constructor` or `toString` finds no route, rather than finding a function and treating it as one.
84
+ */
85
+ export function routeFor(routes, asked) {
86
+ let best = null;
87
+ for (const [pattern, route] of Object.entries(routes)) {
88
+ const parts = pattern.split("/");
89
+ if (parts.length !== asked.length) continue;
90
+ const params = {};
91
+ let literals = 0;
92
+ let fits = true;
93
+ for (let at = 0; at < parts.length; at += 1) {
94
+ const part = parts[at];
95
+ const said = asked[at];
96
+ if (part.startsWith(":")) {
97
+ // A captured segment is somebody's name for something. An empty one names nothing.
98
+ if (said === "") {
99
+ fits = false;
100
+ break;
101
+ }
102
+ params[part.slice(1)] = said;
103
+ continue;
104
+ }
105
+ if (part !== said) {
106
+ fits = false;
107
+ break;
108
+ }
109
+ literals += 1;
110
+ }
111
+ if (!fits) continue;
112
+ if (best === null || literals > best.literals) best = { route, params, literals };
113
+ }
114
+ return best;
115
+ }
116
+
117
+ // -----------------------------------------------------------------------------------------------------------------------------------------
118
+ // The mount
119
+ // -----------------------------------------------------------------------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Mount one tool behind its credentials.
123
+ *
124
+ * `table` is what this tool is - its id, its routes, its verbs and its raw files - and `module` is what it knows, made by the
125
+ * table's own maker and handed in the way paneStore is handed to the pane: this file decides who may ask, never what the answer is.
126
+ * `key` opens the data routes and `rawToken` opens the workspace files, and the two are separate so that neither one is the
127
+ * other's spare.
128
+ */
129
+ export function mountTool({ table, module, key, rawToken }) {
130
+ const paths = toolPaths(table.id);
131
+ const DATA = table.routes(module);
132
+ const VERBS = table.verbs(module);
133
+ // The status a thrown error is answered with, out of this tool's own bundle. Every module maps a code rather than a class, so
134
+ // the answer would be the same from either bundle - and asking the one that threw is what keeps that a property rather than a
135
+ // coincidence.
136
+ const statusFor = table.statusFor;
137
+
138
+ /** What a caller is told a failure was.
139
+ *
140
+ * The module's own words name the path it was asked for, which is the caller's own text. Anything else came from below it, and
141
+ * those messages carry the container path the tool resolved to - which no caller needs and which the log is the place for.
142
+ */
143
+ function reason(error) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ if (message.startsWith(`${table.id}:`)) return message;
146
+ console.warn(`[webterm] ${table.id}: ${message}`);
147
+ return "that could not be read";
148
+ }
149
+
150
+ /** The path a workspace file is loaded from. The token is a segment because a frame sends no headers. */
151
+ const rawBase = `${paths.raw}${rawToken}`;
152
+
153
+ function refused(what) {
154
+ return { status: 403, headers: CORS_OPEN, body: { error: what } };
155
+ }
156
+
157
+ /** The route a data path names, or null. The path is decoded segment by segment, like a raw one. */
158
+ function dataRoute(path) {
159
+ let asked;
160
+ try {
161
+ asked = decodeSegments(path.slice(paths.api.length));
162
+ } catch {
163
+ return null;
164
+ }
165
+ return routeFor(DATA, asked);
166
+ }
167
+
168
+ // The preflight the tool's key header needs, answered for the data routes and for nothing else. A route that is not in the
169
+ // table gets no `Access-Control-Allow-` header at all, which is what a browser reads as "not allowed" rather than as an answer
170
+ // it may act on.
171
+ function preflight(path) {
172
+ const found = dataRoute(path);
173
+ if (!found) return { status: 403, headers: {}, body: { error: `not a ${table.label} data route` } };
174
+ return {
175
+ status: 204,
176
+ headers: {
177
+ ...CORS_OPEN,
178
+ "Access-Control-Allow-Methods": Object.keys(found.route).join(", "),
179
+ "Access-Control-Allow-Headers": `${paths.keyHeader}, content-type`,
180
+ "Access-Control-Max-Age": "600",
181
+ },
182
+ body: null,
183
+ };
184
+ }
185
+
186
+ async function data(method, path, query, body, headers) {
187
+ const found = dataRoute(path);
188
+ if (!found) return { status: 404, headers: CORS_OPEN, body: { error: `no such ${table.label} route` } };
189
+ // The gate before the body: a caller without the key learns nothing about what a body would have done.
190
+ if (!holds(field(headers[paths.keyHeader]), key)) {
191
+ return refused(`the ${table.label} routes take their key in the ${paths.keyHeader} header`);
192
+ }
193
+ const handler = found.route[method];
194
+ if (!handler) {
195
+ const allowed = Object.keys(found.route).join(", ");
196
+ return { status: 405, headers: { ...CORS_OPEN, Allow: allowed }, body: { error: `this route takes ${allowed}` } };
197
+ }
198
+ let payload = null;
199
+ if (body !== null && body !== undefined && body !== "") {
200
+ try {
201
+ payload = typeof body === "string" ? JSON.parse(body) : body;
202
+ } catch {
203
+ return { status: 400, headers: CORS_OPEN, body: { error: "the body is not JSON" } };
204
+ }
205
+ }
206
+ // Awaited inside the try, so a module that answers a promise is answered by the same rules as one that answers a value: a
207
+ // rejection is this tool's own status and its own words, rather than a rejection nobody is holding and an empty body.
208
+ try {
209
+ return { status: 200, headers: CORS_OPEN, body: await handler({ params: found.params, query, body: payload }) };
210
+ } catch (error) {
211
+ return { status: statusFor(error), headers: CORS_OPEN, body: { error: reason(error) } };
212
+ }
213
+ }
214
+
215
+ async function raw(method, path, headers) {
216
+ // The jail rides on every answer this route gives, a refusal included: the rule is about the route, not about the happy
217
+ // path, and a body that says why is still workspace-shaped text.
218
+ const base = { ...JAIL };
219
+ if (method !== "GET" && method !== "HEAD") {
220
+ return { status: 405, headers: { ...base, Allow: "GET, HEAD" }, body: { error: "this route takes GET, HEAD" } };
221
+ }
222
+ // Frame and subresource only. The jail's second mechanism is the sandbox of whatever embeds the file, and a top-level
223
+ // document has no embedder - it would rest on the header alone, which is the one-mechanism state the content boundary
224
+ // forbids, and a top-level document can do more on an origin than a framed one can.
225
+ //
226
+ // A request that says nothing about where it is going is refused with it. Reading an absent header as "not a document"
227
+ // would hand that refusal to whoever leaves the header off, and the cost of the other way round is visible and small: in a
228
+ // browser that sends no Fetch Metadata, workspace files do not render.
229
+ const dest = field(headers["sec-fetch-dest"]);
230
+ if (dest === "" || dest === "document") {
231
+ return { status: 403, headers: base, body: { error: "a workspace file renders inside the tool, not as a page of its own" } };
232
+ }
233
+ const rest = path.slice(paths.raw.length);
234
+ const cut = rest.indexOf("/");
235
+ if (!holds(cut === -1 ? rest : rest.slice(0, cut), rawToken)) {
236
+ return { status: 403, headers: base, body: { error: "the workspace files take their token in the path" } };
237
+ }
238
+ let wanted = "";
239
+ try {
240
+ wanted = decodeSegments(cut === -1 ? "" : rest.slice(cut + 1)).join("/");
241
+ } catch {
242
+ return { status: 400, headers: base, body: { error: "that path cannot be read" } };
243
+ }
244
+ try {
245
+ const file = await table.raw(module, wanted);
246
+ return { status: 200, headers: { ...base, "Content-Type": file.mediaType }, body: file.bytes };
247
+ } catch (error) {
248
+ return { status: statusFor(error), headers: base, body: { error: reason(error) } };
249
+ }
250
+ }
251
+
252
+ return {
253
+ /** Which tool this is, so a caller holding several of them can say which one answered. */
254
+ id: table.id,
255
+ /** The root the module resolved, so the mount can say what it is serving. */
256
+ root: module.root,
257
+ /** The path prefix a workspace file is loaded from, token included. Handed to the frame, never logged. */
258
+ rawBase,
259
+
260
+ /**
261
+ * Where a file the tool wrote sits on disk, or null.
262
+ *
263
+ * What a typed line is allowed to name. Each module refuses any path that is not one of its own writes, so a delivery can
264
+ * point an agent at something that tool wrote and at nothing else - not at a file of the workspace, and not at anything
265
+ * outside the served root.
266
+ */
267
+ fileFor(path) {
268
+ try {
269
+ return module.toolPath(field(path));
270
+ } catch {
271
+ return null;
272
+ }
273
+ },
274
+
275
+ /**
276
+ * Answer one verb from this tool's helper.
277
+ *
278
+ * The other half of the coordination channel: the pane writes a file and types a line, and an agent moves the work through
279
+ * these. There is no key here on purpose - the caller is gated by being a process inside a session this server started,
280
+ * which server.js works out from the caller's pid, the way it does for `webpane`. A key on disk would add nothing, since
281
+ * everything in the container can read the one that is there.
282
+ */
283
+ async agent({ verb, entry, payload }) {
284
+ const name = field(verb);
285
+ if (!Object.hasOwn(VERBS, name)) {
286
+ return { status: 400, body: { error: `${table.id}: no verb named ${name || "(none)"}` } };
287
+ }
288
+ try {
289
+ return { status: 200, body: await VERBS[name](field(entry), payload ?? null) };
290
+ } catch (error) {
291
+ return { status: statusFor(error), body: { error: reason(error) } };
292
+ }
293
+ },
294
+
295
+ /**
296
+ * Answer one request under this tool's prefix.
297
+ *
298
+ * Takes the request as plain data - method, path, query, headers, body - and gives back `{ status, headers, body }`, where
299
+ * the body is a Buffer for a workspace file, an object for everything else, and null for an answer that carries none. It
300
+ * never throws, and it never rejects: a module that answers a promise is awaited here, so what a caller gets back is
301
+ * settled either way.
302
+ */
303
+ async handle({ method, path, query, headers, body }) {
304
+ const verb = String(method || "GET").toUpperCase();
305
+ const where = String(path || "");
306
+ // The path decides first and the method second, so the workspace-file route answers everything under it - a preflight
307
+ // included, which it refuses as a method it does not take, with the jail on the refusal like every other answer it
308
+ // gives.
309
+ if (where.startsWith(paths.raw)) return raw(verb, where, headers ?? {});
310
+ if (where.startsWith(paths.api)) {
311
+ if (verb === "OPTIONS") return preflight(where);
312
+ return data(verb, where, query ?? {}, body, headers ?? {});
313
+ }
314
+ return { status: 404, headers: {}, body: { error: `no such ${table.label} route` } };
315
+ },
316
+
317
+ /**
318
+ * Whether this request may carry a body at all.
319
+ *
320
+ * Asked before a body is read, so nothing without the key reaches a parser - not the JSON one, and not the buffering
321
+ * underneath it, which would otherwise hold whatever an anonymous caller sent up to the body limit. The rule is the same
322
+ * one the data routes are gated by; it is answered earlier.
323
+ */
324
+ takesBody(method, path, headers) {
325
+ const verb = String(method || "").toUpperCase();
326
+ const where = String(path || "");
327
+ if ((verb !== "POST" && verb !== "PUT") || !where.startsWith(paths.api)) return false;
328
+ if (!dataRoute(where)?.route[verb]) return false;
329
+ return holds(field(headers?.[paths.keyHeader]), key);
330
+ },
331
+
332
+ /** Stop watching the root. The mount holds nothing else that outlives it. */
333
+ close() {
334
+ module.close();
335
+ },
336
+ };
337
+ }
@@ -0,0 +1,34 @@
1
+ // =========================================================================================================================================
2
+ // tools/paths.js - Where a tool answers, derived from its name.
3
+ //
4
+ // Every path and every header a tool has follows from its id. Written out per tool they are six strings that have to agree with
5
+ // each other, with the client's own copy of them, and with the helper's - and a prefix that agrees with a header only most of the
6
+ // time is a tool whose frame is refused for a reason nobody can see. So they are derived, once, here.
7
+ //
8
+ // The client derives the same two names from the same id (`src/lib/api.ts` and `src/lib/shell.ts` in the pane-apps package), which
9
+ // is why a tool's id is the one thing about it that may never be spelled differently on the two sides.
10
+ // =========================================================================================================================================
11
+
12
+ /**
13
+ * The paths, prefixes and headers of one tool.
14
+ *
15
+ * `keys` and `agent` sit behind the master key, and are the shell's and the helper's doors. `api` and `raw` are the tool's own two
16
+ * kinds of request, each with its own credential. Both end in a slash, because both are matched as a prefix and a tool named
17
+ * `review` must never answer for one named `reviews`.
18
+ */
19
+ export function toolPaths(id) {
20
+ return {
21
+ prefix: `/${id}`,
22
+ keys: `/${id}/keys`,
23
+ agent: `/${id}/agent`,
24
+ api: `/${id}/api/`,
25
+ raw: `/${id}/raw/`,
26
+ /** The header the tool's key travels in. A header rather than a URL: a document can read the URL it was loaded from. */
27
+ keyHeader: `x-${id}-key`,
28
+ /** The header the helper says which process it is in. A claim, and gated as one - see the helper's door in server.js. */
29
+ pidHeader: `x-${id}-pid`,
30
+ };
31
+ }
32
+
33
+ /** Where the built client of one tool is served from. Open statics, one directory per tool. */
34
+ export const CLIENT_PREFIX = "/pane-apps";
@@ -0,0 +1,137 @@
1
+ // =========================================================================================================================================
2
+ // tools/reviews.js - What the Code Reviews tool is, as one table.
3
+ //
4
+ // The same shape as the Specs table beside it, and the differences are the tool's own. A review is a directory with a slug, so its
5
+ // routes name their subject in the path rather than in a query, and the acts of the screen are one route with the act as a segment.
6
+ // Its raw files are two named locations rather than any workspace file, so a browser reaches a stored blob and a signature and
7
+ // nothing else. And it is one of the two modules in this container that run a child process, Home being the other, which is why it
8
+ // is handed the workspace as well as its own root: every git call it makes runs with the repository as its working directory. The
9
+ // runner itself is shared, in the pane-apps server library, and this module hands it a closed table of read-only operations of its own.
10
+ //
11
+ // The framed sentences are the three acts that keep the review with the reviewer. Sending feedback, syncing and filing a review all
12
+ // hand it over, and each of those types the review's command instead: the act starts the agent, and the skill reads the review's
13
+ // state to know what it was woken for. Two lines for one act would be one line too many - the second is refused by the gap the
14
+ // limits set, and an agent acts on whichever arrived first.
15
+ // =========================================================================================================================================
16
+
17
+ import { createReviewsModule, statusFor } from "../../pane-apps/server/reviews.mjs";
18
+ import {
19
+ MAX_REVIEWS_BYTES,
20
+ MAX_REVIEWS_LINE_LENGTH,
21
+ MAX_REVIEWS_LINES_PER_SESSION,
22
+ REVIEWS_KEY,
23
+ REVIEWS_LINE_MIN_INTERVAL_MS,
24
+ REVIEWS_NEW_SESSION_DELAY_MS,
25
+ REVIEWS_RAW_TOKEN,
26
+ REVIEWS_ROOT,
27
+ WORKSPACE_ROOT,
28
+ } from "../config.js";
29
+ import { field } from "./mount.js";
30
+
31
+ // The module maker, re-exported so the path to the built dist is written once, as it is for every tool.
32
+ export { createReviewsModule };
33
+
34
+ /** A count out of a query, or zero. A page asks for a number and gets what the module will give it. */
35
+ function count(value) {
36
+ const asked = Number.parseInt(field(value), 10);
37
+ return Number.isFinite(asked) && asked > 0 ? asked : 0;
38
+ }
39
+
40
+ /**
41
+ * A selection request out of a query, or null where the query names no kind.
42
+ *
43
+ * The words a person typed, passed on as they are: the module reads what it takes and resolves every one of them through git, so
44
+ * nothing here has to know which of the five shapes carries which field.
45
+ */
46
+ function selectionFrom(query) {
47
+ const kind = field(query.kind);
48
+ if (kind === "") return null;
49
+ const request = { kind, uncommitted: field(query.uncommitted) === "true" };
50
+ for (const name of ["branch", "commit", "from", "to", "base"]) {
51
+ const value = field(query[name]);
52
+ if (value !== "") request[name] = value;
53
+ }
54
+ return request;
55
+ }
56
+
57
+ export const REVIEWS_TABLE = {
58
+ id: "reviews",
59
+ label: "Code Reviews",
60
+ key: REVIEWS_KEY,
61
+ rawToken: REVIEWS_RAW_TOKEN,
62
+ statusFor,
63
+
64
+ /** The reviews root, and the repository they are of. */
65
+ create: ({ onChange }) => createReviewsModule({ root: REVIEWS_ROOT, workspace: WORKSPACE_ROOT, onChange }),
66
+
67
+ /**
68
+ * What a delivery may say it is, in the server's own words.
69
+ *
70
+ * Each one is a review that stayed with the reviewer: the agent was stopped mid-act, the review followed a merge, or the
71
+ * reviewer wants to talk about one hunk in the terminal. Nothing here starts the agent on a review - that is the command in
72
+ * `commands.js`, and it is typed by the four acts that hand the review over.
73
+ */
74
+ events: {
75
+ stopped: "The reviewer stopped the agent",
76
+ followed: "This review followed its merge",
77
+ talk: "The reviewer wants to talk about a hunk",
78
+ },
79
+
80
+ /** As the Specs limits, and for the same reasons. A review carries no images, so its bodies are smaller. */
81
+ limits: {
82
+ bytes: MAX_REVIEWS_BYTES,
83
+ line: MAX_REVIEWS_LINE_LENGTH,
84
+ interval: REVIEWS_LINE_MIN_INTERVAL_MS,
85
+ perSession: MAX_REVIEWS_LINES_PER_SESSION,
86
+ newSession: REVIEWS_NEW_SESSION_DELAY_MS,
87
+ },
88
+
89
+ /**
90
+ * A stored blob or a signature, by the address the client loads it from.
91
+ *
92
+ * The rule about which of the two a path is belongs to the module, which reads it through its own reader: no path here reaches
93
+ * a file of the workspace, and none reaches anything under a review but those two.
94
+ */
95
+ raw: (module, path) => module.raw(path),
96
+
97
+ // The pane's routes. A review is named in the path because it is a directory with a name, and the acts of the screen are one
98
+ // route with the act as its last segment - which is why a literal segment beats a captured one in the matcher: `hunks` is a
99
+ // page of hunks and never an act.
100
+ routes: (module) => ({
101
+ list: { GET: () => module.list() },
102
+ // With no query it is the offered options, each with its counts. With one it is the single option that query describes,
103
+ // which is how a commit the reviewer typed and a base they changed get their counts from the same code.
104
+ options: { GET: ({ query }) => module.options(selectionFrom(query)) },
105
+ start: { POST: ({ body }) => module.start(body, "form") },
106
+ "review/:slug": { GET: ({ params }) => module.review(params.slug) },
107
+ "review/:slug/hunks": {
108
+ GET: ({ params, query }) => module.hunks(params.slug, count(query.from), count(query.count)),
109
+ },
110
+ // The unchanged code around a hunk, from the blob the baseline stored. It is text, so it comes through the data routes
111
+ // where the tool's key opens them, and not from the raw path - that path is for what a browser renders for itself.
112
+ "review/:slug/blob": {
113
+ GET: ({ params, query }) => module.blobLines(params.slug, field(query.name), count(query.from), count(query.count)),
114
+ },
115
+ // One route for every act of a review. The module holds the list of them and refuses a name that is not one, so what
116
+ // an act is stays in one place rather than being a row here as well.
117
+ "review/:slug/:act": { POST: ({ params, body }) => module.act(params.slug, params.act, body) },
118
+ }),
119
+
120
+ // The helper's verbs. The agent writes metadata and replies and says what it is doing; nothing here marks a hunk seen, closes a
121
+ // thread, approves, or changes the baseline of its own accord.
122
+ verbs: (module) => ({
123
+ // The first call any agent makes: where the reviews live, and what a review may hold.
124
+ stages: () => module.stages(),
125
+ state: (entry) => module.agentState(entry),
126
+ // A review filed from the terminal, which the tab shows with a note saying so.
127
+ start: (_entry, payload) => module.start(payload, "terminal"),
128
+ metadata: (entry, payload) => module.metadata(entry, payload),
129
+ // One group at a time, which several workers may post at once without treading on one another.
130
+ group: (entry, payload) => module.group(entry, payload),
131
+ ungroup: (entry, payload) => module.ungroup(entry, payload),
132
+ reply: (entry, payload) => module.reply(entry, payload),
133
+ activity: (entry, payload) => module.activity(entry, payload),
134
+ done: (entry) => module.done(entry),
135
+ log: (entry, payload) => module.log(entry, payload),
136
+ }),
137
+ };