crewforth 0.0.1 → 3.0.0-rc.1

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 (168) hide show
  1. package/CHANGELOG.md +3784 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1670 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +465 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/star.sh +70 -0
  29. package/kit/eval/parser-conformance.sh +612 -0
  30. package/kit/eval/preflight.sh +186 -0
  31. package/kit/eval/routing-eval.sh +209 -0
  32. package/kit/eval/scan-skill.sh +162 -0
  33. package/kit/eval/smoke-test.sh +6693 -0
  34. package/kit/eval/update-guard.sh +81 -0
  35. package/kit/eval/utilization.sh +130 -0
  36. package/kit/hooks/board-sync.sh +106 -0
  37. package/kit/hooks/board.sh +962 -0
  38. package/kit/hooks/commit-msg +49 -0
  39. package/kit/hooks/context-usage.sh +353 -0
  40. package/kit/hooks/floor-blocklist.txt +124 -0
  41. package/kit/hooks/guard-bash.sh +1431 -0
  42. package/kit/hooks/guard-commit-scan.sh +511 -0
  43. package/kit/hooks/guard-write.sh +513 -0
  44. package/kit/hooks/pre-commit +577 -0
  45. package/kit/hooks/route-hint.sh +185 -0
  46. package/kit/hooks/secret-blocklist.txt +82 -0
  47. package/kit/hooks/session-guard.sh +149 -0
  48. package/kit/hooks/session-rehydrate.sh +41 -0
  49. package/kit/hooks/session-stats.sh +252 -0
  50. package/kit/hooks/session-update-check.sh +236 -0
  51. package/kit/hooks/skill-trust.sh +150 -0
  52. package/kit/hooks/trace-blocklist.txt +53 -0
  53. package/kit/settings.json +132 -0
  54. package/kit/skills/a11y/SKILL.md +44 -0
  55. package/kit/skills/adr/SKILL.md +74 -0
  56. package/kit/skills/api-design/SKILL.md +50 -0
  57. package/kit/skills/automode-policy/SKILL.md +91 -0
  58. package/kit/skills/automode-policy/references/policy.json +13 -0
  59. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  60. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  61. package/kit/skills/backend-architecture/SKILL.md +103 -0
  62. package/kit/skills/brainstorm/SKILL.md +47 -0
  63. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  64. package/kit/skills/commit-message/SKILL.md +82 -0
  65. package/kit/skills/commit-message/references/examples.md +8 -0
  66. package/kit/skills/confidence-check/SKILL.md +86 -0
  67. package/kit/skills/crew-board/SKILL.md +55 -0
  68. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  69. package/kit/skills/crew-code-review/SKILL.md +151 -0
  70. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  71. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  72. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  73. package/kit/skills/crew-doctor/SKILL.md +36 -0
  74. package/kit/skills/crew-gates/SKILL.md +25 -0
  75. package/kit/skills/crew-handoff/SKILL.md +14 -0
  76. package/kit/skills/crew-plan/SKILL.md +18 -0
  77. package/kit/skills/crew-review/SKILL.md +23 -0
  78. package/kit/skills/crew-ship/SKILL.md +19 -0
  79. package/kit/skills/crew-skill/SKILL.md +61 -0
  80. package/kit/skills/crew-studio/SKILL.md +129 -0
  81. package/kit/skills/crew-update/SKILL.md +43 -0
  82. package/kit/skills/db-migration/SKILL.md +120 -0
  83. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  84. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  85. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  86. package/kit/skills/dependency-audit/SKILL.md +61 -0
  87. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  88. package/kit/skills/deploy/SKILL.md +100 -0
  89. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  90. package/kit/skills/deploy/references/rollback.md +18 -0
  91. package/kit/skills/deploy/references/scripts.md +9 -0
  92. package/kit/skills/deploy/references/topology-a.md +128 -0
  93. package/kit/skills/deploy/references/topology-b.md +44 -0
  94. package/kit/skills/docs-writer/SKILL.md +53 -0
  95. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  96. package/kit/skills/eval-grader/SKILL.md +75 -0
  97. package/kit/skills/eval-grader/references/method.md +51 -0
  98. package/kit/skills/frontend/SKILL.md +56 -0
  99. package/kit/skills/frontend/references/verify-contract.md +41 -0
  100. package/kit/skills/frontend-design/SKILL.md +71 -0
  101. package/kit/skills/frontend-design/references/design-review.md +34 -0
  102. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  103. package/kit/skills/handoff/SKILL.md +71 -0
  104. package/kit/skills/handoff/references/cold-reader.md +51 -0
  105. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  106. package/kit/skills/incident-runbook/SKILL.md +40 -0
  107. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  108. package/kit/skills/iterate/SKILL.md +40 -0
  109. package/kit/skills/mcp-builder/SKILL.md +54 -0
  110. package/kit/skills/mcp-builder/references/building.md +71 -0
  111. package/kit/skills/observability/SKILL.md +50 -0
  112. package/kit/skills/performance/SKILL.md +38 -0
  113. package/kit/skills/performance/references/bottlenecks.md +10 -0
  114. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  115. package/kit/skills/red-team/SKILL.md +48 -0
  116. package/kit/skills/reflect/SKILL.md +57 -0
  117. package/kit/skills/release/SKILL.md +44 -0
  118. package/kit/skills/security-scan/SKILL.md +99 -0
  119. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  120. package/kit/skills/security-scan/references/fronts.md +99 -0
  121. package/kit/skills/security-scan/references/prompting.md +23 -0
  122. package/kit/skills/security-scan/references/reporting.md +75 -0
  123. package/kit/skills/security-scan/references/verify.md +131 -0
  124. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  125. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  126. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  127. package/kit/skills/spec-planning/SKILL.md +92 -0
  128. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  129. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  130. package/kit/skills/teamboard/SKILL.md +88 -0
  131. package/kit/skills/teamboard/references/setup.md +37 -0
  132. package/kit/skills/testing/SKILL.md +49 -0
  133. package/kit/skills/testing/references/flaky-triage.md +51 -0
  134. package/kit/skills/threat-model/SKILL.md +57 -0
  135. package/kit/skills/threat-model/references/interview.md +51 -0
  136. package/kit/skills/threat-model/references/schema.md +42 -0
  137. package/kit/skills/token-budget/SKILL.md +51 -0
  138. package/kit/skills/trace-scan/SKILL.md +43 -0
  139. package/kit/skills/worktree/SKILL.md +40 -0
  140. package/kit/studio/README.md +266 -0
  141. package/kit/studio/ensure-node.sh +471 -0
  142. package/kit/studio/package.json +14 -0
  143. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  144. package/kit/studio/server/index.js +902 -0
  145. package/kit/studio/server/lib/crew-env.js +11 -0
  146. package/kit/studio/server/lib/fleet.js +146 -0
  147. package/kit/studio/server/lib/graph.js +533 -0
  148. package/kit/studio/server/lib/instance.js +135 -0
  149. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  150. package/kit/studio/server/lib/kit.js +158 -0
  151. package/kit/studio/server/lib/palette.js +118 -0
  152. package/kit/studio/server/lib/peers.js +72 -0
  153. package/kit/studio/server/lib/permissions.js +150 -0
  154. package/kit/studio/server/lib/projects.js +294 -0
  155. package/kit/studio/server/lib/roster.js +191 -0
  156. package/kit/studio/server/lib/session.js +309 -0
  157. package/kit/studio/server/lib/terminal.js +91 -0
  158. package/kit/studio/server/lib/transcript.js +100 -0
  159. package/kit/studio/web/app.js +1098 -0
  160. package/kit/studio/web/canvas.js +1179 -0
  161. package/kit/studio/web/chat.js +543 -0
  162. package/kit/studio/web/index.html +86 -0
  163. package/kit/studio/web/md.js +110 -0
  164. package/kit/studio/web/storage-migrate.js +23 -0
  165. package/kit/studio/web/style.css +1453 -0
  166. package/package.json +46 -8
  167. package/start.sh +748 -0
  168. package/bin/crewforth.js +0 -10
