claude-code-session-manager 0.55.0 → 0.56.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.
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * bilkoHost.cjs — Stage A (deterministic bundle prep) for the "Host on
5
+ * Bilko.run" tab. See session-manager-operations/architecture/
6
+ * bilko-host-integration.md for the full design.
7
+ *
8
+ * Model: ONE Bilko project (slug) hosts a LIST of documents — a root
9
+ * document (subpath '', dist/index.html — "the project itself") plus any
10
+ * number of sub-path documents (subpath 'special-doc/01',
11
+ * dist/special-doc/01/index.html → bilko.run/projects/<slug>/special-doc/01/).
12
+ * `documents.json` is the list; `dist/` is always rebuilt wholesale from it
13
+ * (rm + rewrite) so it can never drift from the list — the same "full
14
+ * replace" semantics Bilko's own publish_static_project already uses, which
15
+ * is exactly what makes local deletion reliably propagate live (see
16
+ * bilkoHostCore.cjs's header comment).
17
+ *
18
+ * This module owns `session-manager-operations/bilko-host/documents.json` +
19
+ * `dist/{**,manifest.json}` (writer id `bilko-host`, OWNERS-enforced via
20
+ * config.cjs's write helpers). It never talks to the bilko-host MCP itself
21
+ * and never writes publish-state.json — that's Stage B, run by the
22
+ * bilko-host-publisher Epic's own session with its own Write tool
23
+ * (agent-authored artifact, same as project-pages/output/*.html; not
24
+ * enforceable by OWNERS). Directory removal (rm -rf of dist/ before a
25
+ * rebuild) uses raw fs directly, same precedent as epicMint.cjs/
26
+ * queueStore.cjs using raw fs for their own owned namespace — this module
27
+ * IS the declared owner of `bilko-host`, so there is no foreign-writer risk
28
+ * to guard against here.
29
+ */
30
+
31
+ const { ipcMain } = require('electron');
32
+ const fsp = require('node:fs/promises');
33
+ const path = require('node:path');
34
+ const crypto = require('node:crypto');
35
+ const { execFileSync } = require('node:child_process');
36
+ const { gzipSync } = require('node:zlib');
37
+ const config = require('./config.cjs');
38
+ const {
39
+ validateSubpath,
40
+ validateDocumentSet,
41
+ documentDistRelPath,
42
+ documentUrl,
43
+ } = require('./bilkoHostCore.cjs');
44
+
45
+ const WRITER = 'bilko-host';
46
+ const PROJECT_PAGE_LENSES = new Set(['home', 'marketing', 'feature', 'architecture']);
47
+
48
+ function bilkoHostDir(cwd) {
49
+ return path.join(cwd, 'session-manager-operations', 'bilko-host');
50
+ }
51
+
52
+ function distDir(cwd) {
53
+ return path.join(bilkoHostDir(cwd), 'dist');
54
+ }
55
+
56
+ function documentsPath(cwd) {
57
+ return path.join(bilkoHostDir(cwd), 'documents.json');
58
+ }
59
+
60
+ /** Kebab-case a project name into a slug candidate. Pure. */
61
+ function deriveSlug(projectDirName) {
62
+ return String(projectDirName)
63
+ .trim()
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9]+/g, '-')
66
+ .replace(/^-+|-+$/g, '')
67
+ .slice(0, 64) || 'project';
68
+ }
69
+
70
+ function gitInfo(cwd) {
71
+ const run = (args, fallback) => {
72
+ try {
73
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
74
+ } catch {
75
+ return fallback;
76
+ }
77
+ };
78
+ return {
79
+ gitSha: run(['rev-parse', '--short', 'HEAD'], '0000000'),
80
+ gitBranch: run(['rev-parse', '--abbrev-ref', 'HEAD'], 'main'),
81
+ };
82
+ }
83
+
84
+ async function readDocuments(cwd) {
85
+ const result = await config.readJson(documentsPath(cwd));
86
+ if (result.exists && !result.parseError && Array.isArray(result.data?.documents)) {
87
+ return result.data.documents;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ async function writeDocuments(cwd, documents) {
93
+ await config.writeJson(documentsPath(cwd), { documents }, { writer: WRITER });
94
+ }
95
+
96
+ /**
97
+ * Reads everything the tab needs to render its status card: whether a
98
+ * Project Page exists to seed a root document from, the project's
99
+ * package.json (for the compatibility gate + default slug/version), the
100
+ * current document list, and any bundle/publish state already on disk.
101
+ */
102
+ async function get({ cwd }) {
103
+ const realCwd = config.validatePath(cwd);
104
+
105
+ const marketingResult = await config.readText(
106
+ path.join(realCwd, 'session-manager-operations', 'project-pages', 'output', 'marketing.html'),
107
+ );
108
+ const pkgResult = await config.readJson(path.join(realCwd, 'package.json'));
109
+ const pkg = pkgResult.exists && !pkgResult.parseError ? pkgResult.data : null;
110
+
111
+ const documents = (await readDocuments(realCwd)) ?? [];
112
+
113
+ const bundleManifestResult = await config.readJson(path.join(distDir(realCwd), 'manifest.json'));
114
+ const bundleManifest = bundleManifestResult.exists && !bundleManifestResult.parseError
115
+ ? bundleManifestResult.data
116
+ : null;
117
+
118
+ const publishStateResult = await config.readJson(path.join(bilkoHostDir(realCwd), 'publish-state.json'));
119
+ const publishState = publishStateResult.exists && !publishStateResult.parseError
120
+ ? publishStateResult.data
121
+ : null;
122
+
123
+ const slug = bundleManifest?.slug
124
+ ?? deriveSlug(pkg && typeof pkg.name === 'string' ? pkg.name : path.basename(realCwd));
125
+
126
+ return {
127
+ hasMarketingPage: marketingResult.exists,
128
+ projectName: pkg && typeof pkg.name === 'string' ? pkg.name : path.basename(realCwd),
129
+ packagePrivate: pkg ? pkg.private === true : false,
130
+ packageHomepage: pkg && typeof pkg.homepage === 'string' ? pkg.homepage : null,
131
+ packageVersion: pkg && typeof pkg.version === 'string' ? pkg.version : '0.0.0',
132
+ defaultSlug: deriveSlug(pkg && typeof pkg.name === 'string' ? pkg.name : path.basename(realCwd)),
133
+ documents: documents.map((d) => ({ ...d, url: documentUrl(slug, d.subpath) })),
134
+ // A rebuild is owed whenever the bundle on disk doesn't reflect the
135
+ // current document list — the tab uses this to gray out "up to date"
136
+ // vs. prompt "Prepare Bundle" again, and (post-publish) to warn that a
137
+ // removed document is still live until the next Publish.
138
+ bundleStale: bundleManifest ? bundleManifest.documentCount !== documents.length : documents.length > 0,
139
+ bundleManifest,
140
+ publishState,
141
+ };
142
+ }
143
+
144
+ /** Seeds documents.json with a single root document (the Marketing Project Page) if it doesn't exist yet. */
145
+ async function ensureSeededDocuments(cwd) {
146
+ const existing = await readDocuments(cwd);
147
+ if (existing) return existing;
148
+ const seeded = [{
149
+ id: crypto.randomUUID(),
150
+ subpath: '',
151
+ title: 'Project (Marketing)',
152
+ source: { kind: 'project-page-lens', lens: 'marketing' },
153
+ addedAt: new Date().toISOString(),
154
+ }];
155
+ await writeDocuments(cwd, seeded);
156
+ return seeded;
157
+ }
158
+
159
+ /**
160
+ * Add a hosted document. `source` is either
161
+ * `{ kind: 'project-page-lens', lens: 'home'|'marketing'|'feature'|'architecture' }`
162
+ * (reuses an already-generated Project Page lens) or
163
+ * `{ kind: 'file', path: <repo-relative path> }` (any other local HTML —
164
+ * e.g. a HUMAN_LEARN page or a hand-authored explainer).
165
+ */
166
+ async function addDocument({ cwd, subpath, title, source }) {
167
+ const realCwd = config.validatePath(cwd);
168
+ const subpathCheck = validateSubpath(subpath);
169
+ if (!subpathCheck.ok) throw new Error(subpathCheck.error);
170
+ if (source.kind === 'project-page-lens' && !PROJECT_PAGE_LENSES.has(source.lens)) {
171
+ throw new Error(`unknown Project Page lens: ${source.lens}`);
172
+ }
173
+
174
+ const documents = (await readDocuments(realCwd)) ?? await ensureSeededDocuments(realCwd);
175
+ if (subpath === '') {
176
+ throw new Error('the root document (subpath "") always exists — edit it instead of adding a second one');
177
+ }
178
+ if (documents.some((d) => d.subpath === subpath)) {
179
+ throw new Error(`a document already uses subpath "${subpath}"`);
180
+ }
181
+
182
+ const next = [...documents, {
183
+ id: crypto.randomUUID(),
184
+ subpath,
185
+ title,
186
+ source,
187
+ addedAt: new Date().toISOString(),
188
+ }];
189
+ const setCheck = validateDocumentSet(next);
190
+ if (!setCheck.ok) throw new Error(setCheck.error);
191
+
192
+ await writeDocuments(realCwd, next);
193
+ return { documents: next };
194
+ }
195
+
196
+ /**
197
+ * Remove a hosted document (never the root). This only updates
198
+ * documents.json — the file stays in `dist/` (and, if already published,
199
+ * live on bilko.run) until the next Prepare Bundle + Publish, which is the
200
+ * confirmed mechanism that actually removes it (Bilko's publish tool
201
+ * rm -rf's + rebuilds the whole slug directory every publish).
202
+ */
203
+ async function removeDocument({ cwd, id }) {
204
+ const realCwd = config.validatePath(cwd);
205
+ const documents = (await readDocuments(realCwd)) ?? [];
206
+ const target = documents.find((d) => d.id === id);
207
+ if (!target) throw new Error(`no document with id ${id}`);
208
+ if (target.subpath === '') throw new Error('cannot remove the root document');
209
+
210
+ const next = documents.filter((d) => d.id !== id);
211
+ await writeDocuments(realCwd, next);
212
+ return { documents: next };
213
+ }
214
+
215
+ async function resolveDocumentHtml(cwd, doc) {
216
+ if (doc.source.kind === 'project-page-lens') {
217
+ const result = await config.readText(
218
+ path.join(cwd, 'session-manager-operations', 'project-pages', 'output', `${doc.source.lens}.html`),
219
+ );
220
+ if (!result.exists) {
221
+ throw new Error(`document "${doc.title}" points at the ${doc.source.lens} Project Page, but it hasn't been generated yet`);
222
+ }
223
+ return result.text;
224
+ }
225
+ if (doc.source.kind === 'file') {
226
+ const abs = config.validatePath(path.join(cwd, doc.source.path));
227
+ const result = await config.readText(abs);
228
+ if (!result.exists) throw new Error(`document "${doc.title}"'s source file is missing: ${doc.source.path}`);
229
+ return result.text;
230
+ }
231
+ throw new Error(`unknown document source kind: ${doc.source.kind}`);
232
+ }
233
+
234
+ /**
235
+ * Stage A: rebuild the ENTIRE dist/ tree from documents.json (rm + rewrite,
236
+ * mirroring Bilko's own full-replace publish semantics so local state can
237
+ * never lag the document list) + dist/manifest.json (host-contract manifest
238
+ * schema, one per bundle — golden.path/expect always point at the root
239
+ * document). Idempotent.
240
+ */
241
+ async function prepareBundle({ cwd, slug }) {
242
+ const realCwd = config.validatePath(cwd);
243
+ const documents = await ensureSeededDocuments(realCwd);
244
+
245
+ const setCheck = validateDocumentSet(documents);
246
+ if (!setCheck.ok) throw new Error(setCheck.error);
247
+
248
+ const pkgResult = await config.readJson(path.join(realCwd, 'package.json'));
249
+ const pkg = pkgResult.exists && !pkgResult.parseError ? pkgResult.data : {};
250
+ const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
251
+
252
+ const dist = distDir(realCwd);
253
+ await fsp.rm(dist, { recursive: true, force: true });
254
+
255
+ let totalGz = 0;
256
+ for (const doc of documents) {
257
+ const html = await resolveDocumentHtml(realCwd, doc);
258
+ const relPath = documentDistRelPath(doc.subpath);
259
+ await config.writeTextAtomic(path.join(dist, relPath), html, { writer: WRITER });
260
+ totalGz += gzipSync(Buffer.from(html, 'utf8')).length;
261
+ }
262
+
263
+ const { gitSha, gitBranch } = gitInfo(realCwd);
264
+ const goldenExpect = pkg && typeof pkg.name === 'string' ? pkg.name : slug;
265
+
266
+ const manifest = {
267
+ schemaVersion: 1,
268
+ slug,
269
+ version,
270
+ builtAt: new Date().toISOString(),
271
+ gitSha,
272
+ gitBranch,
273
+ hostKit: { version: '0.0.0' },
274
+ golden: { path: `/projects/${slug}/`, expect: goldenExpect },
275
+ health: {},
276
+ bundle: { sizeBytesGz: totalGz, fileCount: documents.length },
277
+ // Not part of the host-contract schema itself — this app's own staleness
278
+ // check (get()'s bundleStale) compares this against documents.length.
279
+ documentCount: documents.length,
280
+ documents: documents.map((d) => ({ subpath: d.subpath, title: d.title })),
281
+ };
282
+
283
+ await config.writeJson(path.join(dist, 'manifest.json'), manifest, { writer: WRITER });
284
+
285
+ return { distPath: dist, manifest };
286
+ }
287
+
288
+ function registerBilkoHostIpc() {
289
+ const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
290
+ ipcMain.handle('bilko-host:get', v(s.bilkoHostCwd, get));
291
+ ipcMain.handle('bilko-host:prepare-bundle', v(s.bilkoHostPrepareBundle, prepareBundle));
292
+ ipcMain.handle('bilko-host:add-document', v(s.bilkoHostAddDocument, addDocument));
293
+ ipcMain.handle('bilko-host:remove-document', v(s.bilkoHostRemoveDocument, removeDocument));
294
+ }
295
+
296
+ module.exports = {
297
+ registerBilkoHostIpc,
298
+ get,
299
+ prepareBundle,
300
+ addDocument,
301
+ removeDocument,
302
+ deriveSlug,
303
+ bilkoHostDir,
304
+ distDir,
305
+ };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * bilkoHostCore.cjs — pure logic for the "Host on Bilko.run" tab's
5
+ * multi-document bundle model. No fs/IPC here (that's bilkoHost.cjs) so
6
+ * this stays unit-testable without a real project directory.
7
+ *
8
+ * Model: ONE Bilko project (one registry slug, one `dist/` tree) can host
9
+ * MANY documents — exactly one root document (subpath `''`, becomes
10
+ * dist/index.html — "the project itself") plus any number of sub-path
11
+ * documents (subpath 'special-doc/01', becomes dist/special-doc/01/
12
+ * index.html → bilko.run/projects/<slug>/special-doc/01/). This mirrors how
13
+ * Bilko's own publish_static_project already works: it `rm -rf`s then
14
+ * `cp -r`s the WHOLE public/projects/<slug>/ tree on every publish, so
15
+ * removing a document locally and re-publishing is guaranteed to remove it
16
+ * live too — no separate per-document delete API needed on Bilko's side.
17
+ */
18
+
19
+ const SUBPATH_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
20
+ const MAX_SEGMENTS = 6;
21
+ const MAX_SUBPATH_LENGTH = 200;
22
+
23
+ /**
24
+ * Validates a document's subpath. `''` (root) is always valid. Otherwise
25
+ * every '/'-separated segment must be lowercase kebab-case, no '.'/'..',
26
+ * no leading/trailing/double slashes, bounded depth and length.
27
+ */
28
+ function validateSubpath(subpath) {
29
+ if (typeof subpath !== 'string') return { ok: false, error: 'subpath must be a string' };
30
+ if (subpath === '') return { ok: true, error: null };
31
+ if (subpath.length > MAX_SUBPATH_LENGTH) return { ok: false, error: 'subpath is too long' };
32
+ if (subpath.startsWith('/') || subpath.endsWith('/')) {
33
+ return { ok: false, error: 'subpath must not start or end with /' };
34
+ }
35
+ const segments = subpath.split('/');
36
+ if (segments.length > MAX_SEGMENTS) return { ok: false, error: `subpath has more than ${MAX_SEGMENTS} segments` };
37
+ for (const seg of segments) {
38
+ if (!SUBPATH_SEGMENT.test(seg)) {
39
+ return { ok: false, error: `invalid subpath segment "${seg}" — use lowercase kebab-case` };
40
+ }
41
+ }
42
+ return { ok: true, error: null };
43
+ }
44
+
45
+ /**
46
+ * Validates a full document list: subpaths individually valid, exactly one
47
+ * root document, no duplicate subpaths. Pure — takes the array shape
48
+ * documents.json stores, `[{ id, subpath, ... }]`.
49
+ */
50
+ function validateDocumentSet(documents) {
51
+ if (!Array.isArray(documents) || documents.length === 0) {
52
+ return { ok: false, error: 'at least one document (the root) is required' };
53
+ }
54
+ const seen = new Set();
55
+ let rootCount = 0;
56
+ for (const doc of documents) {
57
+ const v = validateSubpath(doc.subpath);
58
+ if (!v.ok) return { ok: false, error: `document "${doc.id}": ${v.error}` };
59
+ if (seen.has(doc.subpath)) return { ok: false, error: `duplicate subpath "${doc.subpath}"` };
60
+ seen.add(doc.subpath);
61
+ if (doc.subpath === '') rootCount += 1;
62
+ }
63
+ if (rootCount !== 1) return { ok: false, error: 'exactly one document must have subpath "" (the project root)' };
64
+ return { ok: true, error: null };
65
+ }
66
+
67
+ /** Where a document's rendered HTML lands inside dist/. Pure path math. */
68
+ function documentDistRelPath(subpath) {
69
+ return subpath === '' ? 'index.html' : `${subpath}/index.html`;
70
+ }
71
+
72
+ /**
73
+ * The bilko.run URL a document resolves to once published, given the
74
+ * project's slug. Pure — used by the tab to render "will be live at" links
75
+ * before anything is actually published.
76
+ */
77
+ function documentUrl(slug, subpath) {
78
+ return subpath === '' ? `https://bilko.run/projects/${slug}/` : `https://bilko.run/projects/${slug}/${subpath}/`;
79
+ }
80
+
81
+ module.exports = {
82
+ SUBPATH_SEGMENT,
83
+ MAX_SEGMENTS,
84
+ MAX_SUBPATH_LENGTH,
85
+ validateSubpath,
86
+ validateDocumentSet,
87
+ documentDistRelPath,
88
+ documentUrl,
89
+ };
@@ -170,6 +170,14 @@ function validateWrite(realAbs) {
170
170
  if (realAbs === briefSub || realAbs.startsWith(briefSub + path.sep)) {
171
171
  return;
172
172
  }
173
+ // Host on Bilko.run bundle prep (bilkoHost.cjs's prepareBundle/
174
+ // addDocument/removeDocument): narrowly scoped to
175
+ // session-manager-operations/bilko-host/, this repo's existing
176
+ // per-project artifact-store convention.
177
+ const bilkoHostSub = path.join(realRoot, 'session-manager-operations', 'bilko-host');
178
+ if (realAbs === bilkoHostSub || realAbs.startsWith(bilkoHostSub + path.sep)) {
179
+ return;
180
+ }
173
181
  }
174
182
  }
175
183
  throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
@@ -33,6 +33,7 @@ const chatRunner = require('./chatRunner.cjs');
33
33
  const promptSessionEvents = require('./promptSessionEvents.cjs');
34
34
  const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
35
35
  const agentLibrary = require('./agentLibrary.cjs');
36
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
36
37
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
37
38
  const adminHttp = createAdminHttp();
38
39
  scheduler.registerAdminRoutes(adminHttp);
@@ -61,6 +62,7 @@ const memoryTool = require('./memoryTool.cjs');
61
62
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
62
63
  const { registerProjectBriefIpc } = require('./projectBrief.cjs');
63
64
  const { registerProjectPagesIpc } = require('./projectPages.cjs');
65
+ const { registerBilkoHostIpc } = require('./bilkoHost.cjs');
64
66
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
65
67
  const agentMemory = require('./agentMemory.cjs');
66
68
  const git = require('./git.cjs');
@@ -457,10 +459,29 @@ ipcMain.handle('agents:list-personas', () => agentLibrary.listPersonas());
457
459
  // Agent Library CRUD — global personas only (`~/.claude/agents/*.md`); a
458
460
  // project's local overlay can only be removed here (removeOverride), never
459
461
  // created, since Agent Library is a Home-face, machine-wide surface with no
460
- // per-tab cwd context of its own.
461
- ipcMain.handle('agents:save-persona', (_e, payload) => agentLibrary.savePersona(payload));
462
- ipcMain.handle('agents:delete-persona', (_e, payload) => agentLibrary.deletePersona(payload));
463
- ipcMain.handle('agents:remove-override', (_e, payload) => agentLibrary.removeOverride(payload));
462
+ // per-tab cwd context of its own. Each mutation broadcasts `agents:changed`
463
+ // so Agent Library and Tag Library — two independently-mounted dockview
464
+ // panels, each with its own component-local `listPersonas()` snapshot —
465
+ // both refresh off the one file on disk instead of one panel going stale
466
+ // after the other edits the agent<->tag relationship.
467
+ function broadcastAgentsChanged() {
468
+ sendIfAlive(mainWindow, 'agents:changed', {});
469
+ }
470
+ ipcMain.handle('agents:save-persona', async (_e, payload) => {
471
+ const result = await agentLibrary.savePersona(payload);
472
+ broadcastAgentsChanged();
473
+ return result;
474
+ });
475
+ ipcMain.handle('agents:delete-persona', async (_e, payload) => {
476
+ const result = await agentLibrary.deletePersona(payload);
477
+ broadcastAgentsChanged();
478
+ return result;
479
+ });
480
+ ipcMain.handle('agents:remove-override', async (_e, payload) => {
481
+ const result = await agentLibrary.removeOverride(payload);
482
+ broadcastAgentsChanged();
483
+ return result;
484
+ });
464
485
 
465
486
  ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
466
487
 
@@ -801,6 +822,7 @@ activeIndexMerge.registerActiveIndexMergeHandlers();
801
822
  registerMemoryAggregateIpc();
802
823
  registerProjectBriefIpc();
803
824
  registerProjectPagesIpc();
825
+ registerBilkoHostIpc();
804
826
  ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
805
827
  const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
806
828
  return { ok };
@@ -486,6 +486,31 @@ const projectBriefCwd = z.object({
486
486
  const projectPagesCwd = z.object({
487
487
  cwd: z.string().min(1).max(4096),
488
488
  }).strict();
489
+
490
+ // ──────────────────────────────────────────── Host on Bilko.run
491
+ // Same validation split: real path validation is config.cjs's validatePath
492
+ // at first fs access in bilkoHost.cjs — these schemas only bound wire shape.
493
+ const bilkoHostCwd = z.object({
494
+ cwd: z.string().min(1).max(4096),
495
+ }).strict();
496
+ const bilkoHostPrepareBundle = z.object({
497
+ cwd: z.string().min(1).max(4096),
498
+ slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/, 'slug must be lowercase kebab-case'),
499
+ }).strict();
500
+ const BILKO_HOST_DOCUMENT_SOURCE = z.union([
501
+ z.object({ kind: z.literal('project-page-lens'), lens: z.enum(['home', 'marketing', 'feature', 'architecture']) }).strict(),
502
+ z.object({ kind: z.literal('file'), path: z.string().min(1).max(4096) }).strict(),
503
+ ]);
504
+ const bilkoHostAddDocument = z.object({
505
+ cwd: z.string().min(1).max(4096),
506
+ subpath: z.string().min(1).max(200),
507
+ title: z.string().min(1).max(200),
508
+ source: BILKO_HOST_DOCUMENT_SOURCE,
509
+ }).strict();
510
+ const bilkoHostRemoveDocument = z.object({
511
+ cwd: z.string().min(1).max(4096),
512
+ id: z.string().min(1).max(200),
513
+ }).strict();
489
514
  const projectBriefSetPin = z.object({
490
515
  cwd: z.string().min(1).max(4096),
491
516
  block: PROJECT_BRIEF_BLOCK,
@@ -937,6 +962,10 @@ module.exports = {
937
962
  memoryStale,
938
963
  projectBriefCwd,
939
964
  projectPagesCwd,
965
+ bilkoHostCwd,
966
+ bilkoHostPrepareBundle,
967
+ bilkoHostAddDocument,
968
+ bilkoHostRemoveDocument,
940
969
  projectBriefSetPin,
941
970
  projectBriefUpdate,
942
971
  promptSessionTranscriptAppend,
@@ -54,6 +54,11 @@ const OWNERS = Object.freeze({
54
54
  'browser': 'browser',
55
55
  // Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
56
56
  'logs': 'logs',
57
+ // Host on Bilko.run tab's deterministic bundle prep (dist/index.html +
58
+ // dist/manifest.json only — publish-state.json and anything the
59
+ // bilko-host-publisher Epic authors beyond dist/ is agent-Write-tool
60
+ // output, same unenforceable-by-construction class as project-pages/output.
61
+ 'bilko-host': 'bilko-host',
57
62
  });
58
63
 
59
64
  /**
@@ -21,7 +21,7 @@ function outputDir(cwd) {
21
21
  return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
22
22
  }
23
23
 
24
- const LENSES = ['marketing', 'feature', 'architecture'];
24
+ const LENSES = ['home', 'marketing', 'feature', 'architecture'];
25
25
 
26
26
  async function get({ cwd }) {
27
27
  const realCwd = config.validatePath(cwd);
@@ -38,7 +38,7 @@ async function get({ cwd }) {
38
38
  return { output: null };
39
39
  }
40
40
 
41
- const [marketing, feature, architecture] = htmlResults;
41
+ const [home, marketing, feature, architecture] = htmlResults;
42
42
  const generatedAt = typeof manifestResult.data.generatedAt === 'string' ? manifestResult.data.generatedAt : null;
43
43
  if (!generatedAt) {
44
44
  return { output: null };
@@ -46,6 +46,7 @@ async function get({ cwd }) {
46
46
 
47
47
  return {
48
48
  output: {
49
+ home: home.text,
49
50
  marketing: marketing.text,
50
51
  feature: feature.text,
51
52
  architecture: architecture.text,
@@ -987,6 +987,7 @@ export type ProjectBriefUpdateResult =
987
987
 
988
988
  // ────────────────────────────────────────────── Project Pages (PRD 929-932)
989
989
  export interface ProjectPagesOutput {
990
+ home: string;
990
991
  marketing: string;
991
992
  feature: string;
992
993
  architecture: string;
@@ -998,6 +999,76 @@ export interface ProjectPagesGetResult {
998
999
  output: ProjectPagesOutput | null;
999
1000
  }
1000
1001
 
1002
+ // ────────────────────────────────────────────── Host on Bilko.run
1003
+ export interface BilkoHostBundleManifest {
1004
+ schemaVersion: 1;
1005
+ slug: string;
1006
+ version: string;
1007
+ builtAt: string;
1008
+ gitSha: string;
1009
+ gitBranch: string;
1010
+ hostKit: { version: string };
1011
+ golden: { path: string; expect: string };
1012
+ health: Record<string, unknown>;
1013
+ bundle: { sizeBytesGz: number; fileCount: number };
1014
+ /** Not part of the host-contract schema itself — this app's own staleness bookkeeping (see BilkoHostGetResult.bundleStale). */
1015
+ documentCount: number;
1016
+ documents: Array<{ subpath: string; title: string }>;
1017
+ }
1018
+
1019
+ export type BilkoHostPublishStatus =
1020
+ | 'not-published'
1021
+ | 'bundle-ready'
1022
+ | 'publishing'
1023
+ | 'published'
1024
+ | 'publish-failed';
1025
+
1026
+ export interface BilkoHostPublishState {
1027
+ status: BilkoHostPublishStatus;
1028
+ slug: string;
1029
+ url?: string;
1030
+ lastAttemptAt?: string;
1031
+ lastError?: string;
1032
+ }
1033
+
1034
+ export type BilkoHostDocumentSource =
1035
+ | { kind: 'project-page-lens'; lens: 'home' | 'marketing' | 'feature' | 'architecture' }
1036
+ | { kind: 'file'; path: string };
1037
+
1038
+ export interface BilkoHostDocument {
1039
+ id: string;
1040
+ /** '' for the root document (dist/index.html); otherwise a '/'-joined lowercase-kebab path, e.g. 'special-doc/01'. */
1041
+ subpath: string;
1042
+ title: string;
1043
+ source: BilkoHostDocumentSource;
1044
+ addedAt: string;
1045
+ /** Where this document resolves once published, given the project's current/derived slug. */
1046
+ url: string;
1047
+ }
1048
+
1049
+ export interface BilkoHostGetResult {
1050
+ hasMarketingPage: boolean;
1051
+ projectName: string;
1052
+ packagePrivate: boolean;
1053
+ packageHomepage: string | null;
1054
+ packageVersion: string;
1055
+ defaultSlug: string;
1056
+ documents: BilkoHostDocument[];
1057
+ /** True when the on-disk dist/ bundle doesn't reflect the current document list — Prepare Bundle (then Publish) is owed. */
1058
+ bundleStale: boolean;
1059
+ bundleManifest: BilkoHostBundleManifest | null;
1060
+ publishState: BilkoHostPublishState | null;
1061
+ }
1062
+
1063
+ export interface BilkoHostPrepareBundleResult {
1064
+ distPath: string;
1065
+ manifest: BilkoHostBundleManifest;
1066
+ }
1067
+
1068
+ export interface BilkoHostDocumentListResult {
1069
+ documents: Array<Omit<BilkoHostDocument, 'url'>>;
1070
+ }
1071
+
1001
1072
  // ────────────────────────────────────────────── Per-subagent memory
1002
1073
  // Stored at ~/.claude/session-manager/agent-memory/<agentId>.json. Keyed by
1003
1074
  // agent name (the .md filename in ~/.claude/agents/), not by workspace cwd.
@@ -1148,7 +1219,7 @@ export interface ChatCreatePrdPayload {
1148
1219
  /** Originating tab id — used at job completion to route a status prompt back into the tab. */
1149
1220
  sourceTabId?: string;
1150
1221
  /** User-selected Feature/Bug tag (PRD 774) carried from the originating PromptTicket. */
1151
- tag?: 'feature' | 'bug' | 'discussion' | 'build' | 'project-home-builder';
1222
+ tag?: 'feature' | 'bug' | 'discussion' | 'build' | 'project-home-builder' | 'bilko-host-publisher';
1152
1223
  }
1153
1224
 
1154
1225
  export type ChatCreatePrdResult =
@@ -1349,6 +1420,8 @@ export interface SessionManagerAPI {
1349
1420
  savePersona: (payload: AgentPersonaSaveInput) => Promise<{ ok: boolean; path: string }>;
1350
1421
  deletePersona: (payload: { name: string }) => Promise<{ ok: boolean }>;
1351
1422
  removeOverride: (payload: { name: string; projectName: string }) => Promise<{ ok: boolean }>;
1423
+ /** Fires after any save/delete/removeOverride — subscribers should re-fetch listPersonas(). */
1424
+ onChanged: (handler: () => void) => () => void;
1352
1425
  };
1353
1426
  logs: {
1354
1427
  write: (
@@ -1566,6 +1639,16 @@ export interface SessionManagerAPI {
1566
1639
  /** Read output/*.html + manifest.json (or `{output: null}` if none exist yet). Never fires an LLM call. */
1567
1640
  get: (cwd: string) => Promise<ProjectPagesGetResult>;
1568
1641
  };
1642
+ bilkoHost: {
1643
+ /** Read compatibility-gate inputs + any existing bundle/publish state. Never fires an LLM call. */
1644
+ get: (cwd: string) => Promise<BilkoHostGetResult>;
1645
+ /** Stage A: rebuild the whole dist/ tree + dist/manifest.json from the current document list. Pure, no cost, idempotent. */
1646
+ prepareBundle: (cwd: string, slug: string) => Promise<BilkoHostPrepareBundleResult>;
1647
+ /** Add a hosted sub-path document (never the root, which always exists). Does not touch dist/ — Prepare Bundle picks it up. */
1648
+ addDocument: (cwd: string, subpath: string, title: string, source: BilkoHostDocumentSource) => Promise<BilkoHostDocumentListResult>;
1649
+ /** Remove a hosted document (never the root). Only updates documents.json — the file stays live until the next Prepare Bundle + Publish. */
1650
+ removeDocument: (cwd: string, id: string) => Promise<BilkoHostDocumentListResult>;
1651
+ };
1569
1652
  promptSessionTranscript: {
1570
1653
  /** Append one full-text turn to an Epic's durable JSONL transcript. Best-effort — resolves `{ok:false}` rather than throwing on failure. */
1571
1654
  append: (