makersclaw 0.1.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 (57) hide show
  1. package/README.md +68 -0
  2. package/bin/makersclaw.mjs +10 -0
  3. package/package.json +34 -0
  4. package/registry/blocks/board/README.md +130 -0
  5. package/registry/blocks/board/block.json +10 -0
  6. package/registry/blocks/board/board.jsx +435 -0
  7. package/registry/blocks/board/demo.jsx +50 -0
  8. package/registry/blocks/board/index.js +4 -0
  9. package/registry/blocks/job-panel/README.md +68 -0
  10. package/registry/blocks/job-panel/block.json +10 -0
  11. package/registry/blocks/job-panel/demo.jsx +75 -0
  12. package/registry/blocks/job-panel/index.js +3 -0
  13. package/registry/blocks/job-panel/job-panel.jsx +110 -0
  14. package/src/cli.mjs +301 -0
  15. package/src/fsutil.mjs +76 -0
  16. package/src/install.mjs +457 -0
  17. package/src/registry.mjs +191 -0
  18. package/templates/next/files/README.md +626 -0
  19. package/templates/next/files/app/auto-refresh.jsx +26 -0
  20. package/templates/next/files/app/error-relay.jsx +53 -0
  21. package/templates/next/files/app/error.jsx +44 -0
  22. package/templates/next/files/app/global-error.jsx +36 -0
  23. package/templates/next/files/app/globals.css +178 -0
  24. package/templates/next/files/app/layout.jsx +51 -0
  25. package/templates/next/files/app/not-found.jsx +23 -0
  26. package/templates/next/files/app/page.jsx +22 -0
  27. package/templates/next/files/app/s/[key]/page.jsx +47 -0
  28. package/templates/next/files/app/shell.jsx +64 -0
  29. package/templates/next/files/check/run.mjs +683 -0
  30. package/templates/next/files/components/parts/fixture.jsx +60 -0
  31. package/templates/next/files/components/parts/index.js +5 -0
  32. package/templates/next/files/components/parts/record-switcher.jsx +49 -0
  33. package/templates/next/files/components/ui/button.jsx +81 -0
  34. package/templates/next/files/components/ui/cx.js +4 -0
  35. package/templates/next/files/components/ui/depiction.jsx +66 -0
  36. package/templates/next/files/components/ui/field.jsx +126 -0
  37. package/templates/next/files/components/ui/icon.jsx +87 -0
  38. package/templates/next/files/components/ui/index.js +12 -0
  39. package/templates/next/files/components/ui/menu.jsx +123 -0
  40. package/templates/next/files/components/ui/notice.jsx +86 -0
  41. package/templates/next/files/components/ui/sheet.jsx +89 -0
  42. package/templates/next/files/components/ui/tabs.jsx +81 -0
  43. package/templates/next/files/lib/actions.js +187 -0
  44. package/templates/next/files/lib/claw.js +219 -0
  45. package/templates/next/files/lib/data.js +171 -0
  46. package/templates/next/files/lib/db.js +72 -0
  47. package/templates/next/files/lib/hooks.js +218 -0
  48. package/templates/next/files/lib/platform.js +59 -0
  49. package/templates/next/files/lib/sample.js +12 -0
  50. package/templates/next/files/middleware.js +42 -0
  51. package/templates/next/files/next.config.mjs +41 -0
  52. package/templates/next/files/package.json +19 -0
  53. package/templates/next/files/postcss.config.mjs +3 -0
  54. package/templates/next/files/schema.sql +22 -0
  55. package/templates/next/files/surfaces/README.md +32 -0
  56. package/templates/next/files/surfaces.js +53 -0
  57. package/templates/next/template.json +118 -0
