babavoss 0.0.1 → 0.12.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 (158) hide show
  1. package/NOTICE +7 -0
  2. package/bin/voss.ts +48 -0
  3. package/gui/babavoss-web.js +234 -0
  4. package/gui/chunk-5hpp1ypv.js +11710 -0
  5. package/gui/chunk-9rq662fd.js +8627 -0
  6. package/gui/chunk-k3cm8j6r.js +433 -0
  7. package/gui/chunk-pk09y98y.js +50 -0
  8. package/gui/chunk-smz02qa6.js +186 -0
  9. package/gui/chunk-wwqypxre.js +49 -0
  10. package/gui/gui.js +2015 -0
  11. package/gui/react-compiler-runtime.js +39 -0
  12. package/gui/react-dom-client.js +21 -0
  13. package/gui/react-dom.js +44 -0
  14. package/gui/react-jsx-runtime.js +19 -0
  15. package/gui/react.js +105 -0
  16. package/gui/theme.css +3053 -0
  17. package/index.ts +15 -0
  18. package/package.json +50 -4
  19. package/src/baba/check.ts +90 -0
  20. package/src/baba/config.ts +261 -0
  21. package/src/baba/find.ts +12 -0
  22. package/src/baba/init.ts +176 -0
  23. package/src/baba/node.ts +483 -0
  24. package/src/baba/project.ts +63 -0
  25. package/src/baba/registry.ts +35 -0
  26. package/src/baba/worker.ts +55 -0
  27. package/src/bench/index.ts +6 -0
  28. package/src/bench/measure.ts +132 -0
  29. package/src/bench/scenarios.ts +136 -0
  30. package/src/build/builder.ts +74 -0
  31. package/src/build/failure.ts +78 -0
  32. package/src/build/guard.ts +85 -0
  33. package/src/build/mdx-register.ts +3 -0
  34. package/src/build/mdx.ts +38 -0
  35. package/src/build/project.ts +38 -0
  36. package/src/build/views.ts +146 -0
  37. package/src/builder/main.ts +29 -0
  38. package/src/desktop/bob.ts +76 -0
  39. package/src/desktop/desktop.css +111 -0
  40. package/src/desktop/icons.ts +50 -0
  41. package/src/desktop/index.ts +323 -0
  42. package/src/desktop/routes.ts +98 -0
  43. package/src/desktop/view.tsx +673 -0
  44. package/src/door/core.ts +384 -0
  45. package/src/ecs/baba.ts +431 -0
  46. package/src/ecs/codec.ts +334 -0
  47. package/src/ecs/handles.ts +91 -0
  48. package/src/ecs/replica.ts +150 -0
  49. package/src/ecs/runtime.ts +603 -0
  50. package/src/ecs/scheduler.ts +75 -0
  51. package/src/ecs/snapshot.ts +102 -0
  52. package/src/ecs/state.ts +759 -0
  53. package/src/ecs/system.ts +256 -0
  54. package/src/ecs/table.ts +420 -0
  55. package/src/ecs/testbed.ts +97 -0
  56. package/src/exec/host.ts +177 -0
  57. package/src/exec/main.ts +98 -0
  58. package/src/exec/watch.ts +7 -0
  59. package/src/exec/wire.ts +29 -0
  60. package/src/generated/build.ts +4 -0
  61. package/src/gui/css.d.ts +1 -0
  62. package/src/gui/gui.tsx +245 -0
  63. package/src/gui/index.ts +51 -0
  64. package/src/gui/inspector.tsx +47 -0
  65. package/src/gui/levels.tsx +73 -0
  66. package/src/gui/promptware.tsx +68 -0
  67. package/src/gui/runner.tsx +118 -0
  68. package/src/gui/theme.css +498 -0
  69. package/src/gui/theme.ts +25 -0
  70. package/src/gui/wizard.tsx +227 -0
  71. package/src/guide/add-a-desktop.mdx +100 -0
  72. package/src/guide/compose-an-interface.mdx +84 -0
  73. package/src/guide/index.ts +13 -0
  74. package/src/guide/reach-outside.mdx +112 -0
  75. package/src/guide/spec-a-system.mdx +93 -0
  76. package/src/guide/systems-together.mdx +72 -0
  77. package/src/guide/write-a-system.mdx +183 -0
  78. package/src/guide/write-promptware.mdx +90 -0
  79. package/src/http/server.ts +310 -0
  80. package/src/kernel/build.ts +21 -0
  81. package/src/kernel/builder.ts +105 -0
  82. package/src/kernel/children.ts +117 -0
  83. package/src/kernel/context.ts +90 -0
  84. package/src/kernel/lock.ts +46 -0
  85. package/src/kernel/names.ts +14 -0
  86. package/src/kernel/schema.ts +130 -0
  87. package/src/kernel/where.ts +12 -0
  88. package/src/kit/index.ts +232 -0
  89. package/src/maker/system.ts +213 -0
  90. package/src/mcp/daemon.ts +61 -0
  91. package/src/mcp/main.ts +208 -0
  92. package/src/mcp/rpc.ts +64 -0
  93. package/src/mcp/tools.ts +125 -0
  94. package/src/prompt/evals.ts +42 -0
  95. package/src/prompt/index.ts +242 -0
  96. package/src/prompt/jsx-dev-runtime.ts +1 -0
  97. package/src/prompt/jsx-runtime.ts +49 -0
  98. package/src/prompt/mdx.d.ts +1 -0
  99. package/src/promptware/compile.ts +183 -0
  100. package/src/promptware/define.ts +16 -0
  101. package/src/promptware/disk.ts +72 -0
  102. package/src/promptware/markdown.d.ts +6 -0
  103. package/src/promptware/sync.ts +437 -0
  104. package/src/promptware/system.ts +215 -0
  105. package/src/runtime/bridge.ts +85 -0
  106. package/src/runtime/connect.ts +54 -0
  107. package/src/runtime/env.ts +35 -0
  108. package/src/runtime/harness.ts +80 -0
  109. package/src/runtime/main.ts +119 -0
  110. package/src/runtime/worker.ts +33 -0
  111. package/src/server/edge.ts +332 -0
  112. package/src/server/main.ts +45 -0
  113. package/src/server/messages.ts +97 -0
  114. package/src/server/protocol.ts +37 -0
  115. package/src/services/args.ts +45 -0
  116. package/src/services/exec.ts +69 -0
  117. package/src/services/fs.ts +139 -0
  118. package/src/services/http.ts +30 -0
  119. package/src/services/index.ts +113 -0
  120. package/src/services/secrets.ts +18 -0
  121. package/src/shell/address.ts +21 -0
  122. package/src/shell/args.ts +219 -0
  123. package/src/shell/client.ts +107 -0
  124. package/src/shell/codes.ts +26 -0
  125. package/src/shell/positional.ts +20 -0
  126. package/src/shell/run.ts +470 -0
  127. package/src/shell/service.ts +167 -0
  128. package/src/shell/state.ts +204 -0
  129. package/src/spec/adapters.ts +72 -0
  130. package/src/spec/diff.ts +26 -0
  131. package/src/spec/files.ts +17 -0
  132. package/src/spec/index.ts +155 -0
  133. package/src/spec/run.ts +97 -0
  134. package/src/spec/take.ts +54 -0
  135. package/src/test/index.ts +8 -0
  136. package/src/test/prove.ts +56 -0
  137. package/src/test/records.ts +23 -0
  138. package/src/test/specs.ts +56 -0
  139. package/src/test/steps.ts +100 -0
  140. package/src/test/voss-dir.ts +17 -0
  141. package/src/transport/messages.ts +110 -0
  142. package/src/transport/transport.ts +62 -0
  143. package/src/wall/probe.ts +67 -0
  144. package/src/wall/profile.ts +103 -0
  145. package/src/wall/spawn.ts +59 -0
  146. package/src/web/app.tsx +53 -0
  147. package/src/web/core.tsx +140 -0
  148. package/src/web/form.ts +155 -0
  149. package/src/web/hooks.ts +135 -0
  150. package/src/web/index.tsx +17 -0
  151. package/src/web/list.ts +19 -0
  152. package/src/web/maker.tsx +766 -0
  153. package/src/web/objects.tsx +213 -0
  154. package/src/web/socket.ts +84 -0
  155. package/src/web/state.tsx +69 -0
  156. package/src/web/store.ts +221 -0
  157. package/src/web/ui.tsx +135 -0
  158. package/README.md +0 -5
