@cleocode/cleo-os 2026.4.35 → 2026.4.36

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,728 @@
1
+ /**
2
+ * CleoOS branded startup extension.
3
+ *
4
+ * CANONICAL LOCATION: `packages/cleo-os/extensions/cleo-startup.ts`
5
+ *
6
+ * Installed to: $XDG_DATA_HOME/cleo/extensions/cleo-startup.js
7
+ * Loaded by: Pi via `--extension <path>` injected by CleoOS cli.ts
8
+ *
9
+ * On `session_start`, displays a branded CleoOS welcome panel with:
10
+ * - ASCII art CLEO logo
11
+ * - Project name and CLEO task summary (pending / active / done)
12
+ * - Last 3 brain decisions ("what we decided recently")
13
+ * - Memory bridge summary (total entries, % verified, top 3 cited)
14
+ * - Quick action hints based on current state
15
+ * - Current focused task (if any)
16
+ * - Last session handoff note from the memory bridge
17
+ *
18
+ * Also registers:
19
+ * - `/cleo:status` — on-demand project status refresh
20
+ * - `/cleo:focus <task-id>` — focus on a task
21
+ * - `/cleo:end-session [note]` — end the current session
22
+ *
23
+ * All data is fetched via the `cleo` CLI (best-effort). If any call
24
+ * fails, the banner degrades gracefully — Pi is never crashed.
25
+ *
26
+ * Design system:
27
+ * - accentPrimary (purple #a855f7) — banner chrome, icons
28
+ * - accentSuccess (green #22c55e) — active counts
29
+ * - accentWarning (amber #f59e0b) — pending counts, handoff note
30
+ * - textSecondary (gray #94a3b8) — body text, labels
31
+ * - bold — headings, task title
32
+ * - Box-drawing constants from tui-theme for Forge aesthetic
33
+ *
34
+ * Guardrails:
35
+ * - Best-effort: all CLEO CLI calls wrapped in try/catch
36
+ * - NO top-level await; all work inside event handlers
37
+ * - NEVER modify system prompt (startup display only)
38
+ * - NEVER crash Pi
39
+ *
40
+ * @packageDocumentation
41
+ */
42
+ import { execFile } from "node:child_process";
43
+ import { existsSync, readFileSync } from "node:fs";
44
+ import { join } from "node:path";
45
+ import { promisify } from "node:util";
46
+ import { accentPrimary, accentSuccess, accentWarning, accentError, textSecondary, textTertiary, bold, BOX_HORIZONTAL, BOX_VERTICAL, BOX_TOP_LEFT, BOX_TOP_RIGHT, BOX_BOTTOM_LEFT, BOX_BOTTOM_RIGHT, BOX_LEFT_T, BOX_RIGHT_T, ICON_FORGE, DOT_FILLED, } from "./tui-theme.js";
47
+ const execFileAsync = promisify(execFile);
48
+ // ============================================================================
49
+ // Parsers — best-effort, always return a typed default on failure
50
+ // ============================================================================
51
+ /**
52
+ * Parse task summary from `cleo dash --json` output.
53
+ *
54
+ * @param stdout - Raw stdout from the CLI call.
55
+ * @returns Parsed task summary, defaulting all counts to 0 on failure.
56
+ */
57
+ export function parseDashSummary(stdout) {
58
+ try {
59
+ const parsed = JSON.parse(stdout);
60
+ const data = (parsed["data"] ?? parsed);
61
+ const summary = data["summary"];
62
+ const blocked = data["blockedTasks"];
63
+ return {
64
+ active: typeof summary?.["active"] === "number" ? summary["active"] : 0,
65
+ pending: typeof summary?.["pending"] === "number" ? summary["pending"] : 0,
66
+ done: typeof summary?.["done"] === "number" ? summary["done"] : 0,
67
+ total: typeof summary?.["total"] === "number" ? summary["total"] : 0,
68
+ blocked: typeof blocked?.["count"] === "number" ? blocked["count"] : 0,
69
+ };
70
+ }
71
+ catch {
72
+ return { active: 0, pending: 0, done: 0, total: 0, blocked: 0 };
73
+ }
74
+ }
75
+ /**
76
+ * Parse session info from `cleo session status --json` output.
77
+ *
78
+ * Extracts active state, session ID/name, current task, and the last
79
+ * handoff note left at session end.
80
+ *
81
+ * @param stdout - Raw stdout from the CLI call.
82
+ * @returns Parsed session info.
83
+ */
84
+ export function parseSessionInfo(stdout) {
85
+ const defaultInfo = {
86
+ active: false,
87
+ id: "",
88
+ name: "",
89
+ currentTaskId: null,
90
+ handoffNote: null,
91
+ };
92
+ try {
93
+ const parsed = JSON.parse(stdout);
94
+ const data = (parsed["data"] ?? parsed);
95
+ const sessionWrapper = data["session"];
96
+ if (!sessionWrapper)
97
+ return defaultInfo;
98
+ const hasActive = sessionWrapper["hasActiveSession"] === true;
99
+ if (!hasActive)
100
+ return { ...defaultInfo, active: false };
101
+ const session = sessionWrapper["session"];
102
+ if (!session)
103
+ return { ...defaultInfo, active: true };
104
+ const taskWork = sessionWrapper["taskWork"];
105
+ const currentTaskId = typeof taskWork?.["taskId"] === "string" && taskWork["taskId"]
106
+ ? taskWork["taskId"]
107
+ : null;
108
+ // Handoff note is stored on the session object itself
109
+ const handoffRaw = session["handoffJson"];
110
+ let handoffNote = null;
111
+ if (typeof handoffRaw === "string" && handoffRaw.length > 0) {
112
+ try {
113
+ const handoff = JSON.parse(handoffRaw);
114
+ if (typeof handoff["note"] === "string") {
115
+ handoffNote = handoff["note"];
116
+ }
117
+ }
118
+ catch {
119
+ // Not valid JSON — use raw value if it's short enough
120
+ if (handoffRaw.length < 200)
121
+ handoffNote = handoffRaw;
122
+ }
123
+ }
124
+ return {
125
+ active: true,
126
+ id: typeof session["id"] === "string" ? session["id"] : "",
127
+ name: typeof session["name"] === "string" ? session["name"] : "",
128
+ currentTaskId,
129
+ handoffNote,
130
+ };
131
+ }
132
+ catch {
133
+ return defaultInfo;
134
+ }
135
+ }
136
+ /**
137
+ * Parse current task info from `cleo current --json` output.
138
+ *
139
+ * @param stdout - Raw stdout from the CLI call.
140
+ * @returns Parsed current task, or null if none active.
141
+ */
142
+ export function parseCurrentTask(stdout) {
143
+ try {
144
+ const parsed = JSON.parse(stdout);
145
+ const data = (parsed["data"] ?? parsed);
146
+ // `cleo current` can return `{ currentTask: null }` or a task object
147
+ const taskRaw = data["currentTask"] ?? data["task"];
148
+ if (!taskRaw || typeof taskRaw !== "object")
149
+ return null;
150
+ const task = taskRaw;
151
+ const id = typeof task["id"] === "string" ? task["id"] : null;
152
+ const title = typeof task["title"] === "string" ? task["title"] : null;
153
+ const status = typeof task["status"] === "string" ? task["status"] : "unknown";
154
+ if (!id || !title)
155
+ return null;
156
+ return { id, title, status };
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ /**
163
+ * Parse the last 3 decisions from `cleo memory decision-find --json` output.
164
+ *
165
+ * @param stdout - Raw stdout from the CLI call.
166
+ * @returns Array of up to 3 decision entries.
167
+ */
168
+ export function parseRecentDecisions(stdout) {
169
+ try {
170
+ const parsed = JSON.parse(stdout);
171
+ const data = (parsed["data"] ?? parsed);
172
+ const rawDecisions = data["decisions"];
173
+ if (!Array.isArray(rawDecisions))
174
+ return [];
175
+ return rawDecisions
176
+ .slice(0, 3)
177
+ .map((d) => {
178
+ const entry = d;
179
+ const id = typeof entry["id"] === "string" ? entry["id"] : "";
180
+ const title = typeof entry["title"] === "string"
181
+ ? entry["title"]
182
+ : typeof entry["summary"] === "string"
183
+ ? entry["summary"]
184
+ : "";
185
+ const date = typeof entry["date"] === "string"
186
+ ? entry["date"].slice(0, 10)
187
+ : typeof entry["createdAt"] === "string"
188
+ ? entry["createdAt"].slice(0, 10)
189
+ : "";
190
+ return { id, title, date };
191
+ })
192
+ .filter((d) => d.title.length > 0);
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ }
198
+ /**
199
+ * Parse memory bridge statistics from `.cleo/memory-bridge.md`.
200
+ *
201
+ * Counts total list items across all sections and extracts the
202
+ * top 3 cited items (items appearing in the "Recent Decisions" or
203
+ * "Key Learnings" sections).
204
+ *
205
+ * @param content - Raw memory-bridge.md content.
206
+ * @returns Memory bridge statistics.
207
+ */
208
+ export function parseMemoryBridgeStats(content) {
209
+ if (!content || content.length === 0) {
210
+ return { totalEntries: 0, topThree: [] };
211
+ }
212
+ // Count all list items (lines starting with "- " or "* ")
213
+ const allItems = content.match(/^[-*]\s+\[.+?\].+/gm) ?? [];
214
+ const totalEntries = allItems.length;
215
+ // Extract top 3 from "Key Learnings" or "Recent Decisions" sections
216
+ const learningsMatch = content.match(/## Key Learnings\n([\s\S]*?)(?=\n##|\s*$)/m);
217
+ const decisionsMatch = content.match(/## Recent Decisions\n([\s\S]*?)(?=\n##|\s*$)/m);
218
+ const topSection = (learningsMatch?.[1] ?? decisionsMatch?.[1] ?? "");
219
+ const topItems = (topSection.match(/^[-*]\s+\[.+?\]\s+(.+)/gm) ?? [])
220
+ .slice(0, 3)
221
+ .map((line) => {
222
+ // Strip the "- [ID] " prefix, keep the description
223
+ return line.replace(/^[-*]\s+\[.+?\]\s+/, "").trim();
224
+ })
225
+ .filter((s) => s.length > 0);
226
+ return { totalEntries, topThree: topItems };
227
+ }
228
+ /**
229
+ * Build quick-action hints based on current project state.
230
+ *
231
+ * Generates context-sensitive suggestions so the operator knows
232
+ * immediately what to do next without running extra commands.
233
+ *
234
+ * @param tasks - Task summary counts.
235
+ * @param session - Current session state.
236
+ * @param currentTask - Currently focused task, or null.
237
+ * @returns Quick-action hints.
238
+ */
239
+ export function buildQuickHints(tasks, session, currentTask) {
240
+ const hints = [];
241
+ if (!session.active) {
242
+ hints.push("No active session — run `cleo session start` to begin");
243
+ }
244
+ else if (!currentTask && tasks.pending > 0) {
245
+ hints.push(`${tasks.pending} ready tasks — run \`cleo next\` to pick one`);
246
+ }
247
+ else if (currentTask) {
248
+ hints.push(`Working on [${currentTask.id}] — run \`cleo complete ${currentTask.id}\` when done`);
249
+ }
250
+ if (tasks.blocked > 0) {
251
+ hints.push(`${tasks.blocked} blocked task(s) — run \`cleo blockers\` to review`);
252
+ }
253
+ if (hints.length === 0 && tasks.total === 0) {
254
+ hints.push("No tasks yet — run `cleo add \"<task title>\"` to start");
255
+ }
256
+ return { hints };
257
+ }
258
+ /**
259
+ * Read the last session handoff note from `.cleo/memory-bridge.md`.
260
+ *
261
+ * Falls back to null if the file does not exist or parsing fails.
262
+ * Prefers the `## Last Session` section's `Note:` line.
263
+ *
264
+ * @param projectDir - The project root directory.
265
+ * @returns The last session note, or null.
266
+ */
267
+ export function readMemoryBridgeNote(projectDir) {
268
+ try {
269
+ const bridgePath = join(projectDir, ".cleo", "memory-bridge.md");
270
+ if (!existsSync(bridgePath))
271
+ return null;
272
+ const content = readFileSync(bridgePath, "utf-8");
273
+ // Extract the Note: line from the ## Last Session section
274
+ const noteMatch = content.match(/[-*]\s+\*\*Note\*\*:\s*(.+)/m);
275
+ if (noteMatch?.[1]) {
276
+ return noteMatch[1].trim();
277
+ }
278
+ // Fallback: look for any line that starts with "- **Note**"
279
+ const altMatch = content.match(/\*\*Note\*\*[:\s]+(.+)/m);
280
+ if (altMatch?.[1]) {
281
+ return altMatch[1].trim();
282
+ }
283
+ return null;
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ // ============================================================================
290
+ // Banner rendering
291
+ // ============================================================================
292
+ /**
293
+ * Truncate a string to a maximum length, appending "..." if truncated.
294
+ *
295
+ * @param text - The text to truncate.
296
+ * @param maxLen - Maximum character length.
297
+ * @returns Truncated string.
298
+ */
299
+ function truncate(text, maxLen) {
300
+ if (text.length <= maxLen)
301
+ return text;
302
+ return text.slice(0, maxLen - 3) + "...";
303
+ }
304
+ /**
305
+ * Pad a content string to fill the banner inner width.
306
+ *
307
+ * The banner uses box-drawing characters. The padding is calculated
308
+ * from the raw (un-styled) content string length so ANSI escapes
309
+ * in styledContent do not affect the column calculation.
310
+ *
311
+ * @param rawContent - Visible text content (without ANSI codes) for width calculation.
312
+ * @param styledContent - ANSI-styled version of the content to display.
313
+ * @param innerWidth - Inner width of the banner box in characters.
314
+ * @returns A single padded line with box-drawing vertical borders.
315
+ */
316
+ function padBannerLine(rawContent, styledContent, innerWidth) {
317
+ const pad = Math.max(0, innerWidth - rawContent.length);
318
+ return (accentPrimary(BOX_VERTICAL) +
319
+ " " +
320
+ styledContent +
321
+ " ".repeat(pad) +
322
+ accentPrimary(BOX_VERTICAL));
323
+ }
324
+ /**
325
+ * ASCII art CLEO logo lines (no ANSI — caller applies color).
326
+ *
327
+ * Rendered in a compact 3-row form that fits within the 54-char banner width.
328
+ */
329
+ const CLEO_ASCII_LOGO = [
330
+ " ██████╗██╗ ███████╗ ██████╗",
331
+ " ██╔════╝██║ ██╔════╝██╔═══██╗",
332
+ " ██║ ██║ █████╗ ██║ ██║",
333
+ " ██║ ██║ ██╔══╝ ██║ ██║",
334
+ " ╚██████╗███████╗███████╗╚██████╔╝",
335
+ " ╚═════╝╚══════╝╚══════╝ ╚═════╝",
336
+ ];
337
+ /**
338
+ * Build the full CleoOS startup banner.
339
+ *
340
+ * Renders a box-drawing widget with:
341
+ * - ASCII art CLEO logo
342
+ * - Branded header with forge icon
343
+ * - Task counts (active / pending / done / blocked)
344
+ * - Last 3 brain decisions
345
+ * - Memory bridge summary
346
+ * - Quick action hints
347
+ * - Current task title (if any)
348
+ * - Session info (name + ID)
349
+ * - Last session handoff note (from memory-bridge.md or session data)
350
+ *
351
+ * @param tasks - Task summary counts.
352
+ * @param session - Current session state.
353
+ * @param currentTask - Currently focused task, or null.
354
+ * @param handoffNote - Last session handoff note, or null.
355
+ * @param projectName - Project display name.
356
+ * @param decisions - Recent brain decisions (up to 3).
357
+ * @param memStats - Memory bridge statistics.
358
+ * @param hints - Quick-action hints.
359
+ * @returns Array of ANSI-styled banner lines.
360
+ */
361
+ export function buildStartupBanner(tasks, session, currentTask, handoffNote, projectName, decisions = [], memStats = { totalEntries: 0, topThree: [] }, hints = { hints: [] }) {
362
+ // Inner width: characters between the two vertical border chars
363
+ // (not counting the leading space + BOX_VERTICAL or trailing BOX_VERTICAL)
364
+ const INNER = 52;
365
+ const hBar = BOX_HORIZONTAL.repeat(INNER + 2); // +2 for the spaces beside borders
366
+ const lines = [];
367
+ // ── Top border ────────────────────────────────────────────────────────
368
+ lines.push(accentPrimary(BOX_TOP_LEFT + hBar + BOX_TOP_RIGHT));
369
+ // ── ASCII Logo ────────────────────────────────────────────────────────
370
+ for (const logoLine of CLEO_ASCII_LOGO) {
371
+ // Pad to INNER width so border chars align
372
+ lines.push(padBannerLine(logoLine + " ", accentPrimary(logoLine), INNER));
373
+ }
374
+ // ── Header row ────────────────────────────────────────────────────────
375
+ const headerRaw = ` ${ICON_FORGE} Agentic Dev Forge ${ICON_FORGE} ${truncate(projectName, 18)}`;
376
+ lines.push(padBannerLine(" " + headerRaw + " ", " " + bold(accentPrimary(`${ICON_FORGE} Agentic Dev Forge ${ICON_FORGE}`)) +
377
+ accentPrimary(" ") +
378
+ bold(textSecondary(truncate(projectName, 18))), INNER));
379
+ // ── Task summary divider ──────────────────────────────────────────────
380
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
381
+ // ── Task counts ───────────────────────────────────────────────────────
382
+ const activeStr = String(tasks.active);
383
+ const pendingStr = String(tasks.pending);
384
+ const doneStr = String(tasks.done);
385
+ const blockedStr = String(tasks.blocked);
386
+ const countsRaw = ` Tasks: ${activeStr} active ${pendingStr} pending ${doneStr} done` +
387
+ (tasks.blocked > 0 ? ` ${blockedStr} blocked` : "");
388
+ const countsDot = tasks.active > 0
389
+ ? accentSuccess(DOT_FILLED)
390
+ : (tasks.pending > 0 ? accentWarning(DOT_FILLED) : textSecondary(DOT_FILLED));
391
+ const countsStyled = ` ${countsDot} Tasks: ` +
392
+ accentSuccess(activeStr) + textSecondary(" active ") +
393
+ accentWarning(pendingStr) + textSecondary(" pending ") +
394
+ textSecondary(doneStr + " done") +
395
+ (tasks.blocked > 0
396
+ ? " " + accentError(blockedStr) + textSecondary(" blocked")
397
+ : "");
398
+ lines.push(padBannerLine(" " + countsRaw + " ", " " + countsStyled.trimStart(), INNER));
399
+ // ── Current task ──────────────────────────────────────────────────────
400
+ if (currentTask) {
401
+ const taskRaw = ` Focus: [${currentTask.id}] ${truncate(currentTask.title, 32)}`;
402
+ const taskStyled = ` ${textSecondary("Focus:")} ` +
403
+ accentPrimary(`[${currentTask.id}]`) + " " +
404
+ bold(textSecondary(truncate(currentTask.title, 32)));
405
+ lines.push(padBannerLine(" " + taskRaw + " ", taskStyled, INNER));
406
+ }
407
+ else {
408
+ const noTaskRaw = " Focus: none";
409
+ lines.push(padBannerLine(" " + noTaskRaw + " ", " " + textSecondary("Focus:") + " " + textTertiary("none"), INNER));
410
+ }
411
+ // ── Session info ──────────────────────────────────────────────────────
412
+ if (session.active) {
413
+ const shortId = session.id.length > 22 ? session.id.slice(0, 22) + ".." : session.id;
414
+ const sessionName = session.name.length > 0 ? truncate(session.name, 20) : shortId;
415
+ const sessionRaw = ` Session: ${sessionName}`;
416
+ const sessionStyled = ` ${textSecondary("Session:")} ` + accentPrimary(sessionName);
417
+ lines.push(padBannerLine(" " + sessionRaw + " ", sessionStyled, INNER));
418
+ }
419
+ // ── Memory bridge stats ───────────────────────────────────────────────
420
+ if (memStats.totalEntries > 0) {
421
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
422
+ const memRaw = ` Memory: ${memStats.totalEntries} entries`;
423
+ const memStyled = ` ${textSecondary("Memory:")} ` + accentSuccess(String(memStats.totalEntries)) +
424
+ textSecondary(" entries");
425
+ lines.push(padBannerLine(" " + memRaw + " ", memStyled, INNER));
426
+ for (const item of memStats.topThree) {
427
+ const truncItem = truncate(item, INNER - 6);
428
+ lines.push(padBannerLine(` * ${truncItem} `, ` ${textTertiary("*")} ${textSecondary(truncItem)}`, INNER));
429
+ }
430
+ }
431
+ // ── Recent decisions ──────────────────────────────────────────────────
432
+ if (decisions.length > 0) {
433
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
434
+ const decHeaderRaw = " What we decided recently:";
435
+ lines.push(padBannerLine(" " + decHeaderRaw + " ", " " + bold(textSecondary("What we decided recently:")), INNER));
436
+ for (const dec of decisions) {
437
+ const label = dec.date ? `[${dec.date}]` : `[${dec.id}]`;
438
+ const decRaw = ` ${label} ${truncate(dec.title, INNER - label.length - 5)}`;
439
+ const decStyled = ` ${textTertiary(label)} ` +
440
+ textSecondary(truncate(dec.title, INNER - label.length - 5));
441
+ lines.push(padBannerLine(" " + decRaw + " ", decStyled, INNER));
442
+ }
443
+ }
444
+ // ── Quick action hints ────────────────────────────────────────────────
445
+ if (hints.hints.length > 0) {
446
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
447
+ for (const hint of hints.hints) {
448
+ const truncHint = truncate(hint, INNER - 5);
449
+ lines.push(padBannerLine(` > ${truncHint} `, ` ${accentWarning(">")} ${accentWarning(truncHint)}`, INNER));
450
+ }
451
+ }
452
+ // ── Handoff note ──────────────────────────────────────────────────────
453
+ const note = handoffNote ?? session.handoffNote;
454
+ if (note) {
455
+ // Split into two lines if the note is long (up to 88 chars total)
456
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
457
+ const maxNoteChars = (INNER - 4) * 2; // two lines of inner width
458
+ const truncatedNote = truncate(note, maxNoteChars);
459
+ const noteLineMaxLen = INNER - 4; // leave space for " > " prefix
460
+ const noteLine1Raw = truncatedNote.slice(0, noteLineMaxLen);
461
+ const noteLine2Raw = truncatedNote.length > noteLineMaxLen
462
+ ? truncatedNote.slice(noteLineMaxLen)
463
+ : null;
464
+ lines.push(padBannerLine(` > ${noteLine1Raw} `, ` ${accentWarning(">")} ${accentWarning(noteLine1Raw)}`, INNER));
465
+ if (noteLine2Raw) {
466
+ lines.push(padBannerLine(` ${noteLine2Raw} `, ` ${textSecondary(noteLine2Raw)}`, INNER));
467
+ }
468
+ }
469
+ // ── Bottom border ─────────────────────────────────────────────────────
470
+ lines.push(accentPrimary(BOX_BOTTOM_LEFT + hBar + BOX_BOTTOM_RIGHT));
471
+ return lines;
472
+ }
473
+ // ============================================================================
474
+ // Project name detection
475
+ // ============================================================================
476
+ /**
477
+ * Detect the project name for display in the startup banner.
478
+ *
479
+ * Resolution order:
480
+ * 1. `name` field from `.cleo/project-info.json`
481
+ * 2. `name` field from `package.json` in `projectDir`
482
+ * 3. Last path segment of `projectDir`
483
+ *
484
+ * @param projectDir - The project root directory.
485
+ * @returns The resolved project display name.
486
+ */
487
+ export function detectProjectName(projectDir) {
488
+ // Try .cleo/project-info.json first
489
+ try {
490
+ const infoPath = join(projectDir, ".cleo", "project-info.json");
491
+ if (existsSync(infoPath)) {
492
+ const raw = readFileSync(infoPath, "utf-8");
493
+ const parsed = JSON.parse(raw);
494
+ if (typeof parsed["name"] === "string" && parsed["name"].length > 0) {
495
+ return parsed["name"];
496
+ }
497
+ }
498
+ }
499
+ catch {
500
+ // Fall through
501
+ }
502
+ // Try package.json
503
+ try {
504
+ const pkgPath = join(projectDir, "package.json");
505
+ if (existsSync(pkgPath)) {
506
+ const raw = readFileSync(pkgPath, "utf-8");
507
+ const parsed = JSON.parse(raw);
508
+ if (typeof parsed["name"] === "string" && parsed["name"].length > 0) {
509
+ return parsed["name"];
510
+ }
511
+ }
512
+ }
513
+ catch {
514
+ // Fall through
515
+ }
516
+ // Fall back to last path segment
517
+ const parts = projectDir.replace(/\\/g, "/").split("/").filter(Boolean);
518
+ return parts[parts.length - 1] ?? "unknown-project";
519
+ }
520
+ // ============================================================================
521
+ // Pi extension factory
522
+ // ============================================================================
523
+ // ============================================================================
524
+ // Shared fetch helper (used by both session_start and /cleo:status)
525
+ // ============================================================================
526
+ /**
527
+ * Fetch all data needed to render the startup banner.
528
+ *
529
+ * All CLI calls are issued in parallel and are best-effort — no failure
530
+ * will throw or block. Returns a fully-populated data bundle.
531
+ *
532
+ * @param cwd - Project root directory.
533
+ * @returns All banner data.
534
+ */
535
+ async function fetchBannerData(cwd) {
536
+ const [dashResult, sessionResult, currentResult, decisionsResult] = await Promise.allSettled([
537
+ execFileAsync("cleo", ["dash", "--json"], { timeout: 8_000, cwd }),
538
+ execFileAsync("cleo", ["session", "status", "--json"], { timeout: 8_000, cwd }),
539
+ execFileAsync("cleo", ["current", "--json"], { timeout: 8_000, cwd }),
540
+ execFileAsync("cleo", ["memory", "decision-find", "--limit", "3", "--json"], {
541
+ timeout: 8_000,
542
+ cwd,
543
+ }),
544
+ ]);
545
+ const tasks = dashResult.status === "fulfilled"
546
+ ? parseDashSummary(dashResult.value.stdout)
547
+ : { active: 0, pending: 0, done: 0, total: 0, blocked: 0 };
548
+ const session = sessionResult.status === "fulfilled"
549
+ ? parseSessionInfo(sessionResult.value.stdout)
550
+ : { active: false, id: "", name: "", currentTaskId: null, handoffNote: null };
551
+ const currentTask = currentResult.status === "fulfilled"
552
+ ? parseCurrentTask(currentResult.value.stdout)
553
+ : null;
554
+ const decisions = decisionsResult.status === "fulfilled"
555
+ ? parseRecentDecisions(decisionsResult.value.stdout)
556
+ : [];
557
+ // Memory bridge stats — synchronous read from filesystem
558
+ const bridgeContent = readMemoryBridgeContent(cwd);
559
+ const memStats = bridgeContent
560
+ ? parseMemoryBridgeStats(bridgeContent)
561
+ : { totalEntries: 0, topThree: [] };
562
+ const handoffNote = bridgeContent ? extractNoteFromBridgeContent(bridgeContent) : null;
563
+ const projectName = detectProjectName(cwd);
564
+ const hints = buildQuickHints(tasks, session, currentTask);
565
+ return { tasks, session, currentTask, handoffNote, projectName, decisions, memStats, hints };
566
+ }
567
+ /**
568
+ * Read raw `.cleo/memory-bridge.md` content.
569
+ *
570
+ * Returns null when the file does not exist or cannot be read.
571
+ *
572
+ * @param projectDir - The project root directory.
573
+ * @returns The raw file content, or null.
574
+ */
575
+ function readMemoryBridgeContent(projectDir) {
576
+ try {
577
+ const bridgePath = join(projectDir, ".cleo", "memory-bridge.md");
578
+ if (!existsSync(bridgePath))
579
+ return null;
580
+ const content = readFileSync(bridgePath, "utf-8");
581
+ return content.length > 0 ? content : null;
582
+ }
583
+ catch {
584
+ return null;
585
+ }
586
+ }
587
+ /**
588
+ * Extract the last session handoff note from raw memory-bridge.md content.
589
+ *
590
+ * Prefers the `## Last Session` section's `Note:` line.
591
+ *
592
+ * @param content - Raw memory-bridge.md content.
593
+ * @returns The last session note, or null.
594
+ */
595
+ function extractNoteFromBridgeContent(content) {
596
+ try {
597
+ const noteMatch = content.match(/[-*]\s+\*\*Note\*\*:\s*(.+)/m);
598
+ if (noteMatch?.[1])
599
+ return noteMatch[1].trim();
600
+ const altMatch = content.match(/\*\*Note\*\*[:\s]+(.+)/m);
601
+ if (altMatch?.[1])
602
+ return altMatch[1].trim();
603
+ return null;
604
+ }
605
+ catch {
606
+ return null;
607
+ }
608
+ }
609
+ // ============================================================================
610
+ // Pi extension factory
611
+ // ============================================================================
612
+ /**
613
+ * Pi extension factory for the CleoOS branded startup experience.
614
+ *
615
+ * Registers:
616
+ * - `session_start` — fetches all data, renders the branded startup banner
617
+ * - `/cleo:status` — on-demand project status refresh
618
+ * - `/cleo:focus <task-id>` — focus on a task from inside Pi
619
+ * - `/cleo:end-session [note]` — end the current session from inside Pi
620
+ *
621
+ * All operations are best-effort — failures are silently swallowed so Pi
622
+ * is never blocked by CLEO unavailability.
623
+ *
624
+ * @param pi - The Pi extension API instance.
625
+ */
626
+ export default function (pi) {
627
+ pi.on("session_start", async (_event, ctx) => {
628
+ const data = await fetchBannerData(ctx.cwd);
629
+ const bannerLines = buildStartupBanner(data.tasks, data.session, data.currentTask, data.handoffNote, data.projectName, data.decisions, data.memStats, data.hints);
630
+ if (ctx.hasUI) {
631
+ ctx.ui.setWidget("cleo-startup-banner", bannerLines, {
632
+ placement: "aboveEditor",
633
+ });
634
+ // Compact status bar entry
635
+ const taskSummary = `${data.tasks.active}a ${data.tasks.pending}p ${data.tasks.done}d`;
636
+ ctx.ui.setStatus("cleo-startup", `${ICON_FORGE} ${data.projectName.split("/").pop() ?? data.projectName} [${taskSummary}]`);
637
+ }
638
+ else {
639
+ // No UI — print to stderr as a text summary (visible in TTY mode)
640
+ process.stderr.write(bannerLines.join("\n") + "\n");
641
+ }
642
+ });
643
+ // -------------------------------------------------------------------------
644
+ // Command: /cleo:status — on-demand project status refresh
645
+ // -------------------------------------------------------------------------
646
+ pi.registerCommand("cleo:status", {
647
+ description: "Show CleoOS project status: tasks, session, decisions, and hints",
648
+ handler: async (_args, ctx) => {
649
+ const data = await fetchBannerData(ctx.cwd);
650
+ const bannerLines = buildStartupBanner(data.tasks, data.session, data.currentTask, data.handoffNote, data.projectName, data.decisions, data.memStats, data.hints);
651
+ // Refresh the widget too
652
+ if (ctx.hasUI) {
653
+ ctx.ui.setWidget("cleo-startup-banner", bannerLines, {
654
+ placement: "aboveEditor",
655
+ });
656
+ ctx.ui.notify("CleoOS status refreshed", "info");
657
+ }
658
+ pi.sendMessage({
659
+ customType: "cleo-status",
660
+ content: bannerLines.join("\n"),
661
+ display: true,
662
+ }, { triggerTurn: false });
663
+ },
664
+ });
665
+ // -------------------------------------------------------------------------
666
+ // Command: /cleo:focus <task-id> — focus on a task
667
+ // -------------------------------------------------------------------------
668
+ pi.registerCommand("cleo:focus", {
669
+ description: "Focus on a CLEO task: /cleo:focus <task-id>",
670
+ handler: async (args, ctx) => {
671
+ const taskId = args.trim();
672
+ if (!taskId) {
673
+ pi.sendMessage({
674
+ customType: "cleo-focus",
675
+ content: "Usage: /cleo:focus <task-id>",
676
+ display: true,
677
+ }, { triggerTurn: false });
678
+ return;
679
+ }
680
+ let resultText;
681
+ try {
682
+ const { stdout } = await execFileAsync("cleo", ["start", taskId], { timeout: 8_000, cwd: ctx.cwd });
683
+ resultText = stdout.trim() || `Focused on task ${taskId}`;
684
+ }
685
+ catch (err) {
686
+ resultText = `Failed to focus on ${taskId}: ${err instanceof Error ? err.message : String(err)}`;
687
+ }
688
+ pi.sendMessage({
689
+ customType: "cleo-focus",
690
+ content: resultText,
691
+ display: true,
692
+ }, { triggerTurn: false });
693
+ if (ctx.hasUI) {
694
+ ctx.ui.notify(`Focused: ${taskId}`, "info");
695
+ }
696
+ },
697
+ });
698
+ // -------------------------------------------------------------------------
699
+ // Command: /cleo:end-session [note] — end the current CLEO session
700
+ // -------------------------------------------------------------------------
701
+ pi.registerCommand("cleo:end-session", {
702
+ description: "End the current CLEO session with an optional handoff note",
703
+ handler: async (args, ctx) => {
704
+ const note = args.trim() || "Session ended from CleoOS Hearth";
705
+ let resultText;
706
+ try {
707
+ const { stdout } = await execFileAsync("cleo", ["session", "end", "--note", note], { timeout: 15_000, cwd: ctx.cwd });
708
+ resultText = stdout.trim() || "Session ended";
709
+ }
710
+ catch (err) {
711
+ resultText = `Failed to end session: ${err instanceof Error ? err.message : String(err)}`;
712
+ }
713
+ pi.sendMessage({
714
+ customType: "cleo-end-session",
715
+ content: resultText,
716
+ display: true,
717
+ }, { triggerTurn: false });
718
+ if (ctx.hasUI) {
719
+ ctx.ui.notify("CLEO session ended", "info");
720
+ }
721
+ },
722
+ });
723
+ // session_shutdown: no cleanup needed — Pi clears widgets on shutdown
724
+ pi.on("session_shutdown", async () => {
725
+ // Intentional no-op
726
+ });
727
+ }
728
+ //# sourceMappingURL=cleo-startup.js.map