@@ -0,0 +1,543 @@
1
+ // Conversations with sessions the panel owns.
2
+ //
3
+ // Each session gets a pane that keeps its own stream open whether or not it is
4
+ // on screen, so a session working in the background is still working when you
5
+ // come back to it — its progress is not a replay, it happened.
6
+ //
7
+ // Two sources describe the same reply: partial `content_block_delta` events
8
+ // while it is being written, and the complete `assistant` record once it is
9
+ // done. Rendering both would double every message, so deltas only ever feed a
10
+ // provisional bubble that the authoritative record replaces.
11
+
12
+ import { renderMarkdown } from './md.js';
13
+
14
+ const el = (tag, cls, text) => {
15
+ const n = document.createElement(tag);
16
+ if (cls) n.className = cls;
17
+ if (text != null) n.textContent = text;
18
+ return n;
19
+ };
20
+
21
+ /**
22
+ * Options a reply is offering, if it is offering any.
23
+ *
24
+ * Exported so it can be tested without a DOM: the rule for when prose counts
25
+ * as a question is the part worth pinning, and it is easy to get wrong in a
26
+ * direction that puts buttons under every bulleted list.
27
+ */
28
+ export function quickReplies(text) {
29
+ if (!text) return [];
30
+ // Only the tail of the message: an option list in the middle is discussion,
31
+ // not the question being asked now.
32
+ const lines = text.trimEnd().split('\n').slice(-14);
33
+ const opts = [];
34
+ for (const raw of lines) {
35
+ const m = raw.match(/^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$/);
36
+ if (!m) continue;
37
+ const label = m[1].replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
38
+ if (label.length >= 2 && label.length <= 80) opts.push(label);
39
+ }
40
+ // Two to five short options reads as a question. One is a statement, a dozen
41
+ // is a report, and without a question mark it is neither.
42
+ return (opts.length >= 2 && opts.length <= 5 && /\?/.test(text)) ? opts : [];
43
+ }
44
+
45
+ /* ========================================================== one session === */
46
+
47
+ class Pane {
48
+ constructor(session, { api, headers, onChange, readOnly = false }) {
49
+ this.api = api;
50
+ this.headers = headers;
51
+ this.onChange = onChange;
52
+ this.readOnly = readOnly;
53
+ this.session = session;
54
+ this.id = session.sessionId;
55
+ this.source = null;
56
+ this.messages = [];
57
+ this.streaming = null;
58
+ this.permissions = [];
59
+ this.unread = 0;
60
+
61
+ this.root = el('div', 'pane');
62
+ this.root.innerHTML = `
63
+ <div class="chat-log"></div>
64
+ <div class="perm-queue" hidden></div>
65
+ <form class="chat-form">
66
+ <textarea class="chat-input" rows="2"
67
+ placeholder="Message this session… (Enter to send, Shift+Enter for a newline)"></textarea>
68
+ <button class="chat-send" type="submit">send</button>
69
+ </form>
70
+ <div class="chat-ro" hidden>
71
+ <strong>Read-only.</strong> This session belongs to a terminal, and a Claude Code session
72
+ takes input from the process that owns it — the panel has no way in, and two writers on one
73
+ transcript would corrupt it. <strong>⑂ fork &amp; continue</strong> makes a copy the panel
74
+ <em>does</em> own; messages there stay there and never reach your terminal.
75
+ </div>`;
76
+
77
+ this.logEl = this.root.querySelector('.chat-log');
78
+ this.permEl = this.root.querySelector('.perm-queue');
79
+ this.formEl = this.root.querySelector('.chat-form');
80
+ this.inputEl = this.root.querySelector('.chat-input');
81
+ this.roEl = this.root.querySelector('.chat-ro');
82
+
83
+ this.formEl.addEventListener('submit', (e) => { e.preventDefault(); this.send(); });
84
+ this.inputEl.addEventListener('keydown', (e) => {
85
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.send(); }
86
+ });
87
+
88
+ if (readOnly) {
89
+ this.formEl.hidden = true;
90
+ this.roEl.hidden = false;
91
+ this.root.classList.add('pane-readonly');
92
+ } else {
93
+ this.connect();
94
+ }
95
+ this.loadHistory();
96
+ }
97
+
98
+ get visible() { return !this.root.hidden; }
99
+
100
+ /**
101
+ * What was said before this pane existed.
102
+ *
103
+ * A resumed session remembers its history — it will answer questions about
104
+ * it — but its stream starts fresh, so continuing a conversation looked
105
+ * exactly like starting one. An observed session has no stream at all, and
106
+ * its whole conversation is history.
107
+ */
108
+ async loadHistory() {
109
+ const from = this.readOnly ? this.id : this.session?.resumedFrom;
110
+ if (!from) return;
111
+ let conv;
112
+ try {
113
+ conv = await fetch(this.api(`/api/session/${encodeURIComponent(from)}/conversation`), { cache: 'no-store' })
114
+ .then((r) => r.json());
115
+ } catch (e) {
116
+ this.note(`History not read: ${e.message}`, 'bad');
117
+ return;
118
+ }
119
+ if (conv?.measured === false) { this.note(`History not measured — ${conv.reason}`, 'bad'); return; }
120
+
121
+ const frag = document.createDocumentFragment();
122
+ if (conv.truncated) {
123
+ frag.append(el('div', 'chat-note', `${conv.total - conv.messages.length} earlier message(s) not shown`));
124
+ }
125
+ for (const m of conv.messages ?? []) frag.append(this.renderMessage(m));
126
+ if (!this.readOnly) {
127
+ // A visible seam, so nobody reads the history as part of this run — and
128
+ // named for which of the two things actually happened. A fork that calls
129
+ // itself "continued" reads as "you are typing into that session", which
130
+ // is the one thing it is not; a real continuation that calls itself a
131
+ // fork undersells it.
132
+ frag.append(el('div', 'chat-seam', this.session?.forked
133
+ ? `— forked from ${from.slice(0, 8)} · a separate session; the original does not see this —`
134
+ : `— continuing ${from.slice(0, 8)} · same session, same transcript —`));
135
+ }
136
+ this.logEl.prepend(frag);
137
+ this.logEl.scrollTop = this.logEl.scrollHeight;
138
+ }
139
+
140
+ connect() {
141
+ if (this.source) return;
142
+ this.source = new EventSource(this.api(`/api/owned/${encodeURIComponent(this.id)}/events`));
143
+ this.source.addEventListener('state', (e) => {
144
+ try { this.session = JSON.parse(e.data); } catch { return; }
145
+ this.paintState();
146
+ this.onChange(this);
147
+ });
148
+ this.source.addEventListener('event', (e) => {
149
+ let ev;
150
+ try { ev = JSON.parse(e.data); } catch { return; }
151
+ this.absorb(ev.rec);
152
+ });
153
+ this.source.onerror = () => { this.streamBroken = true; this.onChange(this); };
154
+ }
155
+
156
+ disconnect() {
157
+ if (this.source) { this.source.close(); this.source = null; }
158
+ }
159
+
160
+ /* --------------------------------------------------------- ingestion */
161
+
162
+ absorb(rec) {
163
+ if (!rec) return;
164
+
165
+ if (rec.type === 'stream_event') {
166
+ const t = rec.event?.type;
167
+ if (t === 'message_start') { this.streaming = { text: '' }; this.paintStreaming(); }
168
+ else if (t === 'content_block_delta' && rec.event.delta?.type === 'text_delta') {
169
+ if (!this.streaming) this.streaming = { text: '' };
170
+ this.streaming.text += rec.event.delta.text ?? '';
171
+ this.paintStreaming();
172
+ }
173
+ return;
174
+ }
175
+
176
+ // A subagent's text is forwarded with a parent id. It belongs to the graph,
177
+ // not to this conversation — showing it here would read as the session
178
+ // saying things it never said.
179
+ if (rec.parent_tool_use_id) return;
180
+
181
+ if (rec.type === 'permissions') {
182
+ this.permissions = rec.pending ?? [];
183
+ this.paintPermissions();
184
+ this.onChange(this);
185
+ return;
186
+ }
187
+
188
+ if (rec.type === 'user' && rec.message?.content) {
189
+ const text = typeof rec.message.content === 'string'
190
+ ? rec.message.content
191
+ : rec.message.content.filter((c) => c?.type === 'text').map((c) => c.text).join('');
192
+ if (text.trim()) { this.clearPending(); this.push({ role: 'user', text }); }
193
+ return;
194
+ }
195
+
196
+ if (rec.type === 'assistant' && Array.isArray(rec.message?.content)) {
197
+ this.streaming = null;
198
+ const blocks = [];
199
+ for (const c of rec.message.content) {
200
+ if (c?.type === 'text' && c.text?.trim()) blocks.push({ kind: 'text', text: c.text });
201
+ else if (c?.type === 'tool_use') {
202
+ const i = c.input ?? {};
203
+ blocks.push({
204
+ kind: 'tool',
205
+ name: c.name ?? 'tool',
206
+ label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
207
+ });
208
+ }
209
+ }
210
+ if (blocks.length) {
211
+ this.push({ role: 'assistant', blocks });
212
+ if (!this.visible) { this.unread += 1; this.onChange(this); }
213
+ }
214
+ return;
215
+ }
216
+
217
+ if (rec.type === 'result') { this.streaming = null; this.paintStreaming(); return; }
218
+ if (rec.type === 'fault' || rec.type === 'stderr') this.note(rec.reason ?? rec.text, 'bad');
219
+ }
220
+
221
+ push(msg) {
222
+ for (const q of this.logEl.querySelectorAll('.quick')) q.remove();
223
+ this.messages.push(msg);
224
+ this.logEl.append(this.renderMessage(msg));
225
+ this.paintStreaming();
226
+ this.scroll();
227
+ }
228
+
229
+ /* --------------------------------------------------------- rendering */
230
+
231
+ renderMessage(msg) {
232
+ const wrap = el('div', `msg msg-${msg.role}`);
233
+ wrap.append(el('div', 'msg-role', msg.role === 'user' ? 'you' : 'claude'));
234
+
235
+ const body = el('div', 'msg-body');
236
+ if (msg.role === 'user') {
237
+ body.append(el('div', 'msg-text', msg.text));
238
+ } else {
239
+ for (const b of msg.blocks) {
240
+ if (b.kind === 'text') {
241
+ const md = el('div', 'md');
242
+ md.innerHTML = renderMarkdown(b.text);
243
+ body.append(md);
244
+ } else {
245
+ const card = el('div', 'tool-card');
246
+ card.append(el('span', 'tool-name', b.name));
247
+ if (b.label) card.append(el('span', 'tool-label', String(b.label).slice(0, 200)));
248
+ body.append(card);
249
+ }
250
+ }
251
+ }
252
+ wrap.append(body);
253
+
254
+ if (msg.role === 'assistant') {
255
+ const last = msg.blocks?.filter((b) => b.kind === 'text').pop();
256
+ const opts = quickReplies(last?.text);
257
+ if (opts.length) {
258
+ const row = el('div', 'quick');
259
+ row.append(el('span', 'quick-hint', 'reply with'));
260
+ for (const o of opts) {
261
+ const b = el('button', 'quick-btn', o);
262
+ b.addEventListener('click', () => { row.remove(); this.inputEl.value = o; this.send(); });
263
+ row.append(b);
264
+ }
265
+ wrap.append(row);
266
+ }
267
+ }
268
+ return wrap;
269
+ }
270
+
271
+ paintStreaming() {
272
+ let node = this.logEl.querySelector('.msg-streaming');
273
+ if (!this.streaming) { node?.remove(); return; }
274
+ if (!node) {
275
+ node = el('div', 'msg msg-assistant msg-streaming');
276
+ node.append(el('div', 'msg-role', 'claude'));
277
+ node.append(el('div', 'msg-body'));
278
+ this.logEl.append(node);
279
+ }
280
+ const body = node.querySelector('.msg-body');
281
+ if (this.streaming.text) {
282
+ body.textContent = this.streaming.text;
283
+ body.classList.add('msg-text');
284
+ } else {
285
+ body.replaceChildren(el('span', 'thinking', 'thinking…'));
286
+ }
287
+ this.scroll();
288
+ }
289
+
290
+ paintPermissions() {
291
+ const list = this.permissions ?? [];
292
+ this.permEl.hidden = list.length === 0;
293
+ if (!list.length) { this.permEl.replaceChildren(); return; }
294
+
295
+ const wait = this.session?.gateWaitSeconds ?? null;
296
+ const frag = document.createDocumentFragment();
297
+ for (const r of list) {
298
+ const card = el('div', 'perm');
299
+ const head = el('div', 'perm-head');
300
+ head.append(el('span', 'perm-tool', r.toolName));
301
+ // The clock is part of the decision: silence becomes a denial, and the
302
+ // reader should see that coming rather than discover it.
303
+ if (wait) {
304
+ const left = Math.max(0, wait - Math.round((Date.now() - r.askedAt) / 1000));
305
+ head.append(el('span', 'perm-clock', `${left}s → deny`));
306
+ }
307
+ card.append(head);
308
+ if (r.detail) card.append(el('pre', 'perm-detail', String(r.detail).slice(0, 600)));
309
+
310
+ const acts = el('div', 'perm-acts');
311
+ for (const [verdict, label, cls] of [
312
+ ['allow', 'allow once', 'ok'],
313
+ ['always', `always allow ${r.toolName}`, ''],
314
+ ['deny', 'deny', 'bad'],
315
+ ]) {
316
+ const b = el('button', `perm-btn ${cls}`, label);
317
+ b.addEventListener('click', () => this.decide(r.toolUseId, verdict, card));
318
+ acts.append(b);
319
+ }
320
+ card.append(acts);
321
+ frag.append(card);
322
+ }
323
+ this.permEl.replaceChildren(frag);
324
+ }
325
+
326
+ paintState() {
327
+ const s = this.session;
328
+ const dead = s.state === 'exited' || s.state === 'failed';
329
+ this.formEl.hidden = false;
330
+ this.inputEl.disabled = dead;
331
+ }
332
+
333
+ note(text, kind = '') { this.logEl.append(el('div', `chat-note ${kind}`, text)); this.scroll(); }
334
+
335
+ scroll() {
336
+ // Only follow the tail when the reader is already at it, so scrolling back
337
+ // through a long reply is not yanked forward by the next token.
338
+ const near = this.logEl.scrollHeight - this.logEl.scrollTop - this.logEl.clientHeight < 120;
339
+ if (near) this.logEl.scrollTop = this.logEl.scrollHeight;
340
+ }
341
+
342
+ pending(text) {
343
+ this.clearPending();
344
+ const wrap = el('div', 'msg msg-user msg-pending');
345
+ wrap.append(el('div', 'msg-role', 'you'));
346
+ wrap.append(el('div', 'msg-body msg-text', text));
347
+ this.logEl.append(wrap);
348
+ this.scroll();
349
+ }
350
+
351
+ clearPending() { this.logEl.querySelector('.msg-pending')?.remove(); }
352
+
353
+ /* ------------------------------------------------------------ actions */
354
+
355
+ async send() {
356
+ const text = this.inputEl.value.trim();
357
+ if (!text) return;
358
+ this.inputEl.value = '';
359
+ // No optimistic bubble. The session is started with --replay-user-messages,
360
+ // so it echoes what it actually received; drawing our own copy as well
361
+ // printed every message twice.
362
+ this.pending(text);
363
+
364
+ const res = await fetch(this.api(`/api/owned/${encodeURIComponent(this.id)}/message`), {
365
+ method: 'POST',
366
+ headers: { 'content-type': 'application/json', ...this.headers },
367
+ body: JSON.stringify({ text }),
368
+ }).then((r) => r.json()).catch((e) => ({ ok: false, reason: e.message }));
369
+
370
+ if (!res.ok) { this.clearPending(); this.note(`Not sent: ${res.reason}`, 'bad'); }
371
+ else { this.session = res.session; this.paintState(); this.onChange(this); }
372
+ }
373
+
374
+ async decide(toolUseId, verdict, card) {
375
+ for (const b of card.querySelectorAll('button')) b.disabled = true;
376
+ const res = await fetch(
377
+ this.api(`/api/owned/${encodeURIComponent(this.id)}/permissions/${encodeURIComponent(toolUseId)}`),
378
+ { method: 'POST', headers: { 'content-type': 'application/json', ...this.headers }, body: JSON.stringify({ verdict }) },
379
+ ).then((r) => r.json()).catch((e) => ({ ok: false, reason: e.message }));
380
+ if (!res.ok) {
381
+ this.note(`Decision not recorded: ${res.reason}`, 'bad');
382
+ for (const b of card.querySelectorAll('button')) b.disabled = false;
383
+ }
384
+ }
385
+
386
+ async stop() {
387
+ await fetch(this.api(`/api/owned/${encodeURIComponent(this.id)}/stop`), {
388
+ method: 'POST', headers: this.headers,
389
+ }).catch(() => {});
390
+ }
391
+ }
392
+
393
+ /* ============================================================== the tabs === */
394
+
395
+ export class Chat {
396
+ constructor(root, { api, headers }) {
397
+ this.root = root;
398
+ this.api = api;
399
+ this.headers = headers;
400
+ this.panes = new Map();
401
+ this.activeId = null;
402
+ this.onActivate = () => {};
403
+
404
+ this.root.innerHTML = `
405
+ <div class="chat-head">
406
+ <div class="tabs"></div>
407
+ <span class="chat-state"></span>
408
+ <span class="chat-cost"></span>
409
+ <button class="ghost pin chat-grow" type="button" title="Widen the conversation">⇤</button>
410
+ <button class="ghost chat-stop" type="button" hidden>stop</button>
411
+ </div>
412
+ <div class="panes"></div>`;
413
+
414
+ this.tabsEl = this.root.querySelector('.tabs');
415
+ this.panesEl = this.root.querySelector('.panes');
416
+ this.stateEl = this.root.querySelector('.chat-state');
417
+ this.costEl = this.root.querySelector('.chat-cost');
418
+ this.stopEl = this.root.querySelector('.chat-stop');
419
+ this.stopEl.addEventListener('click', () => this.active?.stop?.());
420
+ this.growEl = this.root.querySelector('.chat-grow');
421
+ this.onGrow = () => {};
422
+ this.growEl.addEventListener('click', () => this.onGrow());
423
+
424
+ // Countdowns have to move on their own; nothing else re-renders them.
425
+ setInterval(() => { if (this.active?.permissions?.length) this.active.paintPermissions(); }, 1000);
426
+ }
427
+
428
+ get active() { return this.activeId ? this.panes.get(this.activeId) : null; }
429
+ get ids() { return [...this.panes.keys()]; }
430
+
431
+ async start({ cwd, model, permissionMode, resume }) {
432
+ const res = await fetch(this.api('/api/owned'), {
433
+ method: 'POST',
434
+ headers: { 'content-type': 'application/json', ...this.headers },
435
+ body: JSON.stringify({ cwd, model, permissionMode, resume }),
436
+ });
437
+ const body = await res.json().catch(() => ({ ok: false, reason: 'bad response' }));
438
+ if (!body.ok) return { ok: false, reason: body.reason };
439
+ this.open(body.session);
440
+ return { ok: true, session: body.session };
441
+ }
442
+
443
+ /** An observed session, shown but not driven. */
444
+ openReadOnly(sessionId, title) {
445
+ let pane = this.panes.get(sessionId);
446
+ if (!pane) {
447
+ pane = new Pane(
448
+ { sessionId, state: 'observed', permissionMode: 'read-only', gated: false, title },
449
+ { api: this.api, headers: this.headers, onChange: () => this.paintTabs(), readOnly: true },
450
+ );
451
+ this.panesEl.append(pane.root);
452
+ this.panes.set(sessionId, pane);
453
+ }
454
+ this.activate(sessionId);
455
+ return pane;
456
+ }
457
+
458
+ open(session) {
459
+ let pane = this.panes.get(session.sessionId);
460
+ if (!pane) {
461
+ pane = new Pane(session, { api: this.api, headers: this.headers, onChange: () => this.paintTabs() });
462
+ this.panesEl.append(pane.root);
463
+ this.panes.set(session.sessionId, pane);
464
+ }
465
+ this.activate(session.sessionId);
466
+ return pane;
467
+ }
468
+
469
+ activate(id) {
470
+ if (!this.panes.has(id)) return;
471
+ this.activeId = id;
472
+ for (const [pid, p] of this.panes) {
473
+ p.root.hidden = pid !== id;
474
+ if (pid === id) p.unread = 0;
475
+ }
476
+ this.paintTabs();
477
+ const a = this.active;
478
+ a?.inputEl?.focus();
479
+ this.onActivate(id);
480
+ }
481
+
482
+ close(id) {
483
+ const pane = this.panes.get(id);
484
+ if (!pane) return;
485
+ // Closing a tab closes the view, not the session: it keeps running and can
486
+ // be reopened from the sidebar. Stopping is a separate, explicit act.
487
+ pane.disconnect();
488
+ pane.root.remove();
489
+ this.panes.delete(id);
490
+ if (this.activeId === id) {
491
+ const next = this.ids[0] ?? null;
492
+ this.activeId = null;
493
+ if (next) this.activate(next); else { this.paintTabs(); this.onActivate(null); }
494
+ } else {
495
+ this.paintTabs();
496
+ }
497
+ }
498
+
499
+ paintTabs() {
500
+ const frag = document.createDocumentFragment();
501
+ for (const [id, p] of this.panes) {
502
+ const tab = el('button', `tab${id === this.activeId ? ' on' : ''}`);
503
+ tab.type = 'button';
504
+ const dot = el('span', 'tab-dot');
505
+ dot.dataset.state = p.session?.state ?? 'unknown';
506
+ tab.append(dot);
507
+ const label = p.readOnly ? `👁 ${p.session?.title ? String(p.session.title).slice(0, 18) : shortId(id)}`
508
+ : (p.session?.resumedFrom ? `↩ ${shortId(p.session.resumedFrom)}` : shortId(id));
509
+ tab.append(el('span', 'tab-name', label));
510
+ if (p.permissions?.length) tab.append(el('span', 'tab-badge warn', String(p.permissions.length)));
511
+ else if (p.unread) tab.append(el('span', 'tab-badge', String(p.unread)));
512
+ tab.title = `${id}\n${p.session?.state ?? ''} · ${p.session?.permissionMode ?? ''}`;
513
+ tab.addEventListener('click', () => this.activate(id));
514
+
515
+ const x = el('span', 'tab-x', '×');
516
+ x.title = 'Close this tab — the session keeps running';
517
+ x.addEventListener('click', (e) => { e.stopPropagation(); this.close(id); });
518
+ tab.append(x);
519
+ frag.append(tab);
520
+ }
521
+ this.tabsEl.replaceChildren(frag);
522
+
523
+ const a = this.active;
524
+ const s = a?.session;
525
+ if (a?.readOnly) {
526
+ this.stateEl.textContent = 'observed · read-only';
527
+ this.stateEl.dataset.state = 'observed';
528
+ this.stateEl.classList.remove('ungated');
529
+ this.costEl.textContent = '';
530
+ this.stopEl.hidden = true;
531
+ return;
532
+ }
533
+ this.stateEl.textContent = s
534
+ ? `${s.state} · ${s.permissionMode}${s.gated ? '' : ' · NO GATE'}`
535
+ : 'no session';
536
+ this.stateEl.dataset.state = s?.state ?? 'none';
537
+ this.stateEl.classList.toggle('ungated', Boolean(s) && !s.gated);
538
+ this.costEl.textContent = s?.costUsd ? `$${s.costUsd.toFixed(4)}` : '';
539
+ this.stopEl.hidden = !s || s.state === 'exited' || s.state === 'failed';
540
+ }
541
+ }
542
+
543
+ function shortId(id) { return id.slice(0, 8); }
@@ -0,0 +1,86 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Crewforth Studio</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ </head>
9
+ <body>
10
+ <header class="bar">
11
+ <div class="brand">
12
+ <svg class="brand-mark" viewBox="0 0 200 200" aria-hidden="true">
13
+ <polyline points="44,58 86,100 44,142" stroke-width="21" class="bar-1"/>
14
+ <polyline points="80,58 122,100 80,142" stroke-width="21" class="bar-2"/>
15
+ <polyline points="116,58 158,100 116,142" stroke-width="24" class="bar-3"/>
16
+ </svg>
17
+ <h1>Crewforth <span class="brand-accent">Studio</span></h1>
18
+ </div>
19
+ <div class="bar-mid">
20
+ <span id="graph-summary" class="meta"></span>
21
+ <span id="kitline" class="kitline"></span>
22
+ </div>
23
+ <div class="bar-right">
24
+ <button id="continue-session" class="ghost primary" type="button" hidden
25
+ title="Start a new session holding a copy of this conversation. It does not write into the original.">⑂ fork &amp; continue</button>
26
+ <button id="new-session" class="ghost primary" type="button">+ session</button>
27
+ <span id="pulse" class="pulse">connecting</span>
28
+ <button id="fullscreen" class="ghost" type="button" title="Full screen (f)" aria-label="Full screen">⛶</button>
29
+ <button id="theme" class="ghost" type="button" aria-label="Toggle theme">◐</button>
30
+ </div>
31
+ </header>
32
+
33
+ <div class="shell">
34
+ <button id="side-show" class="side-rail" type="button"
35
+ title="Show the sidebar" aria-label="Show the sidebar" hidden>
36
+ <span class="side-rail-arrow">›</span>
37
+ <span class="side-rail-label">sessions</span>
38
+ </button>
39
+ <aside class="side">
40
+ <section class="side-block" data-sect="fleet">
41
+ <div class="side-head">
42
+ <button class="twisty" type="button" data-fold="fleet" aria-expanded="true"
43
+ aria-label="Collapse Fleet">▾</button>
44
+ <h2 data-fold-for="fleet">Fleet</h2><span id="fleet-meta" class="meta"></span>
45
+ <button id="side-wide" class="ghost pin" type="button" title="Widen the sidebar">⇥</button>
46
+ <button id="side-hide" class="ghost pin" type="button" title="Collapse the sidebar">‹</button>
47
+ </div>
48
+ <div id="fleet" class="fleet" role="list" data-fold-body="fleet"></div>
49
+ <div class="side-head reach-head" hidden id="reach-head">
50
+ <button class="twisty" type="button" data-fold="reach" aria-expanded="true"
51
+ aria-label="Collapse Reachable elsewhere">▾</button>
52
+ <h2 data-fold-for="reach">Reachable elsewhere</h2><span id="reach-meta" class="meta"></span>
53
+ </div>
54
+ <div id="reach" class="fleet" role="list" data-fold-body="reach"></div>
55
+ </section>
56
+ <section class="side-block grow" data-sect="sessions">
57
+ <div class="side-head">
58
+ <button class="twisty" type="button" data-fold="sessions" aria-expanded="true"
59
+ aria-label="Collapse Projects">▾</button>
60
+ <h2 data-fold-for="sessions">Projects</h2><span id="sessions-meta" class="meta"></span>
61
+ </div>
62
+ <div class="side-filter" data-fold-body="sessions">
63
+ <input id="filter" type="search" placeholder="filter projects…" autocomplete="off" spellcheck="false">
64
+ </div>
65
+ <div id="sessions" class="sessions" role="tree" data-fold-body="sessions"></div>
66
+ </section>
67
+ </aside>
68
+
69
+ <div class="resizer" id="resizer" role="separator" aria-orientation="vertical"
70
+ aria-label="Resize sidebar" tabindex="0"></div>
71
+
72
+ <main class="stage">
73
+ <div id="canvas" class="canvas"></div>
74
+ <aside id="inspector" class="inspector" hidden></aside>
75
+ </main>
76
+
77
+ <div class="resizer" id="chat-split" role="separator" aria-orientation="vertical"
78
+ aria-label="Resize conversation" tabindex="0" hidden></div>
79
+ <section id="chat" class="chat" hidden></section>
80
+ </div>
81
+
82
+ <footer class="foot"><span id="foot-note">starting…</span></footer>
83
+
84
+ <script type="module" src="/app.js"></script>
85
+ </body>
86
+ </html>