@@ -0,0 +1,227 @@
1
+ // The New baba app, at voss's root: a folder browser and, in the folder
2
+ // chosen, a baba made, or one already there remembered or opened. A list
3
+ // moved by the keyboard as much as by the pointer: arrows move, Enter goes
4
+ // into a folder, Backspace up, Cmd/Ctrl Enter makes. The daemon answers:
5
+ // /api/dirs for a directory's folders, /api/projects/add to remember a baba
6
+ // or make one.
7
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
8
+ import { apiHeaders } from "babavoss/web";
9
+
10
+ export interface Project { name: string; title: string; dir?: string; parent: string | null }
11
+ interface Folder { name: string; path: string; baba: boolean; known: boolean }
12
+ interface Browsed { path: string; home: string; parent: string | null; baba: boolean; known: boolean; dirs: Folder[] }
13
+
14
+ export const getJson = async <T,>(path: string): Promise<T> => {
15
+ const r = await fetch(path, { headers: apiHeaders() });
16
+ const j = await r.json() as T & { error?: { message: string } };
17
+ if (!r.ok || j.error) throw new Error(j.error?.message ?? r.statusText);
18
+ return j;
19
+ };
20
+ const postJson = async <T,>(path: string, body: unknown): Promise<T> => {
21
+ const r = await fetch(path, { method: "POST", headers: { "content-type": "application/json", ...apiHeaders() }, body: JSON.stringify(body) });
22
+ const j = await r.json() as T & { error?: { message: string } };
23
+ if (!r.ok || j.error) throw new Error(j.error?.message ?? r.statusText);
24
+ return j;
25
+ };
26
+
27
+ /** A path as a person reads it: under home, from ~. */
28
+ export const tilde = (path: string, home: string) => (home && (path === home || path.startsWith(home + "/")) ? "~" + path.slice(home.length) : path);
29
+ /** A path short enough for a row: its start and its last two folders, the middle elided. */
30
+ export const short = (path: string, home: string, max = 52) => {
31
+ const t = tilde(path, home);
32
+ if (t.length <= max) return t;
33
+ const parts = t.split("/");
34
+ const head = parts[0] === "" ? "" : parts[0]!;
35
+ for (let keep = 3; keep >= 1; keep--) { const s = `${head}/…/${parts.slice(-keep).join("/")}`; if (s.length <= max || keep === 1) return s; }
36
+ return t;
37
+ };
38
+ /** A directory's name as a baba's name: what `voss init` would choose. */
39
+ const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
40
+ const isTyping = (e: KeyboardEvent) => e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement;
41
+
42
+ // ---- glyphs: drawn here, 16 on a 16 grid, in the current colour ------------
43
+
44
+ const Svg = ({ children, size = 16 }: { children: ReactNode; size?: number }) => (
45
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{children}</svg>
46
+ );
47
+ const FolderGlyph = () => <Svg><path d="M2 4.5c0-.6.4-1 1-1h3l1.5 1.5H13c.6 0 1 .4 1 1V12c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1z" /></Svg>;
48
+ const BabaGlyph = () => <Svg><rect x="2.5" y="2.5" width="11" height="11" rx="3" /><circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" /></Svg>;
49
+ const Up = () => <Svg><path d="M8 13V3.5M4 7l4-4 4 4" /></Svg>;
50
+ const Chevron = () => <Svg size={14}><path d="M6 4l4 4-4 4" /></Svg>;
51
+ const Home = () => <Svg size={14}><path d="M2.5 7.5L8 3l5.5 4.5V13H10V9.5H6V13H2.5z" /></Svg>;
52
+ const Search = () => <Svg size={14}><circle cx="7" cy="7" r="4" /><path d="M10 10l3 3" /></Svg>;
53
+ const Spinner = () => <span className="pk-spin" aria-hidden="true" />;
54
+
55
+
56
+ /** Keeps the active row in view as the keys move it. */
57
+ function useInView(list: React.RefObject<HTMLElement | null>, active: number) {
58
+ useLayoutEffect(() => { list.current?.querySelector(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [list, active]);
59
+ }
60
+
61
+ // ---- the folder picker ---------------------------------------------------
62
+
63
+ /** The wizard: from `start`, or the daemon's own choice of folder; `onOpened` with the baba's name once it is made, remembered or found; `onBack` on Escape. */
64
+ export function Folders({ home, start, onBack, onOpened, onVerb }: { home: string; start?: string; onBack: () => void; onOpened: (name: string) => void; onVerb?: (verb: string) => void }) {
65
+ const [at, setAt] = useState<Browsed | null>(null);
66
+ const [loading, setLoading] = useState(true);
67
+ const [error, setError] = useState<string | null>(null);
68
+ const [busy, setBusy] = useState<string | null>(null);
69
+ const [filter, setFilter] = useState("");
70
+ const [active, setActive] = useState(0);
71
+ const [name, setName] = useState("");
72
+ const [title, setTitle] = useState("");
73
+ const list = useRef<HTMLOListElement>(null);
74
+ const filterRef = useRef<HTMLInputElement>(null);
75
+ const homeDir = at?.home || home;
76
+
77
+ const walk = (path: string) => {
78
+ setLoading(true);
79
+ getJson<Browsed>(`/api/dirs?path=${encodeURIComponent(path)}`).then((b) => {
80
+ // Coming up out of a folder, the folder left is the one the keys are on.
81
+ const from = at?.path;
82
+ const back = from ? b.dirs.findIndex((d) => d.path === from) : -1;
83
+ setAt(b); setError(null); setFilter(""); setName(""); setTitle("");
84
+ onVerb?.(b.known ? "open" : b.baba ? "remember" : "make");
85
+ // The filter keeps the keys: whatever is typed next narrows the folders here.
86
+ if (!(document.activeElement instanceof HTMLInputElement) || document.activeElement === filterRef.current) filterRef.current?.focus();
87
+ setActive(back >= 0 ? back + (b.parent ? 1 : 0) : 0);
88
+ }, (e) => setError(String(e instanceof Error ? e.message : e))).finally(() => setLoading(false));
89
+ };
90
+ useEffect(() => { walk(start ?? ""); filterRef.current?.focus(); }, []);
91
+
92
+ const words = filter.toLowerCase().split(/\s+/).filter(Boolean);
93
+ const dirs = (at?.dirs ?? []).filter((d) => words.every((w) => d.name.toLowerCase().includes(w)));
94
+ // The rows the keys move over: up first when there is somewhere to go up to and nothing is filtered.
95
+ const rows: ({ kind: "up"; path: string } | { kind: "dir"; d: Folder })[] = [
96
+ ...(at?.parent && !words.length ? [{ kind: "up" as const, path: at.parent }] : []),
97
+ ...dirs.map((d) => ({ kind: "dir" as const, d })),
98
+ ];
99
+ const cur = Math.min(active, Math.max(0, rows.length - 1));
100
+ useInView(list, cur);
101
+
102
+ const act = async () => {
103
+ if (!at || busy) return;
104
+ if (at.known) { const p = await getJson<{ projects: Project[] }>("/api/projects"); const own = p.projects.find((x) => x.dir === at.path); if (own) onOpened(own.name); return; }
105
+ const init = !at.baba;
106
+ setBusy(init ? "Making the baba and installing its packages…" : "Opening the baba…");
107
+ setError(null);
108
+ try {
109
+ const made = await postJson<{ name: string }>("/api/projects/add", { dir: at.path, init, ...(name.trim() ? { name: name.trim() } : {}), ...(title.trim() ? { title: title.trim() } : {}) });
110
+ onOpened(made.name);
111
+ } catch (e) { setError(String(e instanceof Error ? e.message : e)); setBusy(null); }
112
+ };
113
+ const enter = (i: number) => { const r = rows[i]; if (r) walk(r.kind === "up" ? r.path : r.d.path); };
114
+
115
+ useEffect(() => {
116
+ const key = (e: KeyboardEvent) => {
117
+ // The shell's launcher has the keys while it is open over the wizard.
118
+ if (busy || document.querySelector(".desktop-launcher")) return;
119
+ const inField = isTyping(e) && e.target !== filterRef.current;
120
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); void act(); return; }
121
+ if (e.key === "Escape") { e.preventDefault(); if (inField) (e.target as HTMLElement).blur(); else if (filter) setFilter(""); else onBack(); return; }
122
+ if (inField) return;
123
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive(Math.min(rows.length - 1, cur + 1)); }
124
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive(Math.max(0, cur - 1)); }
125
+ else if (e.key === "Enter") { e.preventDefault(); enter(cur); }
126
+ else if (e.key === "ArrowRight" && rows[cur]?.kind === "dir") { e.preventDefault(); enter(cur); }
127
+ else if ((e.key === "Backspace" && !filter) || e.key === "ArrowLeft" && !filter) { if (at?.parent) { e.preventDefault(); walk(at.parent); } }
128
+ else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && document.activeElement !== filterRef.current) filterRef.current?.focus();
129
+ };
130
+ addEventListener("keydown", key); return () => removeEventListener("keydown", key);
131
+ });
132
+
133
+ // The path as crumbs: home is ~, each part walks there.
134
+ const crumbs = useMemo(() => {
135
+ if (!at) return [];
136
+ const t = tilde(at.path, homeDir);
137
+ const parts = t.split("/").filter(Boolean);
138
+ const root = t.startsWith("~") ? homeDir : "";
139
+ return parts.map((part, i) => ({ label: part, path: part === "~" && i === 0 ? homeDir : (t.startsWith("~") ? root + "/" + parts.slice(1, i + 1).join("/") : "/" + parts.slice(0, i + 1).join("/")) }));
140
+ }, [at, homeDir]);
141
+ // Deep paths keep their first crumb and the last three; the ones between fold into one that goes to the nearest of them.
142
+ const shown = crumbs.length > 5 ? [crumbs[0]!, { label: "…", path: crumbs[crumbs.length - 4]!.path }, ...crumbs.slice(-3)] : crumbs;
143
+ const here = at ? short(at.path, homeDir, 44) : "";
144
+ const leaf = at ? at.path.split("/").filter(Boolean).pop() ?? "" : "";
145
+
146
+ return (
147
+ <div className="pk-pane pk-fade">
148
+ <header className="pk-strip">
149
+ <span className="pk-strip-title">New baba</span>
150
+ <span className="pk-gap" />
151
+ {loading && <Spinner />}
152
+ </header>
153
+ <div className="pk-path">
154
+ <nav className="pk-crumbs" aria-label="Folder">
155
+ {!crumbs.length && <span className="pk-crumb is-here">/</span>}
156
+ {shown.map((c, i) => (
157
+ <span key={c.path} className="pk-crumb-wrap">
158
+ {i > 0 && <span className="pk-crumb-sep">/</span>}
159
+ {c.label === "…" ? <button type="button" className="pk-crumb" onClick={() => walk(c.path)} title={tilde(c.path, homeDir)}>…</button>
160
+ : i === shown.length - 1 ? <span className="pk-crumb is-here">{c.label === "~" ? <Home /> : c.label}</span>
161
+ : <button type="button" className="pk-crumb" onClick={() => walk(c.path)} title={tilde(c.path, homeDir)}>{c.label === "~" ? <Home /> : c.label}</button>}
162
+ </span>
163
+ ))}
164
+ </nav>
165
+ <label className="pk-filter">
166
+ <Search />
167
+ <input ref={filterRef} value={filter} placeholder="Filter" aria-label="Filter folders" spellCheck={false} onChange={(e) => { setFilter(e.target.value); setActive(0); }} />
168
+ </label>
169
+ </div>
170
+ <ol ref={list} className={loading && at ? "pk-list pk-folders is-loading" : "pk-list pk-folders"} role="listbox" aria-label="Folders">
171
+ {!at && !error && [0, 1, 2, 3, 4].map((i) => <li key={i} className="pk-folder is-ghost"><span className="pk-folder-icon"><FolderGlyph /></span><span className="pk-ghost-line" style={{ width: `${30 + ((i * 17) % 35)}%` }} /></li>)}
172
+ {rows.map((r, i) => r.kind === "up" ? (
173
+ <li key=".." data-i={i} role="option" aria-selected={i === cur}>
174
+ <button type="button" className={i === cur ? "pk-folder is-active is-up" : "pk-folder is-up"} onClick={() => enter(i)} onMouseEnter={() => setActive(i)}>
175
+ <span className="pk-folder-icon"><Up /></span><span className="pk-folder-name">{tilde(r.path, homeDir) === "~" ? "Home" : r.path.split("/").filter(Boolean).pop() ?? "/"}</span><span className="pk-folder-note">up</span>
176
+ </button>
177
+ </li>
178
+ ) : (
179
+ <li key={r.d.path} data-i={i} role="option" aria-selected={i === cur}>
180
+ <button type="button" className={`pk-folder${i === cur ? " is-active" : ""}${r.d.baba ? " is-baba" : ""}`} onClick={() => enter(i)} onMouseEnter={() => setActive(i)}>
181
+ <span className="pk-folder-icon">{r.d.baba ? <BabaGlyph /> : <FolderGlyph />}</span>
182
+ <span className="pk-folder-name"><Marked text={r.d.name} words={words} /></span>
183
+ {r.d.known ? <span className="pk-pill is-known">remembered</span> : r.d.baba ? <span className="pk-pill">baba</span> : null}
184
+ <span className="pk-go"><Chevron /></span>
185
+ </button>
186
+ </li>
187
+ ))}
188
+ {at && rows.length === 0 && <li className="pk-none">{words.length ? <>No folder matches “{filter}”.</> : <>No folders in here. You can make the baba right here.</>}</li>}
189
+ </ol>
190
+ {error && <p className="pk-alert" role="alert">{error}</p>}
191
+ <footer className={`pk-dock${at?.known ? " is-known" : at?.baba ? " is-baba" : ""}`}>
192
+ <div className="pk-dock-line is-say">
193
+ <span className="pk-dock-icon">{at?.baba ? <BabaGlyph /> : <FolderGlyph />}</span>
194
+ <span className="pk-dock-text">
195
+ {!at ? "…" : at.known ? <>Voss already knows the baba in <span className="pk-here" title={at?.path}>{here}</span>.</>
196
+ : at.baba ? <>There is a baba in <span className="pk-here" title={at?.path}>{here}</span>. Remember it to open it here.</>
197
+ : <>A new baba goes in <span className="pk-here" title={at?.path}>{here}</span>, as <code>.baba/</code> beside what is there.</>}
198
+ </span>
199
+ </div>
200
+ <div className="pk-dock-line">
201
+ {at && !at.baba && <>
202
+ <label className="pk-field"><span>Name</span><input value={name} placeholder={slug(leaf) || "baba"} spellCheck={false} disabled={!!busy} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void act(); } }} /></label>
203
+ <label className="pk-field is-wide"><span>Title</span><input value={title} placeholder={name.trim() || slug(leaf) || "baba"} disabled={!!busy} onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void act(); } }} /></label>
204
+ </>}
205
+ {busy && <span className="pk-busy"><Spinner />{busy}</span>}
206
+ <span className="pk-gap" />
207
+ <button type="button" className="pk-primary" disabled={!at || !!busy || loading} onClick={() => void act()}>
208
+ {!at ? "Make a baba here" : at.known ? "Open it" : at.baba ? "Remember this baba" : "Make a baba here"}
209
+ <kbd className="pk-kbd is-on-primary">⌘↵</kbd>
210
+ </button>
211
+ </div>
212
+ </footer>
213
+ </div>
214
+ );
215
+ }
216
+
217
+ /** A name with the filter's words marked in it. */
218
+ function Marked({ text, words }: { text: string; words: string[] }) {
219
+ if (!words.length) return <>{text}</>;
220
+ const lower = text.toLowerCase();
221
+ const hit = new Array<boolean>(text.length).fill(false);
222
+ for (const w of words) { let i = lower.indexOf(w); while (i >= 0) { for (let k = i; k < i + w.length; k++) hit[k] = true; i = lower.indexOf(w, i + w.length); } }
223
+ const out: ReactNode[] = [];
224
+ let i = 0;
225
+ while (i < text.length) { const on = hit[i]; let j = i; while (j < text.length && hit[j] === on) j++; out.push(on ? <mark key={i}>{text.slice(i, j)}</mark> : text.slice(i, j)); i = j; }
226
+ return <>{out}</>;
227
+ }
@@ -0,0 +1,100 @@
1
+ export const prompt = { kind: "skill", name: "add-a-desktop", description: "Give a babavoss baba a desktop: windows of apps under a bar of pills, a launcher that lists what is open and what can be, Lucide icons by name, sessions, paths, memory per window, and the desktopAsk another system uses to open one. Read before adding an app to the desktop, composing Desktop in interface.tsx, or navigating the page from a system." };
2
+
3
+ # Add a desktop
4
+
5
+ Every baba runs voss's shell: the desktop system, which voss adds to every baba as it adds `maker` and `promptware`, over the apps the baba declares in `baba({ apps })`. The interface gives each app its component; the page is voss's. Three nouns and one verb: a **app** is a kind the baba registers; a **window** is one instance of it in a session, at a path; the **session** shows one window, or nothing. The verb is `desktop-open`: it brings the window at that path to the front, else the app's current window, else makes one; asked for a `fresh` window, an app declared `many` gets another, any other app gives the one it has. The launcher, a link in an app, the page's address, the CLI and another system's ask all go through it.
6
+
7
+ The page is a bar over one app. At the bar's far left the mark, the two eyes, opens the launcher; then a pill per open window, its icon at the left and the one in front filled; pointing at a pill turns its icon into the × that closes it, and a middle click closes it too, as it closes a browser tab; closing the one in front brings the pill to its left forward, else the one to its right. The launcher is the page's, not the state's: it shows while its search has focus, and while nothing is in front, since there is nothing else to show. The mark or Cmd/Ctrl K gives the search focus; a click on the launcher's empty space keeps it; choosing a row, Escape or the mark again takes it, and the launcher goes. It covers the page and changes nothing beneath: the bar's row becomes the search, and below it one column of rows, a palette: the icon, beginning where the search begins, then the label; what is open first, then every app under its group. One dot under the mark says which row is chosen, by the keys or the pointer, and glides between them. Typing ranks both by the words, a word at the start of a name first, and drops the groups. An app's row opens a fresh window of it, or brings the one it has to the front; a window's row brings it to the front, and pointing at it turns its icon into its ×. A browser tab shows one window at a time: a pill on the bar brings an open one to the front, the mark then a row opens another. Below the apps, under Sessions, the tab's other sessions and New session. A page inside a baba is that baba only; the last row, Leave, and the `voss` link before the title on the bar go back to the picker, voss's root page, which has no state: it lists the babas voss knows, one inside another under its parent, and New baba, the wizard that makes a baba in a folder or remembers one already there.
8
+
9
+ ```ts
10
+ // .baba/index.ts: the apps, as data, in launcher order
11
+ export default baba({
12
+ systems: [tasks, …],
13
+ apps: [
14
+ { key: "tasks", title: "Tasks", icon: "list-checks", group: "Work", many: true, systems: ["tasks"], routes: ["/", "/task/:id"] },
15
+ ],
16
+ });
17
+ ```
18
+
19
+ ```tsx
20
+ // .baba/apps/tasks.tsx: an app is a component, reading the state through the hooks
21
+ import { route, type AppProps } from "babavoss/web";
22
+ export function Tasks({ params, navigate, open, setTitle, memory, remember }: AppProps) {
23
+ /* the board, or the task params.id; navigate(route("/task/:id", { id })) opens one */
24
+ }
25
+
26
+ // .baba/interface.tsx: a component for every app, checked against the keys
27
+ import { compose } from "babavoss/web";
28
+ import type baba from "./index.ts";
29
+ import { Tasks } from "./apps/tasks.tsx";
30
+ export default compose<typeof baba>({ apps: { tasks: Tasks } });
31
+ ```
32
+
33
+ An app missing from `compose`, or a component for an app the baba does not declare, is a type error. After the baba's apps come voss's own, under Voss: `maker` the specs run live, `state` the entities and resources, `contract` the actions and queries as forms, `promptware` the know-how, `settings` the theme. A baba never declares them; its interface may give one a component of its own to draw it otherwise, `compose<typeof baba>({ apps: { …, promptware: Mine } })`. A baba that declares no apps has one, `view`, which draws its interface whole. A baba whose interface fails to build or load still has its shell: its apps say why, voss's work. The old addresses, `/NAME/run`, `/inspect`, `/proofs`, open the app they became, unless the baba has an app by that name. The old form, `compose({ shell })`, still loads for one release, in the `view` app; a baba cannot declare apps and keep a desktop system of its own.
34
+
35
+ An app's component receives `AppProps`: `{ session, window, path, route, params, active, memory, navigate, open, setTitle, remember }`. It never names an action: `navigate(path)` moves its own window, `open(app, path)` opens any app by the one rule, `setTitle(title)` names its window after what it shows, the pill's tooltip, `remember(key, value)` keeps a filter, a selection or a fold on its window, in the state, and `memory` reads it back. Windows stay mounted when hidden. A path begins with one `/`.
36
+
37
+ ## Addresses and routes
38
+
39
+ The page's address is the window in front: `/NAME/APP/PATH`, the baba's name, the app's key, the window's path. `/NAME/APP` is the app at `/`, its home; `/NAME/` alone is the launcher. Arriving at an app's address opens it by the one rule, so a link, a bookmark, Back and Forward all land where they name, in the tab's session; arriving at `/NAME/` opens the launcher over whatever is in front, and the address stays until something is chosen. The address names what is shown, never the session: `?session=KEY` joins one, and the tab keeps it. The launcher's rows resume an app as it was left; an address is exact. An old `#desktop/SESSION/APP/PATH` link still opens, rewritten to the path.
40
+
41
+ An app's `routes` are the paths its windows may be at: segments after one `/`, each a word, a `:name` for one segment, or last a `*` for the rest. One must take `/`. `desktop-open`, an ask and an address with a path no route takes answer an error, and the window stays; an app that declares no routes takes any path. The route that took the path, `/task/:id`, and its parameters, decoded, `{ id: "7" }`, come in `route` and `params`; the most specific route wins, a word before a name, a name before the rest, and a route that ends before the rest. A path is kept in one spelling, each segment encoded as `route` encodes it, so `/task/a b` and `/task/a%20b` are one window; `.` and `..` segments are refused. A window whose path the app's routes no longer take, after a change to them, goes home. `route("/task/:id", { id })`, from `babavoss` or `babavoss/web`, builds a path with each parameter encoded, so a system's ask and an app's link spell it the same way.
42
+
43
+ A routed app is one component reading `params`, or a component per route, a system of views, checked against the routes it declares, every one and none other where the table is written inline; the route the shell matched picks the component:
44
+
45
+ ```tsx
46
+ export default compose<typeof baba>({ apps: { tasks: { "/": Board, "/task/:id": Task } } });
47
+ ```
48
+
49
+ Kinds of app, by how they use the path: a **view** declares no routes and ignores its path; a **routed** app declares its routes, one view per route or one component over them all; a **document** app is a routed app declared `many`, each window its own instance at its own path.
50
+
51
+ An app's `systems` names the systems it shows, so the Maker offers it as a view of every spec that holds them (see `compose-an-interface`). An app's `icon` is a Lucide icon's name, as lucide.dev writes it: `list-checks`, `git-merge`. The desktop system reads its drawing once from the baba's own Lucide package, `lucide-static` if installed, else `lucide-react`, which the kit installs, and keeps it on the app in the state, so the bar and the launcher draw it; nothing is imported for it. A name the baba has no icon for, or no name, draws a small dot.
52
+
53
+ ## What the system owns
54
+
55
+ - `desktopApp`, the catalog as declared, synced every round: an app no longer declared closes its windows everywhere. `desktopIcon` beside it: its icon's shapes, as read.
56
+ - `desktopWindow`, one per instance, in the order opened: its session, its app, its path, its title, its `memory`.
57
+ - `desktopSession`, one per session: the selected window and when it was last touched. A browser tab owns one: on load it takes the address's `?session=KEY`, else the one the tab had (its `sessionStorage`), else enters a new one, which the system names after a tree, `oak`, `ash`, `elm`. A link with `?session=KEY` joins a session, and two tabs on one key move together; the launcher lists the other sessions, to go to one or end it, and New session. A session with nothing open goes after a day untouched. The launcher and what is typed into it are the page's.
58
+ - A call that names no session reaches the current session, the one last touched: a tab touches its session on load and on focus, so `voss baba desktop-open '{"app":"tasks","path":"/task/7"}'` lands in the tab last used. Name one to reach another.
59
+ - `desktopSettings`, one resource: the theme, the same on every page.
60
+ - Its contract, the same at the CLI, by MCP and in the page: `desktop-open`, `desktop-select`, `desktop-close`, `desktop-title`, `desktop-remember`, `desktop-enter`, `desktop-leave`, `desktop-settings` and the query `desktop-state`, which also lists every session. Read them with `voss baba`.
61
+
62
+ ## Opening an app from a system
63
+
64
+ Never import the desktop's interface. Name the desktop in your `reads`, add its ask, `shell.desktopAsk`, to your own entity; the desktop answers beside it next round.
65
+
66
+ ```ts
67
+ // a system that wants the page to show the task it just made
68
+ import { system, s, route } from "babavoss";
69
+ import { shell } from "babavoss/desktop"; // voss's desktop system
70
+
71
+ export default system({
72
+ name: "tasks",
73
+ model: { task: { id: s.key(s.string()) } },
74
+ reads: [shell],
75
+ }, (tasks, p) => [
76
+ p.action("task-make", {
77
+ summary: "make a task and show it",
78
+ args: { id: s.string() },
79
+ result: s.object({ entity: s.entity("task") }),
80
+ run: (w, { id }) => {
81
+ const e = w.spawn(tasks.task, { id });
82
+ w.add(e, shell.desktopAsk, { app: "tasks", path: route("/task/:id", { id }) }); // an ask, on your own entity
83
+ return { entity: e };
84
+ },
85
+ }),
86
+ ]);
87
+ // next round, on e: desktopAnswer = { session: <entity> | null, error: string | null }
88
+ ```
89
+
90
+ The ask takes `{ app, path?, fresh?, session? }`; an unknown app, a bad path or one no route takes answers an error, not a throw. See `systems-together` for how asks work.
91
+
92
+ ## Rules
93
+
94
+ - The address records the window in front, `/NAME/APP/PATH`, with Back and Forward; opening the launcher from the page leaves no entry; pass `address={false}` for a desktop embedded in another page.
95
+ - A baba's name is the first segment of its address, so `api`, `assets`, `view` and `ws`, the page's own, are not names a baba can take.
96
+ - Cmd/Ctrl K opens the launcher; Cmd/Ctrl W or the front pill's × closes the window in front; Escape clears the search, then leaves the launcher; arrows move down the rows, Enter opens. The theme is `desktop-settings`, with no control in the launcher yet.
97
+ - The shell inherits voss's theme tokens; the session's theme applies inside the desktop.
98
+ - Reload keeps compatible desktop state and drops apps no longer declared; stopping the baba ends it with the rest of the state.
99
+ - Prove an app's navigation like any system: a scenario that fires `desktop-open` and checks `desktop-state`.
100
+ - The old spellings, `surfaces` and `SurfaceProps`, still work for one release; `dock` in the options is accepted and ignored.
@@ -0,0 +1,84 @@
1
+ export const prompt = { kind: "skill", name: "compose-an-interface", description: "Compose a babavoss baba's interface: interface.tsx, an app for each the baba declares, written as a component in .baba/apps/ that reads the state through hooks and acts with the hand, drawn the same over the live state and a simulated one in the Maker. Read before writing or changing interface.tsx or any app under .baba/apps/." };
2
+
3
+ # Compose an interface
4
+
5
+ Every view is an app: a plain React component in `.baba/apps/NAME.tsx`, reading the state through hooks that follow the rounds and acting through the hand. A system has no look of its own; its directory is `index.ts` and `spec.ts`. `interface.tsx` composes the components only, one for each app the baba declares in `baba({ apps })`; voss's shell, served at `/NAME/`, opens them as windows (see `add-a-desktop`), each a view into the one state. An interface reads what it shows and no more: the server sends a page only what its hooks asked for.
6
+
7
+ ```ts
8
+ // .baba/index.ts: the apps, as data; `systems` names what each one shows
9
+ export default baba({
10
+ systems: [tasks, pond],
11
+ apps: [
12
+ { key: "tasks", title: "Tasks", icon: "list-checks", group: "Work", systems: ["tasks"] },
13
+ { key: "pond", title: "Pond", icon: "fish", group: "Explore", systems: ["pond"] },
14
+ ],
15
+ });
16
+ ```
17
+
18
+ ```tsx
19
+ // .baba/apps/tasks.tsx
20
+ import { select, useComponent, useCount, useHand, useQuery } from "babavoss/web";
21
+ import { Button } from "@/ui/button";
22
+ import tasks from "../systems/tasks/index.ts";
23
+
24
+ const all = select(tasks.task).by(tasks.task, (a, b) => a.dir.localeCompare(b.dir)); // declared once, at module level
25
+
26
+ function Row({ e }: { e: number }) {
27
+ const t = useComponent(e, tasks.task); // wakes only when this task is written
28
+ return t ? <li>{t.dir}</li> : null;
29
+ }
30
+ export function TasksApp() {
31
+ const ids = useQuery(all); // the same array until a task comes, goes or moves
32
+ const n = useCount(all);
33
+ const hand = useHand(); // undefined where the app may not act
34
+ return <main><h1>{n} tasks</h1><ul>{ids.map((e) => <Row key={e} e={e} />)}</ul>{hand && <Button onClick={() => hand.fire("refresh")}>Refresh</Button>}</main>;
35
+ }
36
+ ```
37
+
38
+ ```tsx
39
+ // .baba/interface.tsx: a component for every app, checked against the keys; the Maker is one of them
40
+ import { compose, Maker, type AppProps } from "babavoss/web";
41
+ import specs from "babavoss:specs";
42
+ import type baba from "./index.ts";
43
+ import { TasksApp } from "./apps/tasks.tsx";
44
+ import { PondApp } from "./apps/pond.tsx";
45
+
46
+ const apps = { tasks: TasksApp, pond: PondApp };
47
+ function MakerApp({ path, navigate, setTitle }: AppProps) { return <Maker specs={specs} apps={apps} path={path} navigate={navigate} setTitle={setTitle} />; }
48
+ export default compose<typeof baba>({ apps: { ...apps, maker: MakerApp } });
49
+ ```
50
+
51
+ ## Two ways to read
52
+
53
+ - **`useBabaState(baba)`** gives `w`, a replica of the whole state at the latest round, with `w.query`, `w.get`, `w.has`, `w.count` and the change terms, `added(c)`, `changed(c)`, `removed(c)`, meaning what the last round moved, and `fire`. Every round re-renders the component. Right for a small baba, an app drawn whole, a debug page.
54
+ - **The targeted hooks** each ask for one thing and wake only when it moves, so a page of them costs what it shows: `useQuery(select)` the ids a select answers; `useComponent(e, c)` one component of one entity, the same object until written; `useColumn(select, c)` one component of every member, for a layer drawn at once; `useCount(select)`; `useHas(e, c)`; `useAlive(e)`; `useResource(r)`; `useRound()`. Declare a `select(...)` once at module level, with `.where(fn)` and `.by(c, cmp)` on it, so the table knows which values can change the answer; one made in a render is a new one every time.
55
+ - **`useBaba(baba)`** gives `fire`, `read` and `raw` without the state, for a page built on the targeted hooks. `fireIn` answers the round an action ran in, and `useSettled(round)` says when the page has seen it, so a row shows pending until its effect is in view.
56
+ - `ready` is the first frame landed; `connected` the socket up, and down, calls still go over the API and the state waits. `useProbe()` is what following costs this window, round by round.
57
+
58
+ ## Any state: live, simulated, a frame
59
+
60
+ The hooks read whatever state the app is shown over: the page's live store, the Maker's simulated state, or a fixed frame. An app that reads only through them, and acts only through `useBabaState().fire` or `useHand()`, draws the same in all three, so the one component is the app and its picture in the Maker.
61
+
62
+ - **The hand.** `useHand()` answers `{ fire(name, args) }`, or undefined where the app may not act, so a control renders only when it can: `{hand && <Button onClick={() => hand.fire("feed")}>Feed</Button>}`. Live and in the Maker it fires into the state shown; without one the app is a picture.
63
+ - **A state of your own.** To show an app over a replica, a sketch, a preview, a test, wrap it: `<AppState w={replica} hand={hand}>…</AppState>`; every hook inside reads `w`, and `useHand` answers `hand`, none when it is not given.
64
+ - **Time in a spec is small.** `w.now` starts near zero; an app that shows times formats small numbers as seconds and large ones as clock time.
65
+
66
+ ## Apps in the Maker
67
+
68
+ The Maker is an app like the others: `<Maker specs={specs} apps={apps} />`, the specs from `babavoss:specs` and the baba's apps. A spec open shows its model, its spec (the verdicts, run, step, scrub), and an app: one of the baba's apps drawn over the simulated state, with the hand acting on it, or the desktop with every app that fits, for a spec that has the desktop or is the whole baba. An app fits a spec when every system it names in `systems` is in that spec; an app that names none fits every spec. The person picks the app, and the pick is kept per spec. Name in `systems` every system the app draws, so the Maker offers it where it can draw.
69
+
70
+ ## An app draws objects
71
+
72
+ Start from the Maker's model lens: every object the systems make is already drawn there as a card, with its attributes, what rides beside it, its links and its calls to action. An app is an arrangement of those: the yard board is version objects with issue and feature objects nested in their columns.
73
+
74
+ ## Rules
75
+
76
+ - **Pixels come from the kit.** Import controls from `@/ui`: `import { Button } from "@/ui/button"`. `voss baba ui` lists what the kit has; `voss baba ui-add '{"names":["checkbox"]}'` brings a component from shadcn's registry. Lay out with Tailwind classes; color, type and space are the theme's tokens, `--primary`, `--muted-foreground`, `--border`, `--card`, `--success`, `--destructive`, `--warning`. The demo's hello app is the pattern: hooks, `useHand`, the kit, one component live and in the Maker.
77
+ - **Read through the hooks only.** No fetching, nothing the state does not hold; what an app shows comes from the state it is shown over.
78
+ - **Nothing the interface knows is its own.** Selection, filters, a remembered path: if two windows should agree on it, or a scenario should see it, it is a component in the state, written by an action. A text field's draft may stay in React state; a window's own state, its filter or its fold, goes to the desktop with `remember` (see `add-a-desktop`).
79
+ - **Keys are entities**: `key={e}` on every row, so the DOM follows the entity across rounds and transitions read as motion.
80
+ - **Fire actions, read queries.** The interface has no other way to move or ask the state, so what it does is what the CLI and an agent could do. `raw` is the inspector's.
81
+ - **The desktop is optional.** A baba of several apps composes `Desktop` from `babavoss/web` as its shell; see `add-a-desktop`.
82
+ - An app's markup may be tested with plain bun test beside it, `.baba/apps/NAME.test.tsx`; the systems' behaviour is proved in their specs (see `spec-a-system`).
83
+ - An interface's build, load or render failure shows on its page. voss's own apps, the state, the contract, the Maker, stand without one, so a baba needs none to be used.
84
+ - The old spellings, `view.tsx`, `view({ component })` and `compose({ shell })`, still load for one release, drawn whole in one app, `view`.
@@ -0,0 +1,13 @@
1
+ // voss's own know-how for agents, given to every baba after its own: how to
2
+ // write, prove and compose systems, how to draw the page as apps and give it
3
+ // a desktop, and how to write promptware.
4
+ import type { Prompt } from "../prompt/index.ts";
5
+ import writeASystem from "./write-a-system.mdx";
6
+ import reachOutside from "./reach-outside.mdx";
7
+ import systemsTogether from "./systems-together.mdx";
8
+ import composeAnInterface from "./compose-an-interface.mdx";
9
+ import addADesktop from "./add-a-desktop.mdx";
10
+ import writePromptware from "./write-promptware.mdx";
11
+ import specASystem from "./spec-a-system.mdx";
12
+
13
+ export const guide: readonly Prompt[] = [writeASystem, reachOutside, systemsTogether, specASystem, composeAnInterface, addADesktop, writePromptware];
@@ -0,0 +1,112 @@
1
+ export const prompt = { kind: "skill", name: "reach-outside", description: "Give a babavoss system the outside: requests and sources through the port (fs, http, exec, secrets), mirrors of a directory or an API, streams, and the on bindings that run them. Read before writing an effect, a source, or anything async in a system." };
2
+
3
+ # Reach outside
4
+
5
+ Nothing in a round touches the outside. A system asks for a job; the runtime runs it through the port, `ctx`, outside the round; what comes back lands as data in a later round. Two kinds of job:
6
+
7
+ - an **effect** answers once: a request, a write, a command;
8
+ - a **source** keeps answering: a directory watched, an API followed, a stream.
9
+
10
+ A job is declared in the system's `effects` and bound by a piece in its list, `p.on(pages.fetch, { for, done, failed })`: which entities want it, and how its answers land.
11
+
12
+ ```ts
13
+ import { system, effect, source, s, not, changed } from "babavoss";
14
+
15
+ export default system({
16
+ name: "pages",
17
+ model: { page: { url: s.key(s.string()) }, body: { text: s.string() } },
18
+ effects: {
19
+ fetch: effect({
20
+ summary: "a page's text",
21
+ args: s.object({ url: s.string() }),
22
+ result: s.object({ text: s.string() }),
23
+ retry: 30_000, // a failure runs again after this; omit to fail once
24
+ run: async ({ url }, ctx) => ({ text: (await ctx.http.fetch(url)).body }),
25
+ }),
26
+ },
27
+ }, (pages, p) => [
28
+ // Every page without a body is fetched; the body lands beside it.
29
+ p.on(pages.fetch, {
30
+ for: (w) => w.query(pages.page, not(pages.body)).map((r) => [r.entity, { url: r.page.url }] as const), // who wants it, every round
31
+ done: (w, e, r) => w.add(e!, pages.body, { text: r.text }), // lands next round, as this system
32
+ failed: (w, e, error) => { /* write state that stops the want, or let retry run */ },
33
+ }),
34
+ ]);
35
+ ```
36
+
37
+ ## How jobs work
38
+
39
+ - A binding is `p.on(effect, …)` of one of the system's own effects, listed with its other pieces: `for(w)`, `done(w, entity, result)`, `failed(w, entity, error)`. A `result` is typed by the effect's `result`. A binding has no name; another system's effect, or one effect bound twice, is refused.
40
+ - `for` is asked every round: which entities want this effect, with what args. An entity already running it is skipped, and the round its answer lands it is skipped too. A want that `done` does not change is asked again.
41
+ - `every: ms` runs the effect for the system itself (entity `null`) on a schedule. `w.run(effect, args, entity)` asks by hand, for the system's own effects only.
42
+ - The job is a row in the state: `state` shows it, `follow` waits on it, despawning the entity cancels it and aborts `ctx.signal`. Hold no in-flight state in the system.
43
+ - `repeat: false` marks an effect not safe to run twice: cut off by a crash, it lands once as failed, "interrupted".
44
+ - Results are checked against `result`; a bad one lands as failed.
45
+ - A job shows its `status`, `attempt` and last `error` in `state`. `w.settlement(entity)` is how the entity's last job ended, `{ effect, ok, error, at }`; `w.done(effect)` is this round's answers of one effect, for a step that wants them without a `done` binding. Both are read in the round the answer lands.
46
+
47
+ ## The port
48
+
49
+ `ctx.fs` (`read`, `readBytes`, `write`, `list`, `stat`, `remove`, `mkdir`, `watch`), `ctx.http.fetch(url, { method, headers, body })`, `ctx.exec.run(program, args, { cwd, input, env })`, `ctx.secrets`, `ctx.env`, `ctx.signal`. Paths are inside the project only; a symlink out is refused. Never `process`, `Bun`, `fs` or `fetch` directly: the type guard refuses them, and the wall would too.
50
+
51
+ ## Processes
52
+
53
+ - Use `ctx.exec.run(program, args, { cwd, input, env, timeout })` for a final `{ code, stdout, stderr }`. The default timeout is ten minutes, for `stream` too; `null` explicitly allows an unlimited lifetime. Collected output is capped at 8 MiB. A program may close stdin before reading all of `input`; its exit code still decides. Launch arguments and environment together are limited to 128 KiB.
54
+ - Use `ctx.exec.stream` inside a source's `watch` for `started`, `stdout`, `stderr`, and `exit`. Chunks are `Uint8Array`s of at most 16 KiB, not protocol messages: decode with a streaming `TextDecoder` and keep incomplete messages inside `watch`. Readiness is the system's reading of output or an HTTP probe; yield progress or readiness events and read them with `w.events`. The started event's PID is for diagnostics only.
55
+ - Set `interactive: true` to keep stdin open. The started event's `process` reference can be passed to `ctx.exec.write(reference, text)` by another effect in the same system and generation. `null` closes stdin. Serialize writes, each at most 16 KiB; a write is refused while initial input or another write is pending. An acknowledgement means bytes accepted by the pipe, not application completion, and cancellation can leave delivery uncertain.
56
+ - The job owns the process. Job settlement or cancellation stops its executions; replacement waits for cleanup. Reload prepares the new code without running jobs, retires the old generation, waits for cleanup, then activates; a failed preparation leaves the old one running. References expire with their execution and never survive a reload: re-establish readiness, and treat saved URLs and references as history.
57
+ - Run foreground programs. The macOS launchd runner and sentinel monitor each other and clean up their own process group after runtime, kernel, or either helper is lost. Simultaneous forced loss of both helpers is outside the guarantee. Daemonizing, changing groups, elevation, and externally installed services escape that scope; run installers in their foreground, noninteractive mode. Execution currently requires a macOS GUI login; other platforms refuse it.
58
+ - Keep media bytes in files or network connections. Output delivery is bounded; output that cannot drain within five seconds after exit fails the execution. Coalesce progress instead of filling the baba's inbox.
59
+ - For finite work, stop the want on completion. A source can set `repeat: false` like an effect; interruption then lands as failed. This neither disables configured retries nor proves that an external command did not already execute. Recover partial files and unknown outcomes in the system.
60
+
61
+ ## Sources: the outside as components
62
+
63
+ A source with a `mirror` keeps one of this system's keyed components as a faithful copy of a slice of the outside. The runtime does the reconciling; you declare how to read the slice.
64
+
65
+ ```ts
66
+ export default system({
67
+ name: "files",
68
+ model: {
69
+ project: { dir: s.key(s.string()) },
70
+ file: { path: s.key(s.string()), of: s.entity("project"), size: s.integer() }, // `of`: the job's entity, filled by the runtime
71
+ },
72
+ effects: {
73
+ files: source({
74
+ summary: "a project's files as the disk has them",
75
+ args: s.object({ dir: s.string() }),
76
+ mirror: "file", // one of the model's components, by name
77
+ list: async ({ dir }, ctx) => (await ctx.fs.list(dir)).filter((e) => !e.dir).map((e) => ({ path: e.path, size: e.size })), // the slice as it is
78
+ watch: async function* ({ dir }, ctx) { // its changes
79
+ for await (const c of ctx.fs.watch(dir)) yield "gone" in c ? { gone: c.path } : "resync" in c ? { changed: true } : { path: c.path, size: c.size };
80
+ },
81
+ every: 60_000, // list again on a schedule too
82
+ }),
83
+ },
84
+ }, (files, p) => [
85
+ p.on(files.files, { for: (w) => w.query(files.project).map((r) => [r.entity, { dir: r.project.dir }] as const) }),
86
+ ]);
87
+ ```
88
+
89
+ - `mirror` names one of this system's components, as a string, checked against the model: a name the model does not have, or a `list` answering items of another shape, does not type-check.
90
+ - Items are the component's values, put by the key field. Unchanged rows do not tick. Rows a listing did not name are taken away. `{ gone: key }` takes one; `{ changed: true }` lists again.
91
+ - `list` + `every` with no `watch` is a poll. `list` + `watch` lists, then follows. The runtime lists again on a retry, a reload, a resync.
92
+ - The system's own steps never write a mirrored component. Put what the system says about a file in a component beside it. React with `added(file)`, `changed(file)`, `removed(file)`.
93
+ - A mirror kept per entity needs the `s.entity` field for the job's entity; keys must be unique within the component, so include what scopes them.
94
+ - `for` is the set wanted: an entity that leaves it loses its watch, changed args restart it.
95
+
96
+ A source without a mirror is a stream: `event` is the schema of what `watch` yields; every event lands in the next round; a step reads them with `w.events(effect)`, in order, for that round only.
97
+
98
+ ```ts
99
+ effects: { tokens: source({ event: s.object({ text: s.string() }), watch: async function* ({ prompt }, ctx, last) { /* yield { text } */ } }) },
100
+ // a step in chat's list:
101
+ p.step("grow", (w) => { for (const { entity, event } of w.events(chat.tokens)) w.update(entity!, chat.draft, (d) => ({ text: d.text + event.text })); }),
102
+ ```
103
+
104
+ `last` is the last event this job landed, for a stream that resumes after a failure. Big data stays in the job: write it with `ctx.fs.write` inside `watch` and yield progress.
105
+
106
+ ## Patterns
107
+
108
+ - **Derive the want from state, never from a flag you set.** "every page without a body" asks once per page and stops when the body lands. On failure, write something (`missing`) that stops the want, or let `retry` handle a transient one.
109
+ - **Echo of your own writes**: a system that writes files and mirrors the same directory sees its write come back; compare a hash you kept, or ignore `changed` on rows whose value you just wrote.
110
+ - **Conflicts are state**: a row with both a fresh stamp from the disk and a pending `edit` is "they disagree"; a step decides, not a binding.
111
+ - **Rate limits and queues live in `for`**: yield only as many entities as may run; the rest wait as visible, unanswered state.
112
+ - **One binding for several sources**: a helper typed `(w: StateOf<typeof pages>, …)`, called from the `done` and `failed` of each source's binding.