@cleocode/cleo-os 2026.4.31 → 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,1019 @@
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
+
43
+ import { execFile } from "node:child_process";
44
+ import { existsSync, readFileSync } from "node:fs";
45
+ import { join } from "node:path";
46
+ import { promisify } from "node:util";
47
+ import type {
48
+ ExtensionAPI,
49
+ ExtensionContext,
50
+ } from "@mariozechner/pi-coding-agent";
51
+ import {
52
+ accentPrimary,
53
+ accentSuccess,
54
+ accentWarning,
55
+ accentError,
56
+ textSecondary,
57
+ textTertiary,
58
+ bold,
59
+ BOX_HORIZONTAL,
60
+ BOX_VERTICAL,
61
+ BOX_TOP_LEFT,
62
+ BOX_TOP_RIGHT,
63
+ BOX_BOTTOM_LEFT,
64
+ BOX_BOTTOM_RIGHT,
65
+ BOX_LEFT_T,
66
+ BOX_RIGHT_T,
67
+ ICON_FORGE,
68
+ DOT_FILLED,
69
+ } from "./tui-theme.js";
70
+
71
+ const execFileAsync = promisify(execFile);
72
+
73
+ // ============================================================================
74
+ // Data types
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Task summary counts parsed from `cleo dash --json`.
79
+ */
80
+ interface TaskSummary {
81
+ /** Number of active (in-progress) tasks. */
82
+ active: number;
83
+ /** Number of pending tasks. */
84
+ pending: number;
85
+ /** Number of completed tasks. */
86
+ done: number;
87
+ /** Total task count. */
88
+ total: number;
89
+ /** Number of blocked tasks. */
90
+ blocked: number;
91
+ }
92
+
93
+ /**
94
+ * Current session data parsed from `cleo session status --json`.
95
+ */
96
+ interface SessionInfo {
97
+ /** Whether a session is currently active. */
98
+ active: boolean;
99
+ /** Session ID (short form). */
100
+ id: string;
101
+ /** Session display name. */
102
+ name: string;
103
+ /** ID of the focused task, or null. */
104
+ currentTaskId: string | null;
105
+ /** Handoff note from the previous session end, or null. */
106
+ handoffNote: string | null;
107
+ }
108
+
109
+ /**
110
+ * Current task info parsed from `cleo current --json`.
111
+ */
112
+ interface CurrentTask {
113
+ /** Task ID. */
114
+ id: string;
115
+ /** Task title. */
116
+ title: string;
117
+ /** Task status. */
118
+ status: string;
119
+ }
120
+
121
+ /**
122
+ * A single decision entry from `cleo memory decision-find --json`.
123
+ */
124
+ interface DecisionEntry {
125
+ /** Short decision ID. */
126
+ id: string;
127
+ /** Decision title or summary text. */
128
+ title: string;
129
+ /** ISO date string. */
130
+ date: string;
131
+ }
132
+
133
+ /**
134
+ * Memory bridge statistics parsed from `.cleo/memory-bridge.md`.
135
+ */
136
+ interface MemoryBridgeStats {
137
+ /** Total entry count found in memory bridge sections. */
138
+ totalEntries: number;
139
+ /** Top 3 cited items (label lines from the bridge). */
140
+ topThree: string[];
141
+ }
142
+
143
+ /**
144
+ * Quick-action hints built from task/session state.
145
+ */
146
+ interface QuickHints {
147
+ /** Array of short hint strings to display. */
148
+ hints: string[];
149
+ }
150
+
151
+ // ============================================================================
152
+ // Parsers — best-effort, always return a typed default on failure
153
+ // ============================================================================
154
+
155
+ /**
156
+ * Parse task summary from `cleo dash --json` output.
157
+ *
158
+ * @param stdout - Raw stdout from the CLI call.
159
+ * @returns Parsed task summary, defaulting all counts to 0 on failure.
160
+ */
161
+ export function parseDashSummary(stdout: string): TaskSummary {
162
+ try {
163
+ const parsed = JSON.parse(stdout) as Record<string, unknown>;
164
+ const data = (parsed["data"] ?? parsed) as Record<string, unknown>;
165
+ const summary = data["summary"] as Record<string, unknown> | undefined;
166
+ const blocked = data["blockedTasks"] as Record<string, unknown> | undefined;
167
+
168
+ return {
169
+ active: typeof summary?.["active"] === "number" ? summary["active"] : 0,
170
+ pending: typeof summary?.["pending"] === "number" ? summary["pending"] : 0,
171
+ done: typeof summary?.["done"] === "number" ? summary["done"] : 0,
172
+ total: typeof summary?.["total"] === "number" ? summary["total"] : 0,
173
+ blocked: typeof blocked?.["count"] === "number" ? blocked["count"] : 0,
174
+ };
175
+ } catch {
176
+ return { active: 0, pending: 0, done: 0, total: 0, blocked: 0 };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Parse session info from `cleo session status --json` output.
182
+ *
183
+ * Extracts active state, session ID/name, current task, and the last
184
+ * handoff note left at session end.
185
+ *
186
+ * @param stdout - Raw stdout from the CLI call.
187
+ * @returns Parsed session info.
188
+ */
189
+ export function parseSessionInfo(stdout: string): SessionInfo {
190
+ const defaultInfo: SessionInfo = {
191
+ active: false,
192
+ id: "",
193
+ name: "",
194
+ currentTaskId: null,
195
+ handoffNote: null,
196
+ };
197
+
198
+ try {
199
+ const parsed = JSON.parse(stdout) as Record<string, unknown>;
200
+ const data = (parsed["data"] ?? parsed) as Record<string, unknown>;
201
+ const sessionWrapper = data["session"] as Record<string, unknown> | undefined;
202
+
203
+ if (!sessionWrapper) return defaultInfo;
204
+
205
+ const hasActive = sessionWrapper["hasActiveSession"] === true;
206
+ if (!hasActive) return { ...defaultInfo, active: false };
207
+
208
+ const session = sessionWrapper["session"] as Record<string, unknown> | undefined;
209
+ if (!session) return { ...defaultInfo, active: true };
210
+
211
+ const taskWork = sessionWrapper["taskWork"] as Record<string, unknown> | undefined;
212
+ const currentTaskId =
213
+ typeof taskWork?.["taskId"] === "string" && taskWork["taskId"]
214
+ ? taskWork["taskId"]
215
+ : null;
216
+
217
+ // Handoff note is stored on the session object itself
218
+ const handoffRaw = session["handoffJson"];
219
+ let handoffNote: string | null = null;
220
+ if (typeof handoffRaw === "string" && handoffRaw.length > 0) {
221
+ try {
222
+ const handoff = JSON.parse(handoffRaw) as Record<string, unknown>;
223
+ if (typeof handoff["note"] === "string") {
224
+ handoffNote = handoff["note"];
225
+ }
226
+ } catch {
227
+ // Not valid JSON — use raw value if it's short enough
228
+ if (handoffRaw.length < 200) handoffNote = handoffRaw;
229
+ }
230
+ }
231
+
232
+ return {
233
+ active: true,
234
+ id: typeof session["id"] === "string" ? session["id"] : "",
235
+ name: typeof session["name"] === "string" ? session["name"] : "",
236
+ currentTaskId,
237
+ handoffNote,
238
+ };
239
+ } catch {
240
+ return defaultInfo;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Parse current task info from `cleo current --json` output.
246
+ *
247
+ * @param stdout - Raw stdout from the CLI call.
248
+ * @returns Parsed current task, or null if none active.
249
+ */
250
+ export function parseCurrentTask(stdout: string): CurrentTask | null {
251
+ try {
252
+ const parsed = JSON.parse(stdout) as Record<string, unknown>;
253
+ const data = (parsed["data"] ?? parsed) as Record<string, unknown>;
254
+
255
+ // `cleo current` can return `{ currentTask: null }` or a task object
256
+ const taskRaw = data["currentTask"] ?? data["task"];
257
+ if (!taskRaw || typeof taskRaw !== "object") return null;
258
+ const task = taskRaw as Record<string, unknown>;
259
+
260
+ const id = typeof task["id"] === "string" ? task["id"] : null;
261
+ const title = typeof task["title"] === "string" ? task["title"] : null;
262
+ const status = typeof task["status"] === "string" ? task["status"] : "unknown";
263
+
264
+ if (!id || !title) return null;
265
+ return { id, title, status };
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Parse the last 3 decisions from `cleo memory decision-find --json` output.
273
+ *
274
+ * @param stdout - Raw stdout from the CLI call.
275
+ * @returns Array of up to 3 decision entries.
276
+ */
277
+ export function parseRecentDecisions(stdout: string): DecisionEntry[] {
278
+ try {
279
+ const parsed = JSON.parse(stdout) as Record<string, unknown>;
280
+ const data = (parsed["data"] ?? parsed) as Record<string, unknown>;
281
+ const rawDecisions = data["decisions"];
282
+ if (!Array.isArray(rawDecisions)) return [];
283
+
284
+ return rawDecisions
285
+ .slice(0, 3)
286
+ .map((d) => {
287
+ const entry = d as Record<string, unknown>;
288
+ const id = typeof entry["id"] === "string" ? entry["id"] : "";
289
+ const title =
290
+ typeof entry["title"] === "string"
291
+ ? entry["title"]
292
+ : typeof entry["summary"] === "string"
293
+ ? entry["summary"]
294
+ : "";
295
+ const date =
296
+ typeof entry["date"] === "string"
297
+ ? entry["date"].slice(0, 10)
298
+ : typeof entry["createdAt"] === "string"
299
+ ? entry["createdAt"].slice(0, 10)
300
+ : "";
301
+ return { id, title, date };
302
+ })
303
+ .filter((d) => d.title.length > 0);
304
+ } catch {
305
+ return [];
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Parse memory bridge statistics from `.cleo/memory-bridge.md`.
311
+ *
312
+ * Counts total list items across all sections and extracts the
313
+ * top 3 cited items (items appearing in the "Recent Decisions" or
314
+ * "Key Learnings" sections).
315
+ *
316
+ * @param content - Raw memory-bridge.md content.
317
+ * @returns Memory bridge statistics.
318
+ */
319
+ export function parseMemoryBridgeStats(content: string): MemoryBridgeStats {
320
+ if (!content || content.length === 0) {
321
+ return { totalEntries: 0, topThree: [] };
322
+ }
323
+
324
+ // Count all list items (lines starting with "- " or "* ")
325
+ const allItems = content.match(/^[-*]\s+\[.+?\].+/gm) ?? [];
326
+ const totalEntries = allItems.length;
327
+
328
+ // Extract top 3 from "Key Learnings" or "Recent Decisions" sections
329
+ const learningsMatch = content.match(/## Key Learnings\n([\s\S]*?)(?=\n##|\s*$)/m);
330
+ const decisionsMatch = content.match(/## Recent Decisions\n([\s\S]*?)(?=\n##|\s*$)/m);
331
+
332
+ const topSection = (learningsMatch?.[1] ?? decisionsMatch?.[1] ?? "");
333
+ const topItems = (topSection.match(/^[-*]\s+\[.+?\]\s+(.+)/gm) ?? [])
334
+ .slice(0, 3)
335
+ .map((line) => {
336
+ // Strip the "- [ID] " prefix, keep the description
337
+ return line.replace(/^[-*]\s+\[.+?\]\s+/, "").trim();
338
+ })
339
+ .filter((s) => s.length > 0);
340
+
341
+ return { totalEntries, topThree: topItems };
342
+ }
343
+
344
+ /**
345
+ * Build quick-action hints based on current project state.
346
+ *
347
+ * Generates context-sensitive suggestions so the operator knows
348
+ * immediately what to do next without running extra commands.
349
+ *
350
+ * @param tasks - Task summary counts.
351
+ * @param session - Current session state.
352
+ * @param currentTask - Currently focused task, or null.
353
+ * @returns Quick-action hints.
354
+ */
355
+ export function buildQuickHints(
356
+ tasks: TaskSummary,
357
+ session: SessionInfo,
358
+ currentTask: CurrentTask | null,
359
+ ): QuickHints {
360
+ const hints: string[] = [];
361
+
362
+ if (!session.active) {
363
+ hints.push("No active session — run `cleo session start` to begin");
364
+ } else if (!currentTask && tasks.pending > 0) {
365
+ hints.push(`${tasks.pending} ready tasks — run \`cleo next\` to pick one`);
366
+ } else if (currentTask) {
367
+ hints.push(`Working on [${currentTask.id}] — run \`cleo complete ${currentTask.id}\` when done`);
368
+ }
369
+
370
+ if (tasks.blocked > 0) {
371
+ hints.push(`${tasks.blocked} blocked task(s) — run \`cleo blockers\` to review`);
372
+ }
373
+
374
+ if (hints.length === 0 && tasks.total === 0) {
375
+ hints.push("No tasks yet — run `cleo add \"<task title>\"` to start");
376
+ }
377
+
378
+ return { hints };
379
+ }
380
+
381
+ /**
382
+ * Read the last session handoff note from `.cleo/memory-bridge.md`.
383
+ *
384
+ * Falls back to null if the file does not exist or parsing fails.
385
+ * Prefers the `## Last Session` section's `Note:` line.
386
+ *
387
+ * @param projectDir - The project root directory.
388
+ * @returns The last session note, or null.
389
+ */
390
+ export function readMemoryBridgeNote(projectDir: string): string | null {
391
+ try {
392
+ const bridgePath = join(projectDir, ".cleo", "memory-bridge.md");
393
+ if (!existsSync(bridgePath)) return null;
394
+
395
+ const content = readFileSync(bridgePath, "utf-8");
396
+
397
+ // Extract the Note: line from the ## Last Session section
398
+ const noteMatch = content.match(/[-*]\s+\*\*Note\*\*:\s*(.+)/m);
399
+ if (noteMatch?.[1]) {
400
+ return noteMatch[1].trim();
401
+ }
402
+
403
+ // Fallback: look for any line that starts with "- **Note**"
404
+ const altMatch = content.match(/\*\*Note\*\*[:\s]+(.+)/m);
405
+ if (altMatch?.[1]) {
406
+ return altMatch[1].trim();
407
+ }
408
+
409
+ return null;
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ // ============================================================================
416
+ // Banner rendering
417
+ // ============================================================================
418
+
419
+ /**
420
+ * Truncate a string to a maximum length, appending "..." if truncated.
421
+ *
422
+ * @param text - The text to truncate.
423
+ * @param maxLen - Maximum character length.
424
+ * @returns Truncated string.
425
+ */
426
+ function truncate(text: string, maxLen: number): string {
427
+ if (text.length <= maxLen) return text;
428
+ return text.slice(0, maxLen - 3) + "...";
429
+ }
430
+
431
+ /**
432
+ * Pad a content string to fill the banner inner width.
433
+ *
434
+ * The banner uses box-drawing characters. The padding is calculated
435
+ * from the raw (un-styled) content string length so ANSI escapes
436
+ * in styledContent do not affect the column calculation.
437
+ *
438
+ * @param rawContent - Visible text content (without ANSI codes) for width calculation.
439
+ * @param styledContent - ANSI-styled version of the content to display.
440
+ * @param innerWidth - Inner width of the banner box in characters.
441
+ * @returns A single padded line with box-drawing vertical borders.
442
+ */
443
+ function padBannerLine(
444
+ rawContent: string,
445
+ styledContent: string,
446
+ innerWidth: number,
447
+ ): string {
448
+ const pad = Math.max(0, innerWidth - rawContent.length);
449
+ return (
450
+ accentPrimary(BOX_VERTICAL) +
451
+ " " +
452
+ styledContent +
453
+ " ".repeat(pad) +
454
+ accentPrimary(BOX_VERTICAL)
455
+ );
456
+ }
457
+
458
+ /**
459
+ * ASCII art CLEO logo lines (no ANSI — caller applies color).
460
+ *
461
+ * Rendered in a compact 3-row form that fits within the 54-char banner width.
462
+ */
463
+ const CLEO_ASCII_LOGO: readonly string[] = [
464
+ " ██████╗██╗ ███████╗ ██████╗",
465
+ " ██╔════╝██║ ██╔════╝██╔═══██╗",
466
+ " ██║ ██║ █████╗ ██║ ██║",
467
+ " ██║ ██║ ██╔══╝ ██║ ██║",
468
+ " ╚██████╗███████╗███████╗╚██████╔╝",
469
+ " ╚═════╝╚══════╝╚══════╝ ╚═════╝",
470
+ ] as const;
471
+
472
+ /**
473
+ * Build the full CleoOS startup banner.
474
+ *
475
+ * Renders a box-drawing widget with:
476
+ * - ASCII art CLEO logo
477
+ * - Branded header with forge icon
478
+ * - Task counts (active / pending / done / blocked)
479
+ * - Last 3 brain decisions
480
+ * - Memory bridge summary
481
+ * - Quick action hints
482
+ * - Current task title (if any)
483
+ * - Session info (name + ID)
484
+ * - Last session handoff note (from memory-bridge.md or session data)
485
+ *
486
+ * @param tasks - Task summary counts.
487
+ * @param session - Current session state.
488
+ * @param currentTask - Currently focused task, or null.
489
+ * @param handoffNote - Last session handoff note, or null.
490
+ * @param projectName - Project display name.
491
+ * @param decisions - Recent brain decisions (up to 3).
492
+ * @param memStats - Memory bridge statistics.
493
+ * @param hints - Quick-action hints.
494
+ * @returns Array of ANSI-styled banner lines.
495
+ */
496
+ export function buildStartupBanner(
497
+ tasks: TaskSummary,
498
+ session: SessionInfo,
499
+ currentTask: CurrentTask | null,
500
+ handoffNote: string | null,
501
+ projectName: string,
502
+ decisions: DecisionEntry[] = [],
503
+ memStats: MemoryBridgeStats = { totalEntries: 0, topThree: [] },
504
+ hints: QuickHints = { hints: [] },
505
+ ): string[] {
506
+ // Inner width: characters between the two vertical border chars
507
+ // (not counting the leading space + BOX_VERTICAL or trailing BOX_VERTICAL)
508
+ const INNER = 52;
509
+ const hBar = BOX_HORIZONTAL.repeat(INNER + 2); // +2 for the spaces beside borders
510
+
511
+ const lines: string[] = [];
512
+
513
+ // ── Top border ────────────────────────────────────────────────────────
514
+ lines.push(accentPrimary(BOX_TOP_LEFT + hBar + BOX_TOP_RIGHT));
515
+
516
+ // ── ASCII Logo ────────────────────────────────────────────────────────
517
+ for (const logoLine of CLEO_ASCII_LOGO) {
518
+ // Pad to INNER width so border chars align
519
+ lines.push(padBannerLine(logoLine + " ", accentPrimary(logoLine), INNER));
520
+ }
521
+
522
+ // ── Header row ────────────────────────────────────────────────────────
523
+ const headerRaw = ` ${ICON_FORGE} Agentic Dev Forge ${ICON_FORGE} ${truncate(projectName, 18)}`;
524
+ lines.push(
525
+ padBannerLine(
526
+ " " + headerRaw + " ",
527
+ " " + bold(accentPrimary(`${ICON_FORGE} Agentic Dev Forge ${ICON_FORGE}`)) +
528
+ accentPrimary(" ") +
529
+ bold(textSecondary(truncate(projectName, 18))),
530
+ INNER,
531
+ ),
532
+ );
533
+
534
+ // ── Task summary divider ──────────────────────────────────────────────
535
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
536
+
537
+ // ── Task counts ───────────────────────────────────────────────────────
538
+ const activeStr = String(tasks.active);
539
+ const pendingStr = String(tasks.pending);
540
+ const doneStr = String(tasks.done);
541
+ const blockedStr = String(tasks.blocked);
542
+
543
+ const countsRaw =
544
+ ` Tasks: ${activeStr} active ${pendingStr} pending ${doneStr} done` +
545
+ (tasks.blocked > 0 ? ` ${blockedStr} blocked` : "");
546
+
547
+ const countsDot = tasks.active > 0
548
+ ? accentSuccess(DOT_FILLED)
549
+ : (tasks.pending > 0 ? accentWarning(DOT_FILLED) : textSecondary(DOT_FILLED));
550
+
551
+ const countsStyled =
552
+ ` ${countsDot} Tasks: ` +
553
+ accentSuccess(activeStr) + textSecondary(" active ") +
554
+ accentWarning(pendingStr) + textSecondary(" pending ") +
555
+ textSecondary(doneStr + " done") +
556
+ (tasks.blocked > 0
557
+ ? " " + accentError(blockedStr) + textSecondary(" blocked")
558
+ : "");
559
+
560
+ lines.push(padBannerLine(" " + countsRaw + " ", " " + countsStyled.trimStart(), INNER));
561
+
562
+ // ── Current task ──────────────────────────────────────────────────────
563
+ if (currentTask) {
564
+ const taskRaw = ` Focus: [${currentTask.id}] ${truncate(currentTask.title, 32)}`;
565
+ const taskStyled =
566
+ ` ${textSecondary("Focus:")} ` +
567
+ accentPrimary(`[${currentTask.id}]`) + " " +
568
+ bold(textSecondary(truncate(currentTask.title, 32)));
569
+ lines.push(padBannerLine(" " + taskRaw + " ", taskStyled, INNER));
570
+ } else {
571
+ const noTaskRaw = " Focus: none";
572
+ lines.push(
573
+ padBannerLine(
574
+ " " + noTaskRaw + " ",
575
+ " " + textSecondary("Focus:") + " " + textTertiary("none"),
576
+ INNER,
577
+ ),
578
+ );
579
+ }
580
+
581
+ // ── Session info ──────────────────────────────────────────────────────
582
+ if (session.active) {
583
+ const shortId = session.id.length > 22 ? session.id.slice(0, 22) + ".." : session.id;
584
+ const sessionName = session.name.length > 0 ? truncate(session.name, 20) : shortId;
585
+ const sessionRaw = ` Session: ${sessionName}`;
586
+ const sessionStyled =
587
+ ` ${textSecondary("Session:")} ` + accentPrimary(sessionName);
588
+ lines.push(padBannerLine(" " + sessionRaw + " ", sessionStyled, INNER));
589
+ }
590
+
591
+ // ── Memory bridge stats ───────────────────────────────────────────────
592
+ if (memStats.totalEntries > 0) {
593
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
594
+ const memRaw = ` Memory: ${memStats.totalEntries} entries`;
595
+ const memStyled =
596
+ ` ${textSecondary("Memory:")} ` + accentSuccess(String(memStats.totalEntries)) +
597
+ textSecondary(" entries");
598
+ lines.push(padBannerLine(" " + memRaw + " ", memStyled, INNER));
599
+
600
+ for (const item of memStats.topThree) {
601
+ const truncItem = truncate(item, INNER - 6);
602
+ lines.push(
603
+ padBannerLine(
604
+ ` * ${truncItem} `,
605
+ ` ${textTertiary("*")} ${textSecondary(truncItem)}`,
606
+ INNER,
607
+ ),
608
+ );
609
+ }
610
+ }
611
+
612
+ // ── Recent decisions ──────────────────────────────────────────────────
613
+ if (decisions.length > 0) {
614
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
615
+ const decHeaderRaw = " What we decided recently:";
616
+ lines.push(
617
+ padBannerLine(
618
+ " " + decHeaderRaw + " ",
619
+ " " + bold(textSecondary("What we decided recently:")),
620
+ INNER,
621
+ ),
622
+ );
623
+ for (const dec of decisions) {
624
+ const label = dec.date ? `[${dec.date}]` : `[${dec.id}]`;
625
+ const decRaw = ` ${label} ${truncate(dec.title, INNER - label.length - 5)}`;
626
+ const decStyled =
627
+ ` ${textTertiary(label)} ` +
628
+ textSecondary(truncate(dec.title, INNER - label.length - 5));
629
+ lines.push(padBannerLine(" " + decRaw + " ", decStyled, INNER));
630
+ }
631
+ }
632
+
633
+ // ── Quick action hints ────────────────────────────────────────────────
634
+ if (hints.hints.length > 0) {
635
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
636
+ for (const hint of hints.hints) {
637
+ const truncHint = truncate(hint, INNER - 5);
638
+ lines.push(
639
+ padBannerLine(
640
+ ` > ${truncHint} `,
641
+ ` ${accentWarning(">")} ${accentWarning(truncHint)}`,
642
+ INNER,
643
+ ),
644
+ );
645
+ }
646
+ }
647
+
648
+ // ── Handoff note ──────────────────────────────────────────────────────
649
+ const note = handoffNote ?? session.handoffNote;
650
+ if (note) {
651
+ // Split into two lines if the note is long (up to 88 chars total)
652
+ lines.push(accentPrimary(BOX_LEFT_T + hBar + BOX_RIGHT_T));
653
+
654
+ const maxNoteChars = (INNER - 4) * 2; // two lines of inner width
655
+ const truncatedNote = truncate(note, maxNoteChars);
656
+
657
+ const noteLineMaxLen = INNER - 4; // leave space for " > " prefix
658
+ const noteLine1Raw = truncatedNote.slice(0, noteLineMaxLen);
659
+ const noteLine2Raw = truncatedNote.length > noteLineMaxLen
660
+ ? truncatedNote.slice(noteLineMaxLen)
661
+ : null;
662
+
663
+ lines.push(
664
+ padBannerLine(
665
+ ` > ${noteLine1Raw} `,
666
+ ` ${accentWarning(">")} ${accentWarning(noteLine1Raw)}`,
667
+ INNER,
668
+ ),
669
+ );
670
+
671
+ if (noteLine2Raw) {
672
+ lines.push(
673
+ padBannerLine(
674
+ ` ${noteLine2Raw} `,
675
+ ` ${textSecondary(noteLine2Raw)}`,
676
+ INNER,
677
+ ),
678
+ );
679
+ }
680
+ }
681
+
682
+ // ── Bottom border ─────────────────────────────────────────────────────
683
+ lines.push(accentPrimary(BOX_BOTTOM_LEFT + hBar + BOX_BOTTOM_RIGHT));
684
+
685
+ return lines;
686
+ }
687
+
688
+ // ============================================================================
689
+ // Project name detection
690
+ // ============================================================================
691
+
692
+ /**
693
+ * Detect the project name for display in the startup banner.
694
+ *
695
+ * Resolution order:
696
+ * 1. `name` field from `.cleo/project-info.json`
697
+ * 2. `name` field from `package.json` in `projectDir`
698
+ * 3. Last path segment of `projectDir`
699
+ *
700
+ * @param projectDir - The project root directory.
701
+ * @returns The resolved project display name.
702
+ */
703
+ export function detectProjectName(projectDir: string): string {
704
+ // Try .cleo/project-info.json first
705
+ try {
706
+ const infoPath = join(projectDir, ".cleo", "project-info.json");
707
+ if (existsSync(infoPath)) {
708
+ const raw = readFileSync(infoPath, "utf-8");
709
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
710
+ if (typeof parsed["name"] === "string" && parsed["name"].length > 0) {
711
+ return parsed["name"];
712
+ }
713
+ }
714
+ } catch {
715
+ // Fall through
716
+ }
717
+
718
+ // Try package.json
719
+ try {
720
+ const pkgPath = join(projectDir, "package.json");
721
+ if (existsSync(pkgPath)) {
722
+ const raw = readFileSync(pkgPath, "utf-8");
723
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
724
+ if (typeof parsed["name"] === "string" && parsed["name"].length > 0) {
725
+ return parsed["name"];
726
+ }
727
+ }
728
+ } catch {
729
+ // Fall through
730
+ }
731
+
732
+ // Fall back to last path segment
733
+ const parts = projectDir.replace(/\\/g, "/").split("/").filter(Boolean);
734
+ return parts[parts.length - 1] ?? "unknown-project";
735
+ }
736
+
737
+ // ============================================================================
738
+ // Pi extension factory
739
+ // ============================================================================
740
+
741
+ // ============================================================================
742
+ // Shared fetch helper (used by both session_start and /cleo:status)
743
+ // ============================================================================
744
+
745
+ /**
746
+ * Fetch all data needed to render the startup banner.
747
+ *
748
+ * All CLI calls are issued in parallel and are best-effort — no failure
749
+ * will throw or block. Returns a fully-populated data bundle.
750
+ *
751
+ * @param cwd - Project root directory.
752
+ * @returns All banner data.
753
+ */
754
+ async function fetchBannerData(cwd: string): Promise<{
755
+ tasks: TaskSummary;
756
+ session: SessionInfo;
757
+ currentTask: CurrentTask | null;
758
+ handoffNote: string | null;
759
+ projectName: string;
760
+ decisions: DecisionEntry[];
761
+ memStats: MemoryBridgeStats;
762
+ hints: QuickHints;
763
+ }> {
764
+ const [dashResult, sessionResult, currentResult, decisionsResult] =
765
+ await Promise.allSettled([
766
+ execFileAsync("cleo", ["dash", "--json"], { timeout: 8_000, cwd }),
767
+ execFileAsync("cleo", ["session", "status", "--json"], { timeout: 8_000, cwd }),
768
+ execFileAsync("cleo", ["current", "--json"], { timeout: 8_000, cwd }),
769
+ execFileAsync("cleo", ["memory", "decision-find", "--limit", "3", "--json"], {
770
+ timeout: 8_000,
771
+ cwd,
772
+ }),
773
+ ]);
774
+
775
+ const tasks =
776
+ dashResult.status === "fulfilled"
777
+ ? parseDashSummary(dashResult.value.stdout)
778
+ : { active: 0, pending: 0, done: 0, total: 0, blocked: 0 };
779
+
780
+ const session =
781
+ sessionResult.status === "fulfilled"
782
+ ? parseSessionInfo(sessionResult.value.stdout)
783
+ : { active: false, id: "", name: "", currentTaskId: null, handoffNote: null };
784
+
785
+ const currentTask =
786
+ currentResult.status === "fulfilled"
787
+ ? parseCurrentTask(currentResult.value.stdout)
788
+ : null;
789
+
790
+ const decisions =
791
+ decisionsResult.status === "fulfilled"
792
+ ? parseRecentDecisions(decisionsResult.value.stdout)
793
+ : [];
794
+
795
+ // Memory bridge stats — synchronous read from filesystem
796
+ const bridgeContent = readMemoryBridgeContent(cwd);
797
+ const memStats = bridgeContent
798
+ ? parseMemoryBridgeStats(bridgeContent)
799
+ : { totalEntries: 0, topThree: [] };
800
+
801
+ const handoffNote = bridgeContent ? extractNoteFromBridgeContent(bridgeContent) : null;
802
+ const projectName = detectProjectName(cwd);
803
+ const hints = buildQuickHints(tasks, session, currentTask);
804
+
805
+ return { tasks, session, currentTask, handoffNote, projectName, decisions, memStats, hints };
806
+ }
807
+
808
+ /**
809
+ * Read raw `.cleo/memory-bridge.md` content.
810
+ *
811
+ * Returns null when the file does not exist or cannot be read.
812
+ *
813
+ * @param projectDir - The project root directory.
814
+ * @returns The raw file content, or null.
815
+ */
816
+ function readMemoryBridgeContent(projectDir: string): string | null {
817
+ try {
818
+ const bridgePath = join(projectDir, ".cleo", "memory-bridge.md");
819
+ if (!existsSync(bridgePath)) return null;
820
+ const content = readFileSync(bridgePath, "utf-8");
821
+ return content.length > 0 ? content : null;
822
+ } catch {
823
+ return null;
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Extract the last session handoff note from raw memory-bridge.md content.
829
+ *
830
+ * Prefers the `## Last Session` section's `Note:` line.
831
+ *
832
+ * @param content - Raw memory-bridge.md content.
833
+ * @returns The last session note, or null.
834
+ */
835
+ function extractNoteFromBridgeContent(content: string): string | null {
836
+ try {
837
+ const noteMatch = content.match(/[-*]\s+\*\*Note\*\*:\s*(.+)/m);
838
+ if (noteMatch?.[1]) return noteMatch[1].trim();
839
+ const altMatch = content.match(/\*\*Note\*\*[:\s]+(.+)/m);
840
+ if (altMatch?.[1]) return altMatch[1].trim();
841
+ return null;
842
+ } catch {
843
+ return null;
844
+ }
845
+ }
846
+
847
+ // ============================================================================
848
+ // Pi extension factory
849
+ // ============================================================================
850
+
851
+ /**
852
+ * Pi extension factory for the CleoOS branded startup experience.
853
+ *
854
+ * Registers:
855
+ * - `session_start` — fetches all data, renders the branded startup banner
856
+ * - `/cleo:status` — on-demand project status refresh
857
+ * - `/cleo:focus <task-id>` — focus on a task from inside Pi
858
+ * - `/cleo:end-session [note]` — end the current session from inside Pi
859
+ *
860
+ * All operations are best-effort — failures are silently swallowed so Pi
861
+ * is never blocked by CLEO unavailability.
862
+ *
863
+ * @param pi - The Pi extension API instance.
864
+ */
865
+ export default function (pi: ExtensionAPI): void {
866
+ pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
867
+ const data = await fetchBannerData(ctx.cwd);
868
+
869
+ const bannerLines = buildStartupBanner(
870
+ data.tasks,
871
+ data.session,
872
+ data.currentTask,
873
+ data.handoffNote,
874
+ data.projectName,
875
+ data.decisions,
876
+ data.memStats,
877
+ data.hints,
878
+ );
879
+
880
+ if (ctx.hasUI) {
881
+ ctx.ui.setWidget("cleo-startup-banner", bannerLines, {
882
+ placement: "aboveEditor",
883
+ });
884
+
885
+ // Compact status bar entry
886
+ const taskSummary = `${data.tasks.active}a ${data.tasks.pending}p ${data.tasks.done}d`;
887
+ ctx.ui.setStatus(
888
+ "cleo-startup",
889
+ `${ICON_FORGE} ${data.projectName.split("/").pop() ?? data.projectName} [${taskSummary}]`,
890
+ );
891
+ } else {
892
+ // No UI — print to stderr as a text summary (visible in TTY mode)
893
+ process.stderr.write(bannerLines.join("\n") + "\n");
894
+ }
895
+ });
896
+
897
+ // -------------------------------------------------------------------------
898
+ // Command: /cleo:status — on-demand project status refresh
899
+ // -------------------------------------------------------------------------
900
+ pi.registerCommand("cleo:status", {
901
+ description: "Show CleoOS project status: tasks, session, decisions, and hints",
902
+ handler: async (_args: string, ctx: ExtensionContext) => {
903
+ const data = await fetchBannerData(ctx.cwd);
904
+
905
+ const bannerLines = buildStartupBanner(
906
+ data.tasks,
907
+ data.session,
908
+ data.currentTask,
909
+ data.handoffNote,
910
+ data.projectName,
911
+ data.decisions,
912
+ data.memStats,
913
+ data.hints,
914
+ );
915
+
916
+ // Refresh the widget too
917
+ if (ctx.hasUI) {
918
+ ctx.ui.setWidget("cleo-startup-banner", bannerLines, {
919
+ placement: "aboveEditor",
920
+ });
921
+ ctx.ui.notify("CleoOS status refreshed", "info");
922
+ }
923
+
924
+ pi.sendMessage(
925
+ {
926
+ customType: "cleo-status",
927
+ content: bannerLines.join("\n"),
928
+ display: true,
929
+ },
930
+ { triggerTurn: false },
931
+ );
932
+ },
933
+ });
934
+
935
+ // -------------------------------------------------------------------------
936
+ // Command: /cleo:focus <task-id> — focus on a task
937
+ // -------------------------------------------------------------------------
938
+ pi.registerCommand("cleo:focus", {
939
+ description: "Focus on a CLEO task: /cleo:focus <task-id>",
940
+ handler: async (args: string, ctx: ExtensionContext) => {
941
+ const taskId = args.trim();
942
+ if (!taskId) {
943
+ pi.sendMessage(
944
+ {
945
+ customType: "cleo-focus",
946
+ content: "Usage: /cleo:focus <task-id>",
947
+ display: true,
948
+ },
949
+ { triggerTurn: false },
950
+ );
951
+ return;
952
+ }
953
+
954
+ let resultText: string;
955
+ try {
956
+ const { stdout } = await execFileAsync(
957
+ "cleo",
958
+ ["start", taskId],
959
+ { timeout: 8_000, cwd: ctx.cwd },
960
+ );
961
+ resultText = stdout.trim() || `Focused on task ${taskId}`;
962
+ } catch (err: unknown) {
963
+ resultText = `Failed to focus on ${taskId}: ${err instanceof Error ? err.message : String(err)}`;
964
+ }
965
+
966
+ pi.sendMessage(
967
+ {
968
+ customType: "cleo-focus",
969
+ content: resultText,
970
+ display: true,
971
+ },
972
+ { triggerTurn: false },
973
+ );
974
+
975
+ if (ctx.hasUI) {
976
+ ctx.ui.notify(`Focused: ${taskId}`, "info");
977
+ }
978
+ },
979
+ });
980
+
981
+ // -------------------------------------------------------------------------
982
+ // Command: /cleo:end-session [note] — end the current CLEO session
983
+ // -------------------------------------------------------------------------
984
+ pi.registerCommand("cleo:end-session", {
985
+ description: "End the current CLEO session with an optional handoff note",
986
+ handler: async (args: string, ctx: ExtensionContext) => {
987
+ const note = args.trim() || "Session ended from CleoOS Hearth";
988
+ let resultText: string;
989
+ try {
990
+ const { stdout } = await execFileAsync(
991
+ "cleo",
992
+ ["session", "end", "--note", note],
993
+ { timeout: 15_000, cwd: ctx.cwd },
994
+ );
995
+ resultText = stdout.trim() || "Session ended";
996
+ } catch (err: unknown) {
997
+ resultText = `Failed to end session: ${err instanceof Error ? err.message : String(err)}`;
998
+ }
999
+
1000
+ pi.sendMessage(
1001
+ {
1002
+ customType: "cleo-end-session",
1003
+ content: resultText,
1004
+ display: true,
1005
+ },
1006
+ { triggerTurn: false },
1007
+ );
1008
+
1009
+ if (ctx.hasUI) {
1010
+ ctx.ui.notify("CLEO session ended", "info");
1011
+ }
1012
+ },
1013
+ });
1014
+
1015
+ // session_shutdown: no cleanup needed — Pi clears widgets on shutdown
1016
+ pi.on("session_shutdown", async () => {
1017
+ // Intentional no-op
1018
+ });
1019
+ }