@@ -0,0 +1,171 @@
1
+ import { headers } from "next/headers";
2
+ import { artifactId, ident, query } from "./db";
3
+ import { markSample } from "./sample";
4
+
5
+ // SERVER reads. Import from server components and server actions only —
6
+ // this file pulls in next/headers, so a "use client" file cannot import it
7
+ // (the build says so). Every read returns { ..., error } and never throws:
8
+ // a missing table or an unreachable database becomes error text the
9
+ // surface can show, and console.error puts it in the log tail the
10
+ // platform agent reads.
11
+
12
+ const ORDER = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(asc|desc)?\s*$/i;
13
+
14
+ /** "updated_at desc, id" → validated ORDER BY text. */
15
+ export function orderClause(orderBy, fallback) {
16
+ const parts = String(orderBy ?? fallback)
17
+ .split(",")
18
+ .map((p) => {
19
+ const m = ORDER.exec(p);
20
+ if (!m) throw new Error(`invalid orderBy: ${orderBy}`);
21
+ return `${ident(m[1])} ${m[2] ? m[2].toUpperCase() : "ASC"}`;
22
+ });
23
+ return parts.join(", ");
24
+ }
25
+
26
+ /** Dates and other non-JSON values become plain so rows can cross into
27
+ * client components. pg already returns bigint/numeric as strings. */
28
+ export function plain(row) {
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(row)) {
31
+ if (v instanceof Date) out[k] = Number.isNaN(v.getTime()) ? null : v.toISOString();
32
+ else if (Buffer.isBuffer(v)) out[k] = v.toString("base64");
33
+ else out[k] = v;
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // { column: value } → "WHERE ..." text; params is filled in place.
39
+ // null → IS NULL, an array → = ANY, anything else → equality.
40
+ function whereClause(filter, params) {
41
+ const where = [];
42
+ for (const [col, val] of Object.entries(filter ?? {})) {
43
+ if (val === null) {
44
+ where.push(`${ident(col)} IS NULL`);
45
+ } else if (Array.isArray(val)) {
46
+ params.push(val);
47
+ where.push(`${ident(col)} = ANY($${params.length})`);
48
+ } else {
49
+ params.push(val);
50
+ where.push(`${ident(col)} = $${params.length}`);
51
+ }
52
+ }
53
+ return where.length ? ` WHERE ${where.join(" AND ")}` : "";
54
+ }
55
+
56
+ function failed(what, error) {
57
+ const message = error?.message ?? String(error);
58
+ console.error(`[data] could not read ${what}: ${message}`);
59
+ return message;
60
+ }
61
+
62
+ /**
63
+ * Rows of a source table. `filter` is { column: value }; `orderBy` is
64
+ * "updated_at desc" (default: idColumn desc); `limit` caps at 5000.
65
+ * → { rows, error }
66
+ */
67
+ export async function loadRows({ source, idColumn = "id", orderBy, limit = 500, filter }) {
68
+ try {
69
+ const params = [];
70
+ const sql =
71
+ `SELECT * FROM ${ident(source)}` +
72
+ whereClause(filter, params) +
73
+ ` ORDER BY ${orderClause(orderBy, `${idColumn} desc`)}` +
74
+ ` LIMIT ${Math.max(1, Math.min(5000, Number(limit) || 500))}`;
75
+ const { rows } = await query(sql, params);
76
+ return { rows: rows.map(plain), error: null };
77
+ } catch (error) {
78
+ return { rows: [], error: failed(source, error) };
79
+ }
80
+ }
81
+
82
+ /** One row by id. `row` is null when there is none. → { row, error } */
83
+ export async function loadRow({ source, idColumn = "id", id }) {
84
+ try {
85
+ const { rows } = await query(`SELECT * FROM ${ident(source)} WHERE ${ident(idColumn)} = $1 LIMIT 1`, [id]);
86
+ return { row: rows[0] ? plain(rows[0]) : null, error: null };
87
+ } catch (error) {
88
+ return { row: null, error: failed(source, error) };
89
+ }
90
+ }
91
+
92
+ /** How many rows match `filter`. → { count, error } */
93
+ export async function countRows({ source, filter }) {
94
+ try {
95
+ const params = [];
96
+ const { rows } = await query(`SELECT count(*)::int AS n FROM ${ident(source)}${whereClause(filter, params)}`, params);
97
+ return { count: rows[0]?.n ?? 0, error: null };
98
+ } catch (error) {
99
+ return { count: 0, error: failed(source, error) };
100
+ }
101
+ }
102
+
103
+ /** Rows per distinct value of `column` (a state column, an owner…).
104
+ * NULL values count under the key "null". → { counts: { [value]: n }, error } */
105
+ export async function countBy({ source, column, filter }) {
106
+ try {
107
+ const params = [];
108
+ const { rows } = await query(
109
+ `SELECT ${ident(column)} AS v, count(*)::int AS n FROM ${ident(source)}${whereClause(filter, params)} GROUP BY 1 ORDER BY 2 DESC`,
110
+ params,
111
+ );
112
+ const counts = {};
113
+ for (const r of rows) counts[r.v === null ? "null" : String(r.v)] = r.n;
114
+ return { counts, error: null };
115
+ } catch (error) {
116
+ return { counts: {}, error: failed(`${source}.${column}`, error) };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * What happened, newest first: public.artifact_activity for this artifact
122
+ * (every lib/actions.js write and everything the agent recorded with the
123
+ * sql verb). `surface` narrows to one surface key; `since` (ISO or Date)
124
+ * to rows after that time. Rows: { id, at, action, surface, row_ref,
125
+ * detail, data, by }. → { rows, error }
126
+ */
127
+ export async function loadActivity({ limit = 100, surface, since } = {}) {
128
+ const id = artifactId();
129
+ if (!id) {
130
+ return { rows: [], error: failed("public.artifact_activity", "no CLAW_ARTIFACT_ID; product_run sets it") };
131
+ }
132
+ try {
133
+ const params = [id];
134
+ let where = `WHERE artifact_id = $1`;
135
+ if (surface) {
136
+ params.push(String(surface));
137
+ where += ` AND surface = $${params.length}`;
138
+ }
139
+ if (since) {
140
+ params.push(since instanceof Date ? since.toISOString() : String(since));
141
+ where += ` AND at > $${params.length}::timestamptz`;
142
+ }
143
+ params.push(Math.max(1, Math.min(1000, Number(limit) || 100)));
144
+ const { rows } = await query(
145
+ `SELECT id, at, action, surface, row_ref, detail, data, by
146
+ FROM public.artifact_activity ${where} ORDER BY at DESC, id DESC LIMIT $${params.length}`,
147
+ params,
148
+ );
149
+ return { rows: rows.map(plain), error: null };
150
+ } catch (error) {
151
+ return { rows: [], error: failed("public.artifact_activity", error) };
152
+ }
153
+ }
154
+
155
+ /** Sample rows get stable ids and a `_sample: true` marker (lib/sample.js,
156
+ * pure — the Fixture part uses it in the browser). Kept here so server
157
+ * code keeps one import. */
158
+ export { markSample };
159
+
160
+ /** The surface key of the current request (middleware.js sets it from
161
+ * /s/<key>), unless the caller names one — a string, or the page's
162
+ * `surface` object. */
163
+ export async function currentSurface(explicit) {
164
+ if (typeof explicit === "string" && explicit) return explicit;
165
+ if (explicit && typeof explicit === "object" && typeof explicit.key === "string") return explicit.key;
166
+ try {
167
+ return (await headers()).get("x-claw-surface") || null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
@@ -0,0 +1,72 @@
1
+ import { Pool } from "pg";
2
+
3
+ // One pool per process; DATABASE_URL comes from this directory's .env,
4
+ // written by app_database and sourced by product_run at start.
5
+ const pool =
6
+ globalThis._pgPool ??
7
+ new Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
8
+ globalThis._pgPool = pool;
9
+
10
+ export const query = (text, params) => pool.query(text, params);
11
+
12
+ /** The artifact this app belongs to. product_run sets it from the folder
13
+ * the app runs in; every activity row keys on it, and so does the
14
+ * Activity tab the OS draws on the app window. */
15
+ export function artifactId() {
16
+ return process.env.CLAW_ARTIFACT_ID || null;
17
+ }
18
+
19
+ const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
20
+
21
+ /** Quote a table or column name for SQL: "investors" → "investors",
22
+ * "public.investors" → "public"."investors". Anything else throws, so a
23
+ * typo in a surface config can never reach the database as SQL. */
24
+ export function ident(name) {
25
+ const parts = String(name ?? "").split(".");
26
+ if (!parts.length || parts.length > 2 || !parts.every((p) => IDENT.test(p))) {
27
+ throw new Error(`invalid identifier: ${name}`);
28
+ }
29
+ return parts.map((p) => `"${p}"`).join(".");
30
+ }
31
+
32
+ /**
33
+ * Record what happened, for the Activity tab the OS draws on the app
34
+ * window (and any surface that reads the table with loadActivity). Every
35
+ * lib/actions.js write calls this; the artifact's agent writes the same
36
+ * table with the sql verb.
37
+ * Never throws — an activity line must not fail the write it describes.
38
+ *
39
+ * action found, drafted, sent, replied, skipped, edited, moved, added…
40
+ * surface the surface key the row belongs to (the OS tab shows its
41
+ * label; a link into the app is /s/<surface>?row=<row_ref>)
42
+ * row_ref the id in that surface's source table
43
+ * detail one sentence
44
+ * data anything worth keeping (jsonb)
45
+ * by 'agent' | 'user:<email>' | 'run:<id>'
46
+ */
47
+ export async function logActivity({ action, surface, row_ref, detail, data, by }) {
48
+ const id = artifactId();
49
+ if (!id) {
50
+ console.warn(`[activity] not recorded (no CLAW_ARTIFACT_ID): ${action} ${detail ?? ""}`);
51
+ return false;
52
+ }
53
+ try {
54
+ await query(
55
+ `INSERT INTO public.artifact_activity (artifact_id, action, surface, row_ref, detail, data, by)
56
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)`,
57
+ [
58
+ id,
59
+ String(action || "edited"),
60
+ surface ?? null,
61
+ row_ref == null ? null : String(row_ref),
62
+ detail ?? null,
63
+ JSON.stringify(data ?? {}),
64
+ by || "user:unknown",
65
+ ],
66
+ );
67
+ return true;
68
+ } catch (error) {
69
+ console.error(`[activity] not recorded: ${String(error)}`);
70
+ return false;
71
+ }
72
+ }
@@ -0,0 +1,218 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { claw, publishStatus } from "./claw";
5
+ import { cancelJob, startJob } from "./platform";
6
+
7
+ // The React side of the _claw API, without any look: fetching, polling,
8
+ // streaming and cleanup. A surface renders what comes back and owns every
9
+ // pixel. "use client" only: the _claw API is browser-side.
10
+ //
11
+ // There is no chat, runs or activity hook here, and there will not be:
12
+ // those are STANDING surfaces and the OS draws them as tabs on the app
13
+ // window (docs/ARTIFACT-CONTRACTS.md §9). `useJob` is the other thing —
14
+ // work a founder commissions from inside the app, which is not a surface
15
+ // at all: it opens because they pressed something and it closes when the
16
+ // work lands (SOP §2.3, ARTIFACT-CONTRACTS §9f).
17
+ //
18
+ // While the gateway has no _claw routes yet (`unavailable`), polls back
19
+ // off to once a minute so a preview that is still being wired does not
20
+ // hammer 404s.
21
+
22
+ const BACKOFF_MS = 60000;
23
+
24
+ /**
25
+ * GET one _claw route. Fetches on mount, again every `every` ms while the
26
+ * tab is visible, and on reload(). A failed poll keeps the last data and
27
+ * sets `error`. Cleared on unmount.
28
+ * → { data, error, loading, unavailable, reload }
29
+ */
30
+ export function useClaw(path, { every, enabled = true } = {}) {
31
+ const [state, setState] = useState({ data: null, error: null, loading: Boolean(enabled), unavailable: false });
32
+ const [tick, setTick] = useState(0);
33
+ const lastRef = useRef(0);
34
+ const unavailableRef = useRef(false);
35
+ const reload = useCallback(() => setTick((t) => t + 1), []);
36
+
37
+ useEffect(() => {
38
+ if (!enabled) return undefined;
39
+ const controller = new AbortController();
40
+ let alive = true;
41
+ const load = async () => {
42
+ lastRef.current = Date.now();
43
+ const r = await claw(path, { signal: controller.signal });
44
+ if (!alive || r.aborted) return;
45
+ unavailableRef.current = Boolean(r.unavailable);
46
+ if (r.ok) setState({ data: r.data, error: null, loading: false, unavailable: false });
47
+ else setState((s) => ({ ...s, error: r.error, loading: false, unavailable: Boolean(r.unavailable) }));
48
+ };
49
+ load();
50
+ const id = every
51
+ ? setInterval(() => {
52
+ if (document.visibilityState !== "visible") return;
53
+ if (unavailableRef.current && Date.now() - lastRef.current < BACKOFF_MS) return;
54
+ load();
55
+ }, every)
56
+ : null;
57
+ return () => {
58
+ alive = false;
59
+ controller.abort();
60
+ if (id) clearInterval(id);
61
+ };
62
+ }, [path, every, enabled, tick]);
63
+
64
+ return { ...state, reload };
65
+ }
66
+
67
+ /**
68
+ * Who this app is (_claw/me). Also publishes the status onto <html>
69
+ * so reportError can tell a preview fault from a runtime one.
70
+ * → { artifact, user, blueprint, loading, error, unavailable, reload }
71
+ */
72
+ export function useArtifact() {
73
+ const { data, ...rest } = useClaw("me");
74
+ const artifact = data?.artifact ?? null;
75
+ useEffect(() => {
76
+ publishStatus(artifact?.status);
77
+ }, [artifact?.status]);
78
+ return { artifact, user: data?.user ?? null, blueprint: data?.blueprint ?? null, ...rest };
79
+ }
80
+
81
+ /** A settled tool call closes the LAST running step with that name, not
82
+ * the first: two calls to the same tool in one turn settle in order. */
83
+ function settleStep(steps, name, status) {
84
+ const next = [...steps];
85
+ for (let i = next.length - 1; i >= 0; i -= 1) {
86
+ if (next[i].name === name && next[i].status === "running") {
87
+ next[i] = { ...next[i], status };
88
+ return next;
89
+ }
90
+ }
91
+ return next;
92
+ }
93
+
94
+ const IDLE = {
95
+ running: false,
96
+ steps: [],
97
+ text: "",
98
+ error: null,
99
+ sessionId: null,
100
+ progress: 0,
101
+ finishedAt: null,
102
+ };
103
+
104
+ /**
105
+ * Commission work and follow it: the job half of SOP §2.3. One job at a
106
+ * time per hook — call it once per place the founder can start something.
107
+ *
108
+ * const job = useJob();
109
+ * <button onClick={() => job.start("Draft a launch checklist for …")}>
110
+ *
111
+ * State, for the panel the surface draws:
112
+ * running a job is in flight
113
+ * steps [{ name, label, status }] — `label` is the founder's
114
+ * phrase ("Searching the web"), `status` running | done |
115
+ * failed. Show labels; the raw name is there for debugging.
116
+ * text what the agent has written so far, growing. This is its
117
+ * account of the work, NOT the deliverable — the deliverable
118
+ * is rows on a surface.
119
+ * progress bumps every time a step settles. Feed it to a data hook's
120
+ * reload so rows appear as they land:
121
+ * useEffect(() => { reload(); }, [job.progress]);
122
+ * error a message, or null
123
+ * sessionId the thread. Pass it back to start() to refine what this
124
+ * job produced; keep it on the object's row and the founder
125
+ * can pick the conversation up where the thing lives.
126
+ * finishedAt ISO time the last job settled, or null
127
+ *
128
+ * Actions: start(message, { sessionId }) · cancel() · reset().
129
+ *
130
+ * The panel this drives must not exist when nothing is running — that is
131
+ * §2.3's test, and a standing one is the monitoring console §2.7 warns
132
+ * about. On delivery, close it and let the rows speak.
133
+ */
134
+ export function useJob() {
135
+ const [state, setState] = useState(IDLE);
136
+ const abortRef = useRef(null);
137
+ const sessionRef = useRef(null);
138
+ const aliveRef = useRef(true);
139
+
140
+ useEffect(() => {
141
+ aliveRef.current = true;
142
+ return () => {
143
+ aliveRef.current = false;
144
+ abortRef.current?.abort();
145
+ };
146
+ }, []);
147
+
148
+ const start = useCallback(async (message, { sessionId } = {}) => {
149
+ const text = String(message ?? "").trim();
150
+ if (!text) return { ok: false, error: "nothing to ask" };
151
+ abortRef.current?.abort();
152
+ const controller = new AbortController();
153
+ abortRef.current = controller;
154
+ const thread = sessionId ?? null;
155
+ sessionRef.current = thread;
156
+ setState({ ...IDLE, running: true, sessionId: thread });
157
+
158
+ const onEvent = ({ event, data }) => {
159
+ if (!aliveRef.current) return;
160
+ if (event === "session") {
161
+ sessionRef.current = data?.session_id ?? sessionRef.current;
162
+ setState((s) => ({ ...s, sessionId: sessionRef.current }));
163
+ } else if (event === "delta") {
164
+ setState((s) => ({ ...s, text: s.text + (data?.text ?? "") }));
165
+ } else if (event === "tool") {
166
+ const name = data?.name ?? "tool";
167
+ const label = data?.label ?? name;
168
+ setState((s) =>
169
+ data?.status === "running"
170
+ ? { ...s, steps: [...s.steps, { name, label, status: "running" }] }
171
+ : {
172
+ ...s,
173
+ steps: settleStep(s.steps, name, data?.status ?? "done"),
174
+ progress: s.progress + 1,
175
+ },
176
+ );
177
+ } else if (event === "done") {
178
+ setState((s) => ({
179
+ ...s,
180
+ running: false,
181
+ text: data?.text || s.text,
182
+ progress: s.progress + 1,
183
+ finishedAt: new Date().toISOString(),
184
+ }));
185
+ } else if (event === "error") {
186
+ setState((s) => ({ ...s, running: false, error: data?.message ?? "the agent stopped" }));
187
+ }
188
+ };
189
+
190
+ const result = await startJob(text, {
191
+ sessionId: thread ?? undefined,
192
+ onEvent,
193
+ signal: controller.signal,
194
+ });
195
+ if (!aliveRef.current) return result;
196
+ // A stream that ends without `done` (a dropped connection, a 4xx
197
+ // before the first frame) must still leave the panel closable.
198
+ if (!result.ok && !result.aborted)
199
+ setState((s) => ({ ...s, running: false, error: s.error ?? result.error }));
200
+ else setState((s) => (s.running ? { ...s, running: false, finishedAt: new Date().toISOString() } : s));
201
+ return result;
202
+ }, []);
203
+
204
+ const cancel = useCallback(async () => {
205
+ abortRef.current?.abort();
206
+ const id = sessionRef.current;
207
+ setState((s) => ({ ...s, running: false, finishedAt: new Date().toISOString() }));
208
+ if (id) await cancelJob(id);
209
+ }, []);
210
+
211
+ const reset = useCallback(() => {
212
+ abortRef.current?.abort();
213
+ sessionRef.current = null;
214
+ setState(IDLE);
215
+ }, []);
216
+
217
+ return { ...state, start, cancel, reset };
218
+ }
@@ -0,0 +1,59 @@
1
+ import { claw, clawStream, reportError } from "./claw";
2
+
3
+ // Typed wrappers over the _claw API (lib/claw.js), one per route an app
4
+ // has a reason to call. BROWSER only — the grant cookie is the identity —
5
+ // so call these from "use client" files; lib/hooks.js wraps them.
6
+ //
7
+ // Every call resolves { ok, status, data } or { ok: false, status, error,
8
+ // unavailable }; nothing throws. `unavailable: true` (404 or no network)
9
+ // means the gateway has no such route yet — render a quiet "not connected
10
+ // yet" state, not an error.
11
+ //
12
+ // Three things live here: who the app is (`me`), the error relay
13
+ // (`reportError`), and COMMISSIONING — `startJob` / `cancelJob`, the work
14
+ // a founder asks for from inside the app (SOP §2.3). Monitoring is still
15
+ // not an app's business: the OS draws Agent, Runs and Activity as tabs on
16
+ // the window and the title bar holds the run controls, so nothing here
17
+ // lists chats, runs or activity. `claw(path, opts)` and `clawStream` in
18
+ // lib/claw.js are the generic doors for the rest of §5.
19
+
20
+ /** GET _claw/me → { artifact: { id, name, vehicle, status, home },
21
+ * user: { id, email }, blueprint } */
22
+ export function me() {
23
+ return claw("me");
24
+ }
25
+
26
+ /**
27
+ * Commission work: POST _claw/chat as a stream, and call `onEvent` for
28
+ * every frame — `session` (once, first, carrying the thread's id),
29
+ * `delta` ({ text } as the answer is written), `tool` ({ name, label,
30
+ * status }: `label` is the founder-facing phrase, `status` running | done
31
+ * | failed), `done` ({ text }), `error` ({ message }).
32
+ *
33
+ * Pass `sessionId` to continue a thread the app already holds — refining
34
+ * the thing this job produced. Leave it out and the job gets a thread of
35
+ * its own, so two jobs never land in one conversation.
36
+ *
37
+ * The deliverable is NOT the text: the agent writes rows, and the surface
38
+ * shows them. Treat `delta` as what it is doing, not as the output.
39
+ */
40
+ export function startJob(message, { sessionId, onEvent, signal } = {}) {
41
+ return clawStream(
42
+ "chat",
43
+ sessionId ? { message, session_id: sessionId } : { message, job: true },
44
+ onEvent ?? (() => {}),
45
+ { signal },
46
+ );
47
+ }
48
+
49
+ /** POST _claw/cancel — stop a job's turn. The rows it already wrote stay;
50
+ * so does its run in the Runs tab. */
51
+ export function cancelJob(sessionId) {
52
+ return claw("cancel", { method: "POST", body: { session_id: sessionId } });
53
+ }
54
+
55
+ export { reportError };
56
+
57
+ export const platform = { me, startJob, cancelJob, reportError };
58
+
59
+ export default platform;
@@ -0,0 +1,12 @@
1
+ // Pure, importable from either side (lib/data.js re-exports it for server
2
+ // code; components/parts/fixture.jsx uses it in the browser).
3
+
4
+ /** Sample rows get stable ids and a `_sample: true` marker, so a surface
5
+ * can label them until real rows exist and refuse to edit them. */
6
+ export function markSample(sample, idColumn = "id") {
7
+ return (sample ?? []).map((r, i) => ({
8
+ ...r,
9
+ [idColumn]: r[idColumn] ?? `sample-${i + 1}`,
10
+ _sample: true,
11
+ }));
12
+ }
@@ -0,0 +1,42 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ // The only door is the Makersclaw dashboard proxy, which stamps this token
4
+ // on every request. The sandbox port itself is public, so without this
5
+ // check anyone with the URL could read the founder's data.
6
+ //
7
+ // It also tells the server which surface a request is for: /s/<key> becomes
8
+ // the x-claw-surface request header, so lib/data.js and lib/actions.js can
9
+ // stamp activity rows with the surface without every surface file having
10
+ // to pass its own key down.
11
+ //
12
+ // The theme: the OS window opens the app with ?theme=light|dark to match
13
+ // the desk, and the product proxy carries it as the x-claw-theme header
14
+ // on every later request (the browser's cookies never reach the app).
15
+ // ?theme= on any URL wins for that request and sets the claw_theme cookie
16
+ // too, so the app keeps its theme when run without the proxy (local
17
+ // `next dev`). app/layout.jsx reads header, then cookie, onto
18
+ // <html data-theme>.
19
+ const THEMES = new Set(["light", "dark"]);
20
+
21
+ export function middleware(request) {
22
+ const expected = process.env.CLAW_PRODUCT_TOKEN;
23
+ if (expected && request.headers.get("x-claw-product-token") !== expected) {
24
+ return new NextResponse("Open this app from your Makersclaw dashboard.", {
25
+ status: 401,
26
+ });
27
+ }
28
+ const headers = new Headers(request.headers);
29
+ headers.delete("x-claw-surface");
30
+ const m = /^\/s\/([^/?#]+)/.exec(request.nextUrl.pathname);
31
+ if (m) headers.set("x-claw-surface", decodeURIComponent(m[1]));
32
+ const theme = request.nextUrl.searchParams.get("theme");
33
+ const themed = Boolean(theme && THEMES.has(theme));
34
+ if (themed) headers.set("x-claw-theme", theme);
35
+ const response = NextResponse.next({ request: { headers } });
36
+ if (themed) {
37
+ response.cookies.set("claw_theme", theme, { path: "/", maxAge: 60 * 60 * 24 * 365, sameSite: "lax" });
38
+ }
39
+ return response;
40
+ }
41
+
42
+ export const config = { matcher: "/:path*" };
@@ -0,0 +1,41 @@
1
+ // The dashboard reaches this app at /p/<label>/ through the workspace proxy,
2
+ // so every link and asset must carry that prefix. product_run sets
3
+ // CLAW_BASE_PATH at build AND start time; leave it unset to run at /.
4
+ //
5
+ // `env` inlines the same value into the client bundle: lib/claw.js needs it
6
+ // to address the _claw API from the browser. The <html data-claw-base-path>
7
+ // attribute app/layout.jsx renders is the runtime copy and is read first, so
8
+ // a start under a different prefix than the build still resolves.
9
+ //
10
+ // allowedOrigins is NOT optional here, and it is not yours to edit. Next 15
11
+ // refuses a Server Action whose `Origin` header disagrees with
12
+ // `x-forwarded-host` — and in this stack they always disagree, because the
13
+ // sandbox ingress rewrites `x-forwarded-host` to its own vercel.run name
14
+ // while the browser is on the product host. Without this the app loads
15
+ // perfectly and its FIRST SAVE fails, which reads like a broken button
16
+ // rather than a proxy policy. product_run derives the value
17
+ // (src/lib/products.ts productAllowedOrigins): the product's own FQDN in
18
+ // production, the dashboard's dev origin under a path prefix. Hardcoding a
19
+ // localhost origin here ships an app that works in dev and refuses every
20
+ // write in production.
21
+ const allowedOrigins = (process.env.CLAW_ALLOWED_ORIGINS || "")
22
+ .split(",")
23
+ .map((o) => o.trim())
24
+ .filter(Boolean);
25
+
26
+ // `npm run check` builds into its own folder (CLAW_DIST_DIR=.next-check,
27
+ // check/run.mjs) so it never touches `.next`: in the sandbox the running
28
+ // dev server owns that folder as another user, and a second `next build`
29
+ // there dies with EACCES on .next/trace before it starts.
30
+ const nextConfig = {
31
+ basePath: process.env.CLAW_BASE_PATH || "",
32
+ distDir: process.env.CLAW_DIST_DIR || ".next",
33
+ env: {
34
+ CLAW_BASE_PATH: process.env.CLAW_BASE_PATH || "",
35
+ },
36
+ ...(allowedOrigins.length > 0
37
+ ? { experimental: { serverActions: { allowedOrigins } } }
38
+ : {}),
39
+ };
40
+
41
+ export default nextConfig;
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "artifact",
3
+ "private": true,
4
+ "scripts": {
5
+ "dev": "next dev",
6
+ "build": "next build",
7
+ "start": "next start",
8
+ "check": "node check/run.mjs",
9
+ "test": "node check/run.mjs"
10
+ },
11
+ "dependencies": {
12
+ "@tailwindcss/postcss": "^4",
13
+ "next": "^15.4.0",
14
+ "pg": "^8.16.0",
15
+ "react": "^19.1.0",
16
+ "react-dom": "^19.1.0",
17
+ "tailwindcss": "^4"
18
+ }
19
+ }
@@ -0,0 +1,3 @@
1
+ // Tailwind v4 runs as a PostCSS plugin; app/globals.css imports it. No
2
+ // tailwind.config.js: tokens live in globals.css under @theme.
3
+ export default { plugins: { "@tailwindcss/postcss": {} } };
@@ -0,0 +1,22 @@
1
+ -- Reference only. artifact_scaffold creates this table in the workspace
2
+ -- database when it copies the template; nothing in the app runs this file.
3
+ -- (docs/ARTIFACT-CONTRACTS.md §4)
4
+ --
5
+ -- Every write the app makes (lib/actions.js → lib/db.js logActivity) and
6
+ -- every action the artifact's agent takes (its brief tells it to insert
7
+ -- here with the sql verb) lands in this table, keyed by the artifact id
8
+ -- product_run passes as CLAW_ARTIFACT_ID. The Activity tab the OS draws on
9
+ -- the app window reads it (§9b), and so can any surface via loadActivity.
10
+
11
+ create table if not exists public.artifact_activity (
12
+ id bigserial primary key,
13
+ artifact_id uuid not null,
14
+ at timestamptz not null default now(),
15
+ action text not null, -- found, drafted, sent, replied, skipped, edited…
16
+ surface text, -- surface key
17
+ row_ref text, -- id in the surface's source table
18
+ detail text, -- one sentence
19
+ data jsonb not null default '{}'::jsonb,
20
+ by text not null default 'agent' -- 'agent' | 'user:<email>' | 'run:<id>'
21
+ );
22
+ create index if not exists artifact_activity_artifact_at on public.artifact_activity (artifact_id, at desc);