bom-pi 0.0.0-stage → 0.0.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 (131) hide show
  1. package/README.md +33 -2
  2. package/apps/README.md +7 -0
  3. package/apps/web/README.md +21 -0
  4. package/apps/web/pocket-adapter.mjs +302 -0
  5. package/apps/web/public/LICENSE.pocket +21 -0
  6. package/apps/web/public/activity-fold.js +28 -0
  7. package/apps/web/public/agent-now.js +37 -0
  8. package/apps/web/public/agent-tile.js +79 -0
  9. package/apps/web/public/app.js +975 -0
  10. package/apps/web/public/apple-touch-icon.png +0 -0
  11. package/apps/web/public/artifact-bridge.js +687 -0
  12. package/apps/web/public/avatar.js +87 -0
  13. package/apps/web/public/back.js +554 -0
  14. package/apps/web/public/box-keys.js +216 -0
  15. package/apps/web/public/browser-panel.js +1097 -0
  16. package/apps/web/public/browser.js +68 -0
  17. package/apps/web/public/calls.js +139 -0
  18. package/apps/web/public/chat.js +919 -0
  19. package/apps/web/public/clock.js +34 -0
  20. package/apps/web/public/commands.js +578 -0
  21. package/apps/web/public/composer.js +2068 -0
  22. package/apps/web/public/diff-parse.js +672 -0
  23. package/apps/web/public/diff.js +1646 -0
  24. package/apps/web/public/dock.js +299 -0
  25. package/apps/web/public/extension-ui.js +2734 -0
  26. package/apps/web/public/files-panel.js +775 -0
  27. package/apps/web/public/files.js +368 -0
  28. package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
  29. package/apps/web/public/fonts/Geist.woff2 +0 -0
  30. package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
  31. package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
  32. package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
  33. package/apps/web/public/fonts/InterVariable.woff2 +0 -0
  34. package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
  35. package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
  36. package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
  37. package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
  38. package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
  39. package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
  40. package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
  41. package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
  42. package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
  43. package/apps/web/public/fonts/SOURCES.md +69 -0
  44. package/apps/web/public/gestures.js +610 -0
  45. package/apps/web/public/highlight.js +480 -0
  46. package/apps/web/public/history.js +70 -0
  47. package/apps/web/public/home.js +169 -0
  48. package/apps/web/public/icon-192.png +0 -0
  49. package/apps/web/public/icon-512.png +0 -0
  50. package/apps/web/public/icon-maskable-512.png +0 -0
  51. package/apps/web/public/icon.svg +16 -0
  52. package/apps/web/public/index.html +71 -0
  53. package/apps/web/public/launcher.js +1293 -0
  54. package/apps/web/public/linked-sessions.js +55 -0
  55. package/apps/web/public/live.js +39 -0
  56. package/apps/web/public/manifest.webmanifest +32 -0
  57. package/apps/web/public/model-refresh.js +73 -0
  58. package/apps/web/public/notify.js +251 -0
  59. package/apps/web/public/peeks.js +920 -0
  60. package/apps/web/public/pip-attachments.js +182 -0
  61. package/apps/web/public/pip-window.js +141 -0
  62. package/apps/web/public/pip.js +1217 -0
  63. package/apps/web/public/quote-reply.js +109 -0
  64. package/apps/web/public/quote-text.js +31 -0
  65. package/apps/web/public/rich.js +403 -0
  66. package/apps/web/public/run-frame.js +106 -0
  67. package/apps/web/public/safe-mode.js +71 -0
  68. package/apps/web/public/seen.js +169 -0
  69. package/apps/web/public/session-tree.js +117 -0
  70. package/apps/web/public/sessions.js +2695 -0
  71. package/apps/web/public/share.js +126 -0
  72. package/apps/web/public/sheets/appearance.js +820 -0
  73. package/apps/web/public/sheets/branch.js +353 -0
  74. package/apps/web/public/sheets/extensions.js +818 -0
  75. package/apps/web/public/sheets/file.js +318 -0
  76. package/apps/web/public/sheets/find.js +250 -0
  77. package/apps/web/public/sheets/folder.js +680 -0
  78. package/apps/web/public/sheets/gallery.js +2340 -0
  79. package/apps/web/public/sheets/hooks.js +350 -0
  80. package/apps/web/public/sheets/memory.js +531 -0
  81. package/apps/web/public/sheets/menu.js +580 -0
  82. package/apps/web/public/sheets/message.js +220 -0
  83. package/apps/web/public/sheets/model.js +412 -0
  84. package/apps/web/public/sheets/people.js +414 -0
  85. package/apps/web/public/sheets/pi-sessions.js +264 -0
  86. package/apps/web/public/sheets/providers.js +525 -0
  87. package/apps/web/public/sheets/running.js +130 -0
  88. package/apps/web/public/sheets/schedules.js +71 -0
  89. package/apps/web/public/sheets/session.js +249 -0
  90. package/apps/web/public/sheets/shortcuts.js +101 -0
  91. package/apps/web/public/sheets/spend.js +150 -0
  92. package/apps/web/public/sheets/text.js +53 -0
  93. package/apps/web/public/sheets/viewers.js +363 -0
  94. package/apps/web/public/sheets/worktree.js +50 -0
  95. package/apps/web/public/sheets.js +410 -0
  96. package/apps/web/public/signin.js +57 -0
  97. package/apps/web/public/slots.js +208 -0
  98. package/apps/web/public/store.js +1760 -0
  99. package/apps/web/public/style.css +18625 -0
  100. package/apps/web/public/subagents-board.js +879 -0
  101. package/apps/web/public/subagents.js +273 -0
  102. package/apps/web/public/sw.js +169 -0
  103. package/apps/web/public/taps.js +26 -0
  104. package/apps/web/public/theme-surface.d.ts +4 -0
  105. package/apps/web/public/theme-surface.js +266 -0
  106. package/apps/web/public/theme.js +845 -0
  107. package/apps/web/public/themes/lantern/colors.toml +25 -0
  108. package/apps/web/public/themes/lantern/direction.css +60 -0
  109. package/apps/web/public/themes/lantern/frame.css +254 -0
  110. package/apps/web/public/themes/lantern/theme.css +10 -0
  111. package/apps/web/public/themes/lantern/theme.json +7 -0
  112. package/apps/web/public/themes/pocket/theme.css +189 -0
  113. package/apps/web/public/themes/pocket/theme.json +6 -0
  114. package/apps/web/public/themes.js +556 -0
  115. package/apps/web/public/transcript.js +3348 -0
  116. package/apps/web/public/trust.js +295 -0
  117. package/apps/web/public/turns.js +507 -0
  118. package/apps/web/public/ui.js +1910 -0
  119. package/apps/web/public/voice.js +345 -0
  120. package/apps/web/server.mjs +774 -0
  121. package/package.json +43 -4
  122. package/packages/cli/src/bom-pi.mjs +452 -0
  123. package/packages/cli/templates/blank.factory.json +7 -0
  124. package/packages/cli/templates/dev.factory.json +11 -0
  125. package/packages/cli/templates/docs.factory.json +12 -0
  126. package/packages/cli/templates/ops.factory.json +14 -0
  127. package/packages/cli/templates/triage.factory.json +10 -0
  128. package/packages/core/src/daemon.mjs +1962 -0
  129. package/packages/core/src/limit-gate.mjs +192 -0
  130. package/packages/core/src/rpc-client.mjs +66 -0
  131. package/systemd/README.md +11 -0
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # bom-pi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bom × pi — hệ sinh thái durable cho pi trên nền `@earendil-works/pi-durable`, lý niệm herdr:
4
+ **core daemon headless sở hữu state, clients là process riêng mỏng, thay tự do**.
5
+
6
+ ```
7
+ packages/core bom-core daemon — pi-durable Harness + JSON-RPC 2.0 NDJSON unix socket
8
+ packages/cli bom-pi — cli mỏng: hello / status / shutdown
9
+ apps/ client apps (M2 herdr-plugin, M3 web) — chưa có
10
+ docs/ BLUEPRINT.md (kiến trúc chốt) · DECISIONS.md (nhật ký quyết định)
11
+ ```
12
+
13
+ ## Chạy
14
+
15
+ Daemon đang chạy như **systemd user service** (`bom-core.service` — enabled, tự restart khi crash):
16
+
17
+ ```sh
18
+ systemctl --user status bom-core # trạng thái
19
+ journalctl --user -u bom-core -f # log
20
+ node packages/cli/src/bom-pi.mjs status
21
+ node packages/cli/src/bom-pi.mjs models # model khả dụng (account pi thật)
22
+ node packages/cli/src/bom-pi.mjs submit "..." --model zai/glm-5.3-flash
23
+ node packages/cli/src/bom-pi.mjs watch # transcript live (Ctrl+C thoát)
24
+ node packages/cli/src/bom-pi.mjs transcript
25
+ ```
26
+
27
+ Socket `$XDG_RUNTIME_DIR/bom-pi.sock` · data `~/.local/state/bom-pi/` · credentials
28
+ `~/.pi/agent/auth.json` (ModelRuntime của pi — cùng account với pi CLI).
29
+
30
+ Real-test mỗi vòng: `npm run smoke` (19 assert — boot/hello/status/models/transcript/lỗi chuẩn/
31
+ notification/null-crash-regression/double-start/shutdown/reopen/SIGTERM, dùng thư mục tạm riêng).
32
+
33
+ Milestone (M1 xong: submit/resume + events + systemd + kill-test) + bảng tra "mượn gì từ đâu":
34
+ `docs/BLUEPRINT.md`.
package/apps/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # apps/ — client apps (process riêng, không chứa state bền)
2
+
3
+ - `herdr-plugin/` (M2): manifest + scripts plugin herdr `pi.bom-bridge` — status/send/approve +
4
+ tokens roles + agent.view.set.
5
+ - `web/` (M3): bom-web minimal — transcript + approve + submit từ phone (SSE + token, tailscale).
6
+
7
+ Chưa có gì ở đây cho tới khi M1 xong (xem `../docs/BLUEPRINT.md` §4 milestone).
@@ -0,0 +1,21 @@
1
+ # bom-web — client web minimal (M3)
2
+
3
+ Transcript + submit + live events từ phone, không dependency (`node apps/web/server.mjs`).
4
+
5
+ ```sh
6
+ # local (không token, chỉ 127.0.0.1)
7
+ npm run web
8
+
9
+ # từ phone qua tailscale: đặt token
10
+ BOM_PI_TOKEN=$(head -c16 /dev/urandom | base64) npm run web
11
+ # rồi mở http://<tailscale-ip>:8791/?token=<token>
12
+ ```
13
+
14
+ | Endpoint | Làm gì |
15
+ |---|---|
16
+ | `GET /` | trang chính (model picker + input + transcript live) |
17
+ | `GET /api/hello` `/api/status` `/api/models` `/api/transcript` | proxy bom-core |
18
+ | `GET /api/events` | SSE — forward `events.update` / `events.heartbeat` |
19
+ | `POST /api/submit` `{text, model?}` | nộp turn (requestId `web-*` idempotent-ish) |
20
+
21
+ Kiến trúc: web là CLIENT mỏng — mọi state nằm ở bom-core daemon, web chết tự do.
@@ -0,0 +1,302 @@
1
+ // pocket-adapter — đưa wire contract của pi-pocket (TannerMidd, MIT) vào bom-core.
2
+ // UI nguyên bản nằm ở public/ (không sửa); file này dịch /api/* pocket → RPC bom-pi.
3
+ // Phần bom-pi chưa có tính năng: trả giá trị rỗng hợp lệ + ghi chú PORT(bom-pi) ngay tại nhánh.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { join, dirname } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import process from 'node:process';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ export const COOKIE = 'pocket_auth';
11
+
12
+ // ---- projectEntry: port từ pi-pocket src/server/projection.ts (MIT) ----
13
+ // Pi-durable EntryRecord → ClientEntry mà UI pocket render. Cùng pi-durable 1.4.0 ⇒ shape 1-1.
14
+ const clip = (text, limit = 4000) => (text.length > limit ? { text: text.slice(0, limit), clipped: true } : { text });
15
+ const textOfContent = (content) =>
16
+ typeof content === 'string' ? content
17
+ : Array.isArray(content) ? content.filter((p) => p?.type === 'text').map((p) => p.text).join('\n')
18
+ : '';
19
+ const countImages = (content) => (Array.isArray(content) ? content.filter((p) => p?.type === 'image').length : 0);
20
+ const madeAt = (m) => (typeof m?.timestamp === 'number' && m.timestamp > 0 ? { at: m.timestamp } : {});
21
+
22
+ function projectBlocks(content, full = false) {
23
+ if (!Array.isArray(content)) return [];
24
+ return content
25
+ .filter((p) => p?.type === 'text' || p?.type === 'thinking')
26
+ .map((p) => (p.type === 'thinking' ? { type: 'thinking', text: String(p.text ?? '') } : { type: 'text', text: String(p.text ?? '') }));
27
+ }
28
+
29
+ export function projectEntry(entry, full = false) {
30
+ const m = entry?.model?.[0];
31
+ const id = entry.id;
32
+ switch (entry?.kind) {
33
+ case 'pi.user':
34
+ return { id, kind: 'user', text: textOfContent(m?.content), images: countImages(m?.content), ...madeAt(m) };
35
+ case 'pi.assistant': {
36
+ const blocks = [];
37
+ if (Array.isArray(m?.content)) {
38
+ for (const p of m.content) {
39
+ if (p?.type === 'text' || p?.type === 'thinking') blocks.push(p.type === 'thinking' ? { type: 'thinking', text: String(p.text ?? '') } : { type: 'text', text: String(p.text ?? '') });
40
+ else if (p?.type === 'toolCall') blocks.push({ type: 'toolCall', id: String(p.id ?? ''), name: String(p.name ?? '?'), args: p.arguments ?? p.args ?? {} });
41
+ }
42
+ } else if (typeof m?.text === 'string' && m.text) {
43
+ blocks.push({ type: 'text', text: m.text }); // bom lightEntries: "[tool write]" fallback
44
+ }
45
+ return {
46
+ id, kind: 'assistant', blocks,
47
+ ...(typeof m?.stopReason === 'string' ? { stopReason: m.stopReason } : {}),
48
+ ...(m?.errorMessage ? { error: String(m.errorMessage).slice(0, 200) } : {}),
49
+ ...(typeof m?.model === 'string' ? { model: m.model } : {}),
50
+ ...(typeof m?.provider === 'string' ? { provider: m.provider } : {}),
51
+ ...madeAt(m),
52
+ };
53
+ }
54
+ case 'pi.tool-result': {
55
+ const { text, clipped } = full ? { text: textOfContent(m?.content), clipped: undefined } : clip(textOfContent(m?.content) || String(m?.text ?? ''));
56
+ return {
57
+ id, kind: 'toolResult', callId: String(m?.toolCallId ?? ''), name: String(m?.toolName ?? '?'), text,
58
+ isError: m?.isError === true, ...(clipped ? { clipped } : {}), ...madeAt(m),
59
+ };
60
+ }
61
+ case 'pi.compaction':
62
+ return { id, kind: 'compaction', summary: textOfContent(m?.content) };
63
+ case 'pi.reset': {
64
+ const text = textOfContent(m?.content);
65
+ return text === '' ? { id, kind: 'reset' } : { id, kind: 'reset', text };
66
+ }
67
+ default:
68
+ return undefined; // pi.system + bookkeeping: UI không vẽ
69
+ }
70
+ }
71
+
72
+ // ---- map model bom-pi → model pocket ----
73
+ function mapModel(mm) {
74
+ return {
75
+ provider: String(mm.provider ?? '?'),
76
+ id: String(mm.modelId ?? mm.id ?? '?'),
77
+ name: String(mm.name ?? mm.modelId ?? '?'),
78
+ contextWindow: Number(mm.contextWindow ?? 200000),
79
+ reasoning: mm.reasoning !== false,
80
+ images: mm.images === true,
81
+ levels: Array.isArray(mm.levels) ? mm.levels : ['off', 'low', 'medium', 'high'],
82
+ };
83
+ }
84
+
85
+ // ---- approvals bom → pocket shape {id, conversationId, tool, subject, reason, requestedBy} ----
86
+ function mapApproval(a) {
87
+ return {
88
+ id: String(a.id),
89
+ conversationId: Number(a.conversationId ?? 0),
90
+ tool: String(a.tool ?? 'call'),
91
+ subject: String(a.command ?? a.summary ?? ''), // command đầy đủ — card Review hiện nguyên lệnh
92
+ reason: String(a.reason ?? a.type ?? 'bom-pi approval gate'),
93
+ requestedBy: String(a.requestedBy ?? 'bom'),
94
+ at: Number(a.at ?? 0),
95
+ };
96
+ }
97
+
98
+ const OWNER = { id: 'bom-owner', name: 'Owner', role: 'owner' };
99
+
100
+ export function makePocketAdapter({ rpc, checkToken }) {
101
+ // me-shape: mọi thứ UI cần biết về server (chụp từ pocket 0.27.0 thật)
102
+ async function mePayload() {
103
+ const hello = await rpc('hello');
104
+ let models = [];
105
+ try { models = (await rpc('models.list')).models ?? []; } catch { /* PORT(bom-pi): models.list lỗi → danh sách rỗng */ }
106
+ let themes = [{ id: 'pocket', name: 'Pocket', description: "The app's own look: soft tiles over the colors you choose." },
107
+ { id: 'lantern', name: 'Lantern', description: 'Calm and dark for long reading.' }];
108
+ return {
109
+ user: OWNER, users: [OWNER],
110
+ models: models.map(mapModel),
111
+ guard: { available: false, enabled: false, detail: 'bom-pi: mọi tool đều qua approval gate của daemon' },
112
+ server: { pocketVersion: '0.27.0-bom', supervised: false, startedAt: 0, home: process.env.HOME ?? '/home', defaultCwd: process.env.HOME ?? '/home' },
113
+ folderFavorites: [], themes,
114
+ };
115
+ }
116
+
117
+ async function sessionsPayload() {
118
+ const list = await rpc('session.list');
119
+ const convs = list.conversations ?? [];
120
+ const appr = (await rpc('approvals.list')).pending ?? [];
121
+ // bom-pi session.list không có title/thời gian — nạp từ transcript.raw:
122
+ // title = message user đầu (đúng cách pocket), updatedAt = at entry cuối
123
+ const out = [];
124
+ for (const c of convs) {
125
+ let title = null; let updatedAt = 0;
126
+ try {
127
+ const raw = await rpc('transcript.raw', { conversationId: c.id });
128
+ const first = (raw.entries ?? []).find((e) => e.kind === 'pi.user');
129
+ const m0 = first?.model?.[0];
130
+ const text = typeof m0?.content === 'string' ? m0.content : Array.isArray(m0?.content) ? m0.content.filter((p) => p?.type === 'text').map((p) => p.text).join(' ') : '';
131
+ if (text) title = text.split('\n')[0].slice(0, 60);
132
+ for (const e of raw.entries ?? []) { const at = e.model?.[0]?.timestamp; if (typeof at === 'number' && at > updatedAt) updatedAt = at; }
133
+ } catch { /* conversation đã đổi trạng thái — bỏ qua metadata */ }
134
+ out.push({
135
+ id: c.id,
136
+ cwd: c.cwd ?? '',
137
+ createdAt: updatedAt,
138
+ updatedAt,
139
+ createdBy: OWNER.id,
140
+ busy: false, // PORT(bom-pi): session.list chưa báo busy — làm mới qua events.update
141
+ waiting: appr.some((a) => Number(a.conversationId) === Number(c.id)),
142
+ model: c.model?.modelId ?? 'glm-5.3',
143
+ title,
144
+ });
145
+ }
146
+ return out;
147
+ }
148
+
149
+ // view: transcript + trạng thái một conversation (shape chụp từ pocket)
150
+ async function viewPayload(convId) {
151
+ const raw = await rpc('transcript.raw', { conversationId: convId });
152
+ const entries = (raw.entries ?? []).map((e) => projectEntry(e)).filter(Boolean);
153
+ const appr = ((await rpc('approvals.list')).pending ?? []).filter((a) => Number(a.conversationId) === Number(convId));
154
+ let conv = null;
155
+ try { conv = (await rpc('session.list')).conversations.find((c) => Number(c.id) === Number(convId)) ?? null; } catch { conv = null; }
156
+ return {
157
+ full: true,
158
+ conversation: { id: Number(convId), kind: 'session', title: conv?.title ?? `session ${convId}`, cwd: conv?.cwd ?? '', archived: false },
159
+ entries,
160
+ order: entries.map((e) => e.id),
161
+ live: { busy: conv?.busy === true || conv?.running === true },
162
+ agent: { model: { provider: conv?.model?.provider ?? 'zai', modelId: conv?.model?.modelId ?? 'glm-5.3' }, thinkingLevel: 'high', cwd: conv?.cwd ?? '', available: true, modelName: conv?.model?.modelId ?? 'GLM-5.3', contextWindow: 1000000, reasoning: true, images: false, levels: ['low', 'high', 'max'] },
163
+ stats: { cost: 0 },
164
+ clients: 1, viewers: ['Owner'],
165
+ approvals: appr.map(mapApproval),
166
+ inbox: [], artifacts: [], subagents: [], subagentsHeld: null,
167
+ authors: {}, reactions: {}, pins: [], turns: { on: false, asks: [] }, decisions: {},
168
+ plan: { on: false }, schedules: [], branch: null, goal: null, watch: null,
169
+ };
170
+ }
171
+
172
+ const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }); res.end(JSON.stringify(obj)); };
173
+ const readBody = (req) => new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => { try { done(JSON.parse(b || '{}')); } catch { done({}); } }); });
174
+
175
+ /**
176
+ * Mount pocket wire. Trả true nếu đã xử lý request.
177
+ * Auth: cookie pocket_auth (sign-in qua /login?token=) hoặc Authorization Bearer (device/master bom).
178
+ */
179
+ return async function pocketRoute(req, res, url, authed) {
180
+ const path = url.pathname.replace(/^\/api\//, '');
181
+ const parts = path.split('/').filter(Boolean);
182
+ const [first, second, third] = parts;
183
+ const method = req.method;
184
+ const isPost = method !== 'GET' && method !== 'HEAD';
185
+
186
+ if (isPost && req.headers['x-pocket'] !== '1') return false; // POST pocket luôn có X-Pocket (api.ts l365)
187
+ if (!['me', 'login', 'logout', 'sessions', 'c', 'providers', 'themes', 'theme', 'events', 'poll',
188
+ 'users', 'hooks', 'subagents', 'pip', 'extensions', 'extension-settings', 'fs', 'visibility', 'peeks',
189
+ 'folder-favorites', 'spend', 'pi-sessions', 'x', 'repo-watches', 'approvals'].includes(first)) return false;
190
+
191
+ try {
192
+ // ---- sign-in/lifecycle ----
193
+ if (first === 'me' && method === 'GET') return json(res, 200, await mePayload()), true;
194
+ if (first === 'logout' && isPost) {
195
+ res.writeHead(200, { 'set-cookie': `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, 'content-type': 'application/json' });
196
+ return res.end('{}'), true;
197
+ }
198
+
199
+ // ---- sessions ----
200
+ if (first === 'sessions' && second === undefined && method === 'GET') return json(res, 200, await sessionsPayload()), true;
201
+ if (first === 'sessions' && second === undefined && isPost) {
202
+ const body = await readBody(req);
203
+ const r = await rpc('session.new', { cwd: body.cwd });
204
+ return json(res, 200, { id: r.conversationId ?? r.id }), true;
205
+ }
206
+ if (first === 'sessions' && second !== undefined && isPost) {
207
+ await readBody(req);
208
+ return json(res, 200, { ok: true }), true; // PORT(bom-pi): rename/archive session — daemon chưa lưu, chấp nhận im lặng
209
+ }
210
+
211
+ // ---- conversation ----
212
+ if (first === 'c' && second !== undefined) {
213
+ const id = Number(second);
214
+ if (third === 'submit' && isPost) {
215
+ const body = await readBody(req);
216
+ const r = await rpc('session.submit', { conversationId: id, text: body.text, ...(body.requestId ? { requestId: String(body.requestId) } : {}) });
217
+ return json(res, 200, { submissionId: r.submissionId ?? 0, requestId: String(body.requestId ?? '') }), true;
218
+ }
219
+ if (third === 'configure' && isPost) {
220
+ const body = await readBody(req);
221
+ // pocket gửi {model: {provider, id, level?}} → bom session.configure {model:{provider,modelId}}
222
+ const model = body.model ? { provider: body.model.provider, modelId: body.model.id ?? body.model.modelId } : undefined;
223
+ return json(res, 200, await rpc('session.configure', { conversationId: id, ...(model ? { model } : {}) })), true;
224
+ }
225
+ if (third === 'abort' && isPost) return json(res, 200, await rpc('session.abort', { conversationId: id })), true;
226
+ if (third === 'reset' && isPost) { await readBody(req); return json(res, 200, await rpc('session.reset', { conversationId: id })), true; }
227
+ if (third === 'history' && method === 'GET') {
228
+ const raw = await rpc('transcript.raw', { conversationId: id });
229
+ // pocket history: OLDEST FIRST (transcripts.ts out.reverse()) — transcript.raw đã oldest-first
230
+ return json(res, 200, (raw.entries ?? []).map((e) => projectEntry(e)).filter(Boolean)), true;
231
+ }
232
+ if ((third === 'context' || third === 'messages') && method === 'GET') {
233
+ const raw = await rpc('transcript.raw', { conversationId: id });
234
+ const entries = (raw.entries ?? []).map((e) => projectEntry(e)).filter(Boolean);
235
+ return json(res, 200, { entries, last: entries.at(-1)?.id ?? 0 }), true;
236
+ }
237
+ if (third === 'entry' && method === 'GET') {
238
+ const raw = await rpc('transcript.raw', { conversationId: id });
239
+ const e = (raw.entries ?? []).find((x) => Number(x.id) === Number(parts[3]));
240
+ if (e) { json(res, 200, projectEntry(e, true)); return true; }
241
+ json(res, 404, { error: 'no entry' });
242
+ return true;
243
+ }
244
+ if (third === 'turns' && isPost) return json(res, 200, { ok: true }), true; // PORT(bom-pi): multiplayer turns — chưa có
245
+ if (third === 'typing' && isPost) return json(res, 200, { ok: true }), true; // PORT(bom-pi): presence
246
+ if (isPost) { await readBody(req); return json(res, 200, { ok: true }), true; } // PORT(bom-pi): plan/chat/pin/notes/fork/... — noop
247
+ return json(res, 200, {}), true;
248
+ }
249
+
250
+ // ---- approvals ----
251
+ if (first === 'approvals' && second !== undefined && isPost) {
252
+ const body = await readBody(req);
253
+ await rpc('approvals.answer', { id: parts.slice(1).join('/'), allow: body.allow === true, by: 'web' });
254
+ return json(res, 200, true), true;
255
+ }
256
+
257
+ // ---- providers/themes ----
258
+ if (first === 'providers' && method === 'GET') {
259
+ const models = ((await rpc('models.list')).models ?? []).map(mapModel);
260
+ return json(res, 200, { providers: [{ name: 'bom-pi', models, signedIn: true }] }), true;
261
+ }
262
+ if (first === 'theme' && method === 'GET') return json(res, 200, { theme: 'pocket', defaultTheme: null }), true;
263
+ if (first === 'themes' && method === 'GET') return json(res, 200, (await mePayload()).themes), true;
264
+ if (first === 'themes' && isPost) return json(res, 200, { ok: true }), true; // PORT(bom-pi): duplicate/edit theme folders
265
+
266
+ // ---- events (SSE) ----
267
+ if (first === 'events' && method === 'GET') {
268
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
269
+ const send = (name, data) => res.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
270
+ const cid = Number(url.searchParams.get('c'));
271
+ const push = async (why) => {
272
+ try {
273
+ send('hello', await mePayload());
274
+ send('sessions', await sessionsPayload());
275
+ if (Number.isInteger(cid) && cid > 0) send('view', await viewPayload(cid));
276
+ } catch (e) { send('notice', { level: 'error', message: String(e?.message ?? e).slice(0, 120) }); }
277
+ };
278
+ await push('open');
279
+ // bom-pi events.update (SSE legacy) làm tín hiệu làm mới — pull-on-push
280
+ const conn = await rpc.__subscribeEvents(cid, () => push('update'));
281
+ const ping = setInterval(() => res.write(': ping\n\n'), 20000);
282
+ req.on('close', () => { clearInterval(ping); conn.close(); });
283
+ return true;
284
+ }
285
+
286
+ // ---- stub rỗng: tính năng pocket chưa có ở bom-pi ----
287
+ if (first === 'users' && method === 'GET') return json(res, 200, [OWNER]), true;
288
+ if (first === 'subagents' && method === 'GET') return json(res, 200, []), true;
289
+ if (first === 'hooks' && method === 'GET') return json(res, 200, []), true;
290
+ if (first === 'folder-favorites' && method === 'GET') return json(res, 200, []), true;
291
+ if (first === 'pi-sessions' && method === 'GET') return json(res, 200, []), true;
292
+ if (first === 'fs') return json(res, 200, { entries: [] }), true; // PORT(bom-pi): file tree
293
+ if (first === 'spend' && method === 'GET') return json(res, 200, { people: [], sessions: [] }), true;
294
+ if (isPost || method === 'PUT') { await readBody(req); return json(res, 200, { ok: true }), true; }
295
+ return json(res, 200, method === 'GET' ? [] : {}), true;
296
+ } catch (e) {
297
+ return json(res, e?.status ?? 500, { error: String(e?.message ?? e).replace(/^\d+: /, '') }), true;
298
+ }
299
+ };
300
+ }
301
+
302
+ const fourth = (parts) => parts[3];
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tanner Middleton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ // Whether an activity group in the conversation shows its timeline. Appearance's Keep activity folded keeps every group
2
+ // on its one line until someone opens it, also while it runs or sits in the newest turn; off, the newest turn's groups
3
+ // open as they come. A jump to one of a group's answers opens it as a click does, and a thought alone in its group keeps
4
+ // its own rule (its words follow Show thinking). No DOM here, so `test/activity-fold.test.ts` can check it.
5
+
6
+ /**
7
+ * Whether a group opens, from what it is and what was chosen for it.
8
+ *
9
+ * `chosen` is a click on its line, or a jump to one of its answers (`pocket:reveal`): true opened it, false closed it,
10
+ * undefined leaves it to the rules below. `lone` is the group's only step when that step is a thought, else null.
11
+ * `fold` is Appearance's Keep activity folded.
12
+ */
13
+ export function activityOpen({ chosen, lone, live, newest, reading, fold }) {
14
+ // A thought whose words have not come yet (a session opening) stays folded until they have.
15
+ if (lone?.partial === true) {
16
+ return false;
17
+ }
18
+
19
+ if (chosen !== undefined) {
20
+ return chosen;
21
+ }
22
+
23
+ if (lone) {
24
+ return reading === true && lone.streaming !== true && Boolean(lone.text?.trim());
25
+ }
26
+
27
+ return fold === true ? false : live === true || newest === true;
28
+ }
@@ -0,0 +1,37 @@
1
+ // What a working subagent does this moment, from its peek: the call it waits on, the call it runs, or its words. The
2
+ // subagents bar and the peek tiles both say it, for a row each.
3
+ import { describeCall } from "./calls.js";
4
+ import { store } from "./store.js";
5
+
6
+ /** What a working subagent does this moment: its peek's waiting call, running call, or words. */
7
+ export function nowDoing(agent, peek = store.state.peeks?.[agent.conversationId]) {
8
+ // Its request waits for its provider's limit to clear: that is what it does, whatever its peek last showed.
9
+ if (agent.limited !== undefined) {
10
+ return "Waiting for a usage limit to clear";
11
+ }
12
+
13
+ const waiting = peek?.approvals?.[0];
14
+
15
+ if (waiting) {
16
+ return `Waiting for approval: ${waiting.tool} ${waiting.subject}`.trim();
17
+ }
18
+
19
+ const last = (peek?.lines ?? []).at(-1);
20
+
21
+ if (last?.kind === "tool" && last.status === "running") {
22
+ const call = describeCall({ name: last.name, args: last.args });
23
+
24
+ return [call.icon, call.label, call.subject].filter(Boolean).join(" ");
25
+ }
26
+
27
+ if (last?.kind === "shell") {
28
+ return `$ ${last.command}`;
29
+ }
30
+
31
+ if (last?.kind === "text") {
32
+ return last.text;
33
+ }
34
+
35
+ // Before its peek arrives, nothing is known but that it works.
36
+ return peek === undefined ? "Working…" : "Thinking…";
37
+ }
@@ -0,0 +1,79 @@
1
+ // One subagent at work, as a sub-tile: the state square in its session's folder color, its name and state, and what it
2
+ // does this moment, from its own peek (or, until that arrives, what it was asked). Peek tiles nest these in a session's
3
+ // tile; the subagents board shows them under a session's row.
4
+ import { useRef } from "preact/hooks";
5
+ import { nowDoing } from "./agent-now.js";
6
+ import { store } from "./store.js";
7
+ import { html } from "./ui.js";
8
+
9
+ /** Two clicks (or taps) this close together open the subagent, where `onOpen` is given: the board's. */
10
+ const DOUBLE_MS = 350;
11
+
12
+ /** Whether it waits for an approval: the board knows at once; a peek tile, from the subagent's own peek. */
13
+ function waitingOf(agent, peek) {
14
+ return agent.waiting === true || (peek?.approvals?.length ?? 0) > 0;
15
+ }
16
+
17
+ /**
18
+ * What it does now; before its own peek arrives, what it was asked says more than "Working…". A call it waits on is
19
+ * the call alone: the tile says it needs approval.
20
+ */
21
+ function doingOf(agent, peek) {
22
+ const approval = agent.waiting === true ? agent.approval : peek?.approvals?.[0];
23
+
24
+ if (approval !== undefined) {
25
+ return `${approval.tool} ${approval.subject}`.trim();
26
+ }
27
+
28
+ const doing = nowDoing(agent, peek);
29
+
30
+ return peek === undefined && agent.asked !== undefined ? `Working on: ${agent.asked}` : doing;
31
+ }
32
+
33
+ /**
34
+ * A subagent's sub-tile. `folder` is its session's folder color; `selected` marks it as the one the board shows.
35
+ * `onActivate` takes a click (a peek tile opens it; the board picks it), and `onOpen` a double click or tap.
36
+ */
37
+ export function AgentTile({ agent, folder, selected = false, onActivate, onOpen }) {
38
+ const peek = agent.peek ?? store.state.peeks?.[agent.conversationId];
39
+ const waiting = waitingOf(agent, peek);
40
+ const state = waiting ? "waiting" : "working";
41
+ const doing = doingOf(agent, peek);
42
+ const last = useRef(0);
43
+
44
+ // A double click, or a double tap: the first picks it, the second opens it. No native dblclick, which a double
45
+ // tap does not always make.
46
+ const activate = () => {
47
+ const now = Date.now();
48
+
49
+ if (onOpen !== undefined && now - last.current < DOUBLE_MS) {
50
+ last.current = 0;
51
+ onOpen();
52
+
53
+ return;
54
+ }
55
+
56
+ last.current = now;
57
+ onActivate();
58
+ };
59
+
60
+ return html`<button
61
+ class=${`agent-tile ${state} ${selected ? "on" : ""}`}
62
+ data-peek=${agent.conversationId}
63
+ data-peek-kind="agent"
64
+ title=${`${agent.name}: ${doing}`}
65
+ aria-label=${`${agent.name}, ${waiting ? "needs approval" : "working"}: ${doing}`}
66
+ onClick=${activate}
67
+ >
68
+ <span class="agent-tile-head">
69
+ <span
70
+ class=${`state-mark ${state}`}
71
+ style=${`--folder:${waiting ? "var(--warn)" : (folder ?? "var(--muted)")}`}
72
+ aria-hidden="true"
73
+ ></span>
74
+ <span class="agent-tile-name">${agent.name}</span>
75
+ <span class="agent-tile-state">${waiting ? "needs approval" : "working"}</span>
76
+ </span>
77
+ <span class="agent-tile-now">${doing}</span>
78
+ </button>`;
79
+ }