@kal-elsam/kairo-runtime 0.13.1 → 0.14.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.14.0 — 2026-08-07 (Kairo Runtime)
9
+
10
+ Minor release. Simpler Cockpit, one-command CLI self-update, pnpm for
11
+ contributors, and a minimal IDE status panel. Publish tag:
12
+ `kairo-runtime-v0.14.0`.
13
+
14
+ ### Added
15
+
16
+ - Cockpit Home with three tabs (Home · Settings · History) and two clear
17
+ actions (prepare/repair + Configure). Secondary destinations stay in `/`.
18
+ - `kairo update`: update the Kairo CLI from npm (`--yes` applies). Workspace
19
+ template refresh stays at `kairo update --scope=workspace`.
20
+ - VS Code / Cursor extension scaffold in `packages/kairo-vscode`: status bar +
21
+ explorer tree from `kairo status --json`; sync/doctor/cockpit open a terminal
22
+ (no silent writes). Local VSIX via `npm run package`.
23
+
24
+ ### Changed
25
+
26
+ - Local development and CI use **pnpm 10.x** (`packageManager` pinned). End-user
27
+ installs and `npm publish` (OIDC Trusted Publishing) stay on npm.
28
+ - Install scripts blocked by default (`pnpm.onlyBuiltDependencies: []`);
29
+ `.npmrc` sets `strict-dep-builds` and `minimum-release-age=1440`.
30
+
8
31
  ## 0.13.1 — 2026-08-07 (Kairo Runtime)
9
32
 
10
33
  Patch release. Docs/UX/license polish for npm adoption. Publish tag:
package/README.md CHANGED
@@ -24,25 +24,23 @@ KAIRO
24
24
  Needs attention · 4/4 agents · drift pending
25
25
  Kairo coordinates installed AI agents for this project.
26
26
 
27
- Fix drift
28
- Run "kairo sync" to repair managed content.
29
- Enter → preview in Governance
30
-
31
- Last activity · codex · failed
32
- Obsidian not connected · open Settings to choose your vault
33
- Update available · run kairo updates check
34
- 9 more in Details · Space
27
+ Repair 3 changes
28
+ You will see the exact plan before anything is written.
29
+
30
+ Configure
31
+ Agents, Obsidian vault, integrations.
35
32
  ```
36
33
 
37
- `↑↓` move · `Enter` open · `Space` details · `R` refresh · `Esc` back · `?` help
34
+ `↑↓` move · `Enter` open · `Tab` nav · `Space` details · `R` refresh · `Esc` back · `?` help
38
35
 
39
- ## The four commands
36
+ ## Day-to-day commands
40
37
 
41
38
  | Command | What it does |
42
39
  |---|---|
43
40
  | `kairo` | Open the cockpit |
44
41
  | `kairo status` | See how your setup is doing |
45
42
  | `kairo sync` | Repair what drifted |
43
+ | `kairo update` | Update Kairo itself from npm |
46
44
  | `kairo doctor` | Deeper health checks |
47
45
 
48
46
  Run `kairo help --all` for everything else, or read the
@@ -70,6 +68,10 @@ npx @kal-elsam/kairo-runtime
70
68
  # Install globally
71
69
  npm i -g @kal-elsam/kairo-runtime && kairo
72
70
 
71
+ # Later: update the CLI itself
72
+ kairo update
73
+ kairo update --yes
74
+
73
75
  # Preview the setup plan, write nothing
74
76
  npx @kal-elsam/kairo-runtime --dry-run
75
77
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kal-elSam/harness#readme",
@@ -62,6 +62,10 @@
62
62
  "engines": {
63
63
  "node": ">=20.12.0"
64
64
  },
65
+ "packageManager": "pnpm@10.34.5",
66
+ "pnpm": {
67
+ "onlyBuiltDependencies": []
68
+ },
65
69
  "dependencies": {
66
70
  "@clack/prompts": "^1.7.0",
67
71
  "@modelcontextprotocol/server": "2.0.0",
@@ -86,7 +86,7 @@ const asciiNav = buildNavModel({
86
86
  assert.equal(asciiNav.items[1].marker, ">");
87
87
  assert.equal(asciiNav.items[0].current, true);
88
88
  assert.equal(asciiNav.items[1].selected, true);
89
- assert.match(asciiNav.explanation, /Repair|Govern|Activity|Usage|Settings|Overview/i);
89
+ assert.match(asciiNav.explanation, /agents|Obsidian|integrations/i);
90
90
 
91
91
  function applyKey(state, keyAction) {
92
92
  const routed = routeCockpitKey(state, keyAction);
@@ -99,25 +99,29 @@ function smokeNavigation(layoutMode) {
99
99
  layoutMode,
100
100
  region: layoutMode === LAYOUT_MODES.MINIMAL ? COCKPIT_REGIONS.CONTENT : COCKPIT_REGIONS.NAV
101
101
  });
102
- const diagnosticsIndex = COCKPIT_NAV.findIndex((item) => item.id === "governance");
103
- while (state.navIndex < diagnosticsIndex) {
102
+ const settingsIndex = COCKPIT_NAV.findIndex((item) => item.id === "settings");
103
+ while (state.navIndex < settingsIndex) {
104
104
  state = applyKey(state, { type: "arrow", direction: "down" });
105
105
  }
106
106
  state = applyKey(state, { type: "enter" });
107
- assert.equal(state.view, ORCHESTRATOR_VIEWS.CHANGES);
108
- assert.equal(state.region, COCKPIT_REGIONS.NAV);
107
+ assert.equal(state.view, ORCHESTRATOR_VIEWS.PROFILE);
108
+ assert.equal(state.region, COCKPIT_REGIONS.CONTENT);
109
109
 
110
- state = applyKey(state, { type: "arrow", direction: "up" });
110
+ state = applyKey(state, { type: "tab" });
111
+ assert.equal(state.region, COCKPIT_REGIONS.NAV);
112
+ while (state.navIndex > 0) {
113
+ state = applyKey(state, { type: "arrow", direction: "up" });
114
+ }
111
115
  state = applyKey(state, { type: "enter" });
112
116
  assert.equal(state.view, ORCHESTRATOR_VIEWS.HOME);
113
- assert.equal(state.region, COCKPIT_REGIONS.NAV);
117
+ assert.equal(state.region, COCKPIT_REGIONS.CONTENT);
114
118
 
115
119
  const footer = buildFooterModel({
116
- view: ORCHESTRATOR_VIEWS.CHANGES,
117
- region: COCKPIT_REGIONS.NAV,
120
+ view: ORCHESTRATOR_VIEWS.HOME,
121
+ region: COCKPIT_REGIONS.CONTENT,
118
122
  unicode: false
119
123
  });
120
- assert.doesNotMatch(footer.text, /Tab/);
124
+ assert.match(footer.text, /Tab/);
121
125
 
122
126
  state = applyKey(state, { type: "escape" });
123
127
  assert.equal(state.view, ORCHESTRATOR_VIEWS.HOME);
package/src/cli.js CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  runGlobalReport
31
31
  } from "./global/global-cli.js";
32
32
  import { runEcosystemUpdatesCheck } from "./global/updates-cli.js";
33
+ import { runSelfUpdate } from "./global/self-update.js";
33
34
  import { applyPolicyToOptions, loadPolicyFile } from "./global/policy.js";
34
35
  import { resolveHomeDir } from "./global/paths.js";
35
36
  import { runWorkspaceDetect, runWorkspaceDoctor, runWorkspaceInit, runWorkspaceUpdate } from "./workspace-cli.js";
@@ -173,9 +174,15 @@ export async function runCli(argv) {
173
174
  });
174
175
  return;
175
176
  case "update":
176
- await dispatchByScope(options, "agent-global", {
177
- "agent-global": () => runGlobalInstall(options, packageManifest, packageRoot, { update: true }),
178
- workspace: () => runWorkspaceUpdate(options, packageManifest, packageRoot)
177
+ if (options.scope === "workspace") {
178
+ await runWorkspaceUpdate(options, packageManifest, packageRoot);
179
+ return;
180
+ }
181
+ await runSelfUpdate({
182
+ packageName: packageManifest.name,
183
+ cliVersion: packageManifest.version,
184
+ yes: optionsWithPolicy.yes === true,
185
+ json: optionsWithPolicy.json === true
179
186
  });
180
187
  return;
181
188
  case "doctor":
@@ -29,6 +29,7 @@ Usage:
29
29
  ${cli} Open the cockpit (setup on first run)
30
30
  ${cli} status See how your setup is doing
31
31
  ${cli} sync Repair what drifted
32
+ ${cli} update Update Kairo itself (npm)
32
33
  ${cli} doctor Deeper health checks
33
34
 
34
35
  ${cli} help --all Every command and flag
@@ -63,7 +64,8 @@ Bootstrap: see README.md and docs/install.md (curl install.sh or npx ${PACKAGE_N
63
64
  ${cli} updates check [--json] [--force]
64
65
  ${cli} install [--agents <list|all>] [--components <list>] [--dry-run]
65
66
  ${cli} install --no-default-components
66
- ${cli} update [--dry-run] Technical repair alias (prefer sync)
67
+ ${cli} update [--yes] [--json] Update the Kairo CLI from npm
68
+ ${cli} update --scope=workspace [--dry-run] Refresh workspace template files
67
69
  ${cli} uninstall [--dry-run]
68
70
 
69
71
  ## Agents & runs
@@ -143,7 +145,7 @@ Commands:
143
145
  updates Read-only ecosystem update check (kairo/hermes/gentle/skills). Never applies.
144
146
  install Non-interactive configure (agent-global) or legacy workspace scaffold.
145
147
  doctor Detailed health checks for managed state and configs.
146
- update Technical repair alias (prefer sync for day-to-day use).
148
+ update Update the Kairo CLI from npm (--yes applies). Workspace templates: --scope=workspace.
147
149
  detect Inspect global agents and the current project. Read-only.
148
150
  adapters Official adapter matrix: roots, config files, detected/managed.
149
151
  explain Read-only audit of managed adapters, configs, markers, and backups.
@@ -122,9 +122,7 @@ export function reduceCockpitUi(state, action) {
122
122
  if (state.region === COCKPIT_REGIONS.SYSTEM) return state;
123
123
 
124
124
  const navigatesNav = state.region === COCKPIT_REGIONS.NAV
125
- || isNavFocusedView(state.view)
126
- || (state.view === ORCHESTRATOR_VIEWS.HOME
127
- && state.layoutMode === LAYOUT_MODES.MINIMAL);
125
+ || isNavFocusedView(state.view);
128
126
 
129
127
  if (navigatesNav) {
130
128
  const delta = action.direction === "up" ? -1 : 1;
@@ -11,7 +11,9 @@ export function resolveEnterNavIntent({
11
11
  } = {}) {
12
12
  if (!navItem) return { kind: "noop" };
13
13
 
14
- const isOverview = navItem.id === "overview" || navItem.view === ORCHESTRATOR_VIEWS.HOME;
14
+ const isOverview = navItem.id === "home"
15
+ || navItem.id === "overview"
16
+ || navItem.view === ORCHESTRATOR_VIEWS.HOME;
15
17
  if (isOverview) {
16
18
  if (currentView === ORCHESTRATOR_VIEWS.HOME && ctaDestination) {
17
19
  if (ctaDestination === "setup") return { kind: "activate-setup" };
@@ -3,7 +3,6 @@ import { ORCHESTRATOR_VIEWS } from "./orchestrator-state.js";
3
3
  import { LAYOUT_MODES } from "./layout.js";
4
4
 
5
5
  const NAV_FOCUSED_VIEWS = new Set([
6
- ORCHESTRATOR_VIEWS.HOME,
7
6
  ORCHESTRATOR_VIEWS.IDES,
8
7
  ORCHESTRATOR_VIEWS.MODULES,
9
8
  ORCHESTRATOR_VIEWS.CHANGES,
@@ -14,6 +13,7 @@ const NAV_FOCUSED_VIEWS = new Set([
14
13
  ]);
15
14
 
16
15
  const CONTENT_INTERACTIVE_VIEWS = new Set([
16
+ ORCHESTRATOR_VIEWS.HOME,
17
17
  ORCHESTRATOR_VIEWS.RUNS,
18
18
  ORCHESTRATOR_VIEWS.ACTIVE_RUNS,
19
19
  ORCHESTRATOR_VIEWS.RECENT_RUNS,
@@ -102,6 +102,12 @@ export function routeCockpitKey(state, keyAction) {
102
102
  if (state.region === COCKPIT_REGIONS.NAV || isNavFocusedView(state.view)) {
103
103
  return { type: "enter-nav" };
104
104
  }
105
+ if (
106
+ state.region === COCKPIT_REGIONS.CONTENT
107
+ && state.view === ORCHESTRATOR_VIEWS.HOME
108
+ ) {
109
+ return { type: "enter-home-button" };
110
+ }
105
111
  return null;
106
112
  default:
107
113
  return null;
@@ -17,23 +17,33 @@ export const COCKPIT_REGIONS = {
17
17
 
18
18
  export const COCKPIT_NAV = [
19
19
  {
20
- id: "overview",
21
- label: "Overview",
20
+ id: "home",
21
+ label: "Home",
22
22
  view: ORCHESTRATOR_VIEWS.HOME,
23
- description: "Status, next action, activity, alerts, and tokens."
23
+ description: "What needs you, and what to do about it."
24
24
  },
25
+ {
26
+ id: "settings",
27
+ label: "Settings",
28
+ view: ORCHESTRATOR_VIEWS.PROFILE,
29
+ description: "Choose agents, connect Obsidian, add integrations."
30
+ },
31
+ {
32
+ id: "history",
33
+ label: "History",
34
+ view: ORCHESTRATOR_VIEWS.ACTIVITY,
35
+ description: "What Kairo changed, and how to undo it."
36
+ }
37
+ ];
38
+
39
+ /** Destinations reachable via the action palette, not the top nav. */
40
+ export const COCKPIT_SECONDARY = [
25
41
  {
26
42
  id: "governance",
27
43
  label: "Governance",
28
44
  view: ORCHESTRATOR_VIEWS.CHANGES,
29
45
  description: "Repair drift and apply governed changes."
30
46
  },
31
- {
32
- id: "activity",
33
- label: "Activity",
34
- view: ORCHESTRATOR_VIEWS.ACTIVITY,
35
- description: "Operations, backups, and recovery."
36
- },
37
47
  {
38
48
  id: "orchestration",
39
49
  label: "Orchestration",
@@ -45,12 +55,6 @@ export const COCKPIT_NAV = [
45
55
  label: "Usage",
46
56
  view: ORCHESTRATOR_VIEWS.USAGE,
47
57
  description: "Token and context pressure when auditable."
48
- },
49
- {
50
- id: "settings",
51
- label: "Settings",
52
- view: ORCHESTRATOR_VIEWS.PROFILE,
53
- description: "Profile, policy, and guided configuration."
54
58
  }
55
59
  ];
56
60
 
@@ -64,8 +68,8 @@ export function regionsForLayout(layoutMode) {
64
68
 
65
69
  export function navIndexForView(view, items = COCKPIT_NAV) {
66
70
  if (isRunsBranchView(view)) {
67
- const orchIndex = items.findIndex((item) => item.id === "orchestration");
68
- return orchIndex >= 0 ? orchIndex : 0;
71
+ const historyIndex = items.findIndex((item) => item.id === "history");
72
+ return historyIndex >= 0 ? historyIndex : 0;
69
73
  }
70
74
  const index = items.findIndex((item) => item.view === view);
71
75
  return index >= 0 ? index : 0;
@@ -105,6 +109,7 @@ export function resolveNavStatusSummary(item, {
105
109
  const backups = snapshot?.backups?.count ?? 0;
106
110
 
107
111
  switch (item.id) {
112
+ case "home":
108
113
  case "overview":
109
114
  return snapshot?.health?.replaceAll("_", " ")
110
115
  ?? resolveProjectReadiness({
@@ -114,6 +119,7 @@ export function resolveNavStatusSummary(item, {
114
119
  }).label;
115
120
  case "governance":
116
121
  return changes > 0 ? `${changes} pending` : "Clean";
122
+ case "history":
117
123
  case "activity":
118
124
  return backups > 0 ? `${backups} backups` : "No backups";
119
125
  case "orchestration":
@@ -174,6 +180,7 @@ export function buildNavModel({
174
180
  const mapped = items.map((item, index) => {
175
181
  const isSelected = index === navIndex;
176
182
  const isCurrent = item.view === currentView
183
+ || (item.id === "history" && isRunsBranchView(currentView))
177
184
  || (item.id === "orchestration" && isRunsBranchView(currentView));
178
185
  return {
179
186
  ...item,
@@ -185,11 +192,13 @@ export function buildNavModel({
185
192
  };
186
193
  });
187
194
 
195
+ const explanation = !selected || selected.id === "home" || selected.id === "overview"
196
+ ? ""
197
+ : (selected.description ?? "");
198
+
188
199
  return {
189
200
  title: "NAVIGATION",
190
- explanation: selected
191
- ? `${selected.description} (${resolveNavStatusSummary(selected, { dashboard, diagnostics, snapshot })})`
192
- : "",
201
+ explanation,
193
202
  items: mapped
194
203
  };
195
204
  }
@@ -320,7 +329,7 @@ export function buildFooterModel({
320
329
  // HOME footer must stay on one line at 80 cols (frame already near 24 rows).
321
330
  if (view === ORCHESTRATOR_VIEWS.HOME) {
322
331
  return {
323
- text: ["↑↓", "Enter", "Space", "R", "?", "/", "Esc"].join(` ${glyphs.bullet} `),
332
+ text: ["↑↓", "Enter", "Tab", "Space", "R", "?", "/", "Esc"].join(` ${glyphs.bullet} `),
324
333
  columns: footerColumns
325
334
  };
326
335
  }
@@ -1,6 +1,8 @@
1
- import { COCKPIT_NAV } from "./cockpit-models.js";
1
+ import { COCKPIT_NAV, COCKPIT_SECONDARY } from "./cockpit-models.js";
2
2
  import { ORCHESTRATOR_VIEWS } from "./orchestrator-state.js";
3
3
 
4
+ const PALETTE_NAV_ITEMS = [...COCKPIT_NAV, ...COCKPIT_SECONDARY];
5
+
4
6
  export const PALETTE_KINDS = Object.freeze({
5
7
  NAVIGATE: "navigate",
6
8
  REFRESH: "refresh",
@@ -12,10 +14,13 @@ const DESTINATION_VIEWS = Object.freeze({
12
14
  changes: ORCHESTRATOR_VIEWS.CHANGES,
13
15
  governance: ORCHESTRATOR_VIEWS.CHANGES,
14
16
  "control-center": ORCHESTRATOR_VIEWS.HOME,
17
+ home: ORCHESTRATOR_VIEWS.HOME,
15
18
  ides: ORCHESTRATOR_VIEWS.IDES,
16
19
  modules: ORCHESTRATOR_VIEWS.MODULES,
17
20
  activity: ORCHESTRATOR_VIEWS.ACTIVITY,
21
+ history: ORCHESTRATOR_VIEWS.ACTIVITY,
18
22
  profile: ORCHESTRATOR_VIEWS.PROFILE,
23
+ settings: ORCHESTRATOR_VIEWS.PROFILE,
19
24
  runs: ORCHESTRATOR_VIEWS.RUNS,
20
25
  orchestration: ORCHESTRATOR_VIEWS.RUNS,
21
26
  usage: ORCHESTRATOR_VIEWS.USAGE,
@@ -30,12 +35,12 @@ export function canOpenPalette({ loading = false, busy = false, confirming = fal
30
35
  return !loading && !busy && !confirming;
31
36
  }
32
37
 
33
- /** Optional CTA + six destinations + Refresh + Help. No write shortcuts. */
38
+ /** Optional CTA + primary/secondary destinations + Refresh + Help. No write shortcuts. */
34
39
  export function buildPaletteActions({
35
40
  ctaDestination = null,
36
41
  ctaTitle = null,
37
42
  ctaDetail = null,
38
- navItems = COCKPIT_NAV
43
+ navItems = PALETTE_NAV_ITEMS
39
44
  } = {}) {
40
45
  const actions = [];
41
46
  if (ctaDestination === "setup") {
@@ -100,7 +100,8 @@ export function renderCockpitView({
100
100
  governanceDetailsOpen = false,
101
101
  activityDetailsOpen = false,
102
102
  contentFocused = false,
103
- homeDir = null
103
+ homeDir = null,
104
+ hasGlobalState = false
104
105
  }) {
105
106
  if (palette) {
106
107
  return React.createElement(PalettePanel, { model: palette, colorEnabled });
@@ -112,7 +113,13 @@ export function renderCockpitView({
112
113
  detailsOpen: overviewDetailsOpen,
113
114
  colorEnabled,
114
115
  unicode,
115
- layoutMode
116
+ layoutMode,
117
+ selectedIndex: listIndex,
118
+ contentFocused,
119
+ hasGlobalState,
120
+ snapshot,
121
+ diagnostics,
122
+ dashboard
116
123
  });
117
124
  case ORCHESTRATOR_VIEWS.USAGE:
118
125
  return React.createElement(SemanticUsagePanel, {
@@ -46,6 +46,7 @@ import { LAYOUT_MODES } from "./layout.js";
46
46
  import { CHANGES_PHASE } from "./cockpit-changes.js";
47
47
  import { RECOVERY_PHASE, listRecoverySnapshots } from "./cockpit-recovery.js";
48
48
  import { SETTINGS_PHASE } from "./cockpit-settings.js";
49
+ import { buildOverviewButtons, OVERVIEW_BUTTON_COUNT } from "./ux/overview-actions.js";
49
50
 
50
51
  export function OrchestratorApp({
51
52
  homeDir,
@@ -245,7 +246,9 @@ export function OrchestratorApp({
245
246
  return;
246
247
  }
247
248
 
248
- const listLength = ui.view === ORCHESTRATOR_VIEWS.RUNS
249
+ const listLength = ui.view === ORCHESTRATOR_VIEWS.HOME
250
+ ? OVERVIEW_BUTTON_COUNT
251
+ : ui.view === ORCHESTRATOR_VIEWS.RUNS
249
252
  ? RUNS_HUB_ITEMS.length
250
253
  : ui.view === ORCHESTRATOR_VIEWS.ACTIVE_RUNS
251
254
  ? (data.dashboard?.activeRuns ?? []).length
@@ -301,6 +304,21 @@ export function OrchestratorApp({
301
304
  return;
302
305
  }
303
306
  }
307
+ if (routed.type === "enter-home-button") {
308
+ const buttons = buildOverviewButtons({
309
+ hasGlobalState,
310
+ snapshot: data.snapshot,
311
+ diagnostics: data.diagnostics,
312
+ dashboard: data.dashboard
313
+ });
314
+ const selected = buttons[Math.min(Math.max(0, ui.listIndex), buttons.length - 1)];
315
+ const intent = selected?.intent ?? null;
316
+ if (intent === "setup") {
317
+ finish({ cancelled: false, action: "setup" });
318
+ return;
319
+ }
320
+ if (intent && openDestination(intent)) return;
321
+ }
304
322
  dispatch(routed);
305
323
  return;
306
324
  }
@@ -583,7 +601,8 @@ export function OrchestratorApp({
583
601
  governanceDetailsOpen: ui.governanceDetailsOpen,
584
602
  activityDetailsOpen: ui.activityDetailsOpen,
585
603
  contentFocused: ui.region === COCKPIT_REGIONS.CONTENT,
586
- homeDir
604
+ homeDir,
605
+ hasGlobalState
587
606
  })
588
607
  )
589
608
  );
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Live semantic Overview for Cockpit HOME — product cover.
3
- * Nav owns the sole focus mark this panel never renders `>`.
3
+ * Content owns the button focus mark when region=content.
4
4
  * ASCII wordmark only here (wide/compact); minimal is textual.
5
5
  *
6
- * Rule: show purpose + one next step + a few plain-language needs.
6
+ * Rule: show purpose + two buttons + a few plain-language needs.
7
7
  * Machine/system noise stays out of the first screen (Details only).
8
8
  */
9
9
  import React from "react";
10
10
  import { Box, Text } from "ink";
11
11
  import { DASHBOARD_PURPOSE } from "../../dashboard-guidance.js";
12
12
  import { LAYOUT_MODES } from "../layout.js";
13
- import { COCKPIT_COLORS } from "../theme.js";
13
+ import { COCKPIT_COLORS, resolveGlyphs } from "../theme.js";
14
14
  import {
15
15
  overviewBrandTitle,
16
16
  shouldShowWordmark,
@@ -24,6 +24,7 @@ import {
24
24
  mapHealthTone,
25
25
  partitionCompanionLines
26
26
  } from "./overview-needs.js";
27
+ import { buildOverviewButtons, OVERVIEW_BUTTON_COUNT } from "./overview-actions.js";
27
28
 
28
29
  export {
29
30
  humanizeCompanionNeed,
@@ -33,6 +34,8 @@ export {
33
34
  partitionCompanionLines
34
35
  } from "./overview-needs.js";
35
36
 
37
+ export { buildOverviewButtons, OVERVIEW_BUTTON_COUNT } from "./overview-actions.js";
38
+
36
39
  /** Safe Details lines — leftovers + raw signals; never invent paths/IDs. */
37
40
  export function buildOverviewDetails(model = {}, companionRest = []) {
38
41
  const lines = [];
@@ -59,13 +62,19 @@ export function buildOverviewDetails(model = {}, companionRest = []) {
59
62
 
60
63
  /**
61
64
  * Pure adapter: buildControlCenterModel → semantic overview props.
62
- * First screen = purpose + next step + plain needs. Machine noise → Details.
65
+ * First screen = purpose + two buttons + plain needs. Machine noise → Details.
63
66
  */
64
- export function adaptControlCenterToOverview(model = {}) {
67
+ export function adaptControlCenterToOverview(model = {}, options = {}) {
65
68
  const status = model.status ?? model.health ?? {};
66
69
  const next = model.nextAction ?? model.cta ?? {};
67
70
  const { needs, rest } = partitionCompanionLines(model.companion?.lines ?? []);
68
71
  const primary = humanizePrimary(next);
72
+ const buttons = buildOverviewButtons({
73
+ hasGlobalState: options.hasGlobalState,
74
+ snapshot: options.snapshot,
75
+ diagnostics: options.diagnostics,
76
+ dashboard: options.dashboard
77
+ });
69
78
 
70
79
  const activity = model.activity?.headline;
71
80
  const metrics = [];
@@ -104,6 +113,7 @@ export function adaptControlCenterToOverview(model = {}) {
104
113
  body: status.summaryLine ?? ""
105
114
  },
106
115
  primary,
116
+ buttons,
107
117
  metrics,
108
118
  details: buildOverviewDetails(model, rest)
109
119
  };
@@ -136,14 +146,30 @@ export function SemanticOverviewPanel({
136
146
  detailsOpen = false,
137
147
  colorEnabled = true,
138
148
  unicode = true,
139
- layoutMode = LAYOUT_MODES.COMPACT
149
+ layoutMode = LAYOUT_MODES.COMPACT,
150
+ selectedIndex = 0,
151
+ contentFocused = false,
152
+ hasGlobalState = false,
153
+ snapshot = null,
154
+ diagnostics = null,
155
+ dashboard = null
140
156
  }) {
141
- const view = adaptControlCenterToOverview(model);
157
+ const view = adaptControlCenterToOverview(model, {
158
+ hasGlobalState,
159
+ snapshot,
160
+ diagnostics,
161
+ dashboard
162
+ });
142
163
  const showArt = shouldShowWordmark(layoutMode);
143
164
  const brandTitle = overviewBrandTitle(layoutMode);
144
165
  const isWide = layoutMode === LAYOUT_MODES.WIDE;
145
166
  const mark = renderWordmark({ layoutMode, colorEnabled, unicode });
146
167
  const status = renderCallout(view, colorEnabled);
168
+ const safeIndex = Math.min(
169
+ Math.max(0, selectedIndex),
170
+ Math.max(0, view.buttons.length - 1)
171
+ );
172
+ const glyphs = resolveGlyphs(unicode);
147
173
 
148
174
  const hero = showArt
149
175
  ? (isWide
@@ -166,18 +192,25 @@ export function SemanticOverviewPanel({
166
192
  color: colorEnabled ? COCKPIT_COLORS.muted : undefined
167
193
  }, view.purpose),
168
194
  React.createElement(Box, { marginTop: 1, flexDirection: "column" },
169
- React.createElement(Text, {
170
- bold: true,
171
- color: colorEnabled ? COCKPIT_COLORS.interactive : undefined
172
- }, `→ ${view.primary.label}`),
173
- view.primary.detail
174
- ? React.createElement(Text, null, view.primary.detail)
175
- : null,
176
- view.primary.hint
177
- ? React.createElement(Text, {
178
- color: colorEnabled ? COCKPIT_COLORS.muted : undefined
179
- }, view.primary.hint)
180
- : null
195
+ ...view.buttons.map((button, index) => {
196
+ const selected = index === safeIndex;
197
+ const focused = contentFocused && selected;
198
+ return React.createElement(Box, {
199
+ key: button.id,
200
+ flexDirection: "column",
201
+ marginBottom: index === view.buttons.length - 1 ? 0 : 1
202
+ },
203
+ React.createElement(Text, {
204
+ bold: true,
205
+ color: focused && colorEnabled ? COCKPIT_COLORS.interactive : undefined
206
+ }, `${focused ? glyphs.focus : " "} ${button.label}`),
207
+ button.detail
208
+ ? React.createElement(Text, {
209
+ color: colorEnabled ? COCKPIT_COLORS.muted : undefined
210
+ }, ` ${button.detail}`)
211
+ : null
212
+ );
213
+ })
181
214
  ),
182
215
  React.createElement(Box, { marginTop: 1, flexDirection: "column" },
183
216
  React.createElement(ActionList, {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Two explicit Home buttons — prepare + configure.
3
+ * No writes here: intents only open existing consent-gated flows.
4
+ */
5
+
6
+ export const OVERVIEW_BUTTON_COUNT = 2;
7
+
8
+ function changeCount(snapshot) {
9
+ if (!snapshot?.diff?.hasChanges) return 0;
10
+ return snapshot.diff.changeCount
11
+ ?? snapshot.diff.changes?.length
12
+ ?? 0;
13
+ }
14
+
15
+ function detectedAgentCount({ snapshot = null, diagnostics = null, dashboard = null } = {}) {
16
+ if (Number.isFinite(snapshot?.coverage?.detectedAgents)) {
17
+ return snapshot.coverage.detectedAgents;
18
+ }
19
+ if (Number.isFinite(diagnostics?.diagnostics?.detected)) {
20
+ return diagnostics.diagnostics.detected;
21
+ }
22
+ return (dashboard?.providers ?? []).filter((entry) => entry?.available).length;
23
+ }
24
+
25
+ function buildPrepareButton({
26
+ hasGlobalState = false,
27
+ snapshot = null,
28
+ diagnostics = null,
29
+ dashboard = null
30
+ } = {}) {
31
+ const detected = detectedAgentCount({ snapshot, diagnostics, dashboard });
32
+ const needsSetup = hasGlobalState === false || detected === 0;
33
+ if (needsSetup) {
34
+ return {
35
+ id: "prepare",
36
+ label: "Set up Kairo",
37
+ detail: "Detect your agents and install what is missing.",
38
+ intent: "setup"
39
+ };
40
+ }
41
+
42
+ const pending = changeCount(snapshot);
43
+ if (pending > 0) {
44
+ return {
45
+ id: "prepare",
46
+ label: `Repair ${pending} change${pending === 1 ? "" : "s"}`,
47
+ detail: "You will see the exact plan before anything is written.",
48
+ intent: "governance"
49
+ };
50
+ }
51
+
52
+ return {
53
+ id: "prepare",
54
+ label: "Everything is ready",
55
+ detail: "Open History to see what changed, or Configure to adjust.",
56
+ intent: "history"
57
+ };
58
+ }
59
+
60
+ const CONFIGURE_BUTTON = Object.freeze({
61
+ id: "configure",
62
+ label: "Configure",
63
+ detail: "Agents, Obsidian vault, integrations.",
64
+ intent: "settings"
65
+ });
66
+
67
+ /**
68
+ * Exactly two buttons for Home. First button changes face by state.
69
+ */
70
+ export function buildOverviewButtons({
71
+ hasGlobalState = false,
72
+ snapshot = null,
73
+ diagnostics = null,
74
+ dashboard = null
75
+ } = {}) {
76
+ return [
77
+ buildPrepareButton({ hasGlobalState, snapshot, diagnostics, dashboard }),
78
+ { ...CONFIGURE_BUTTON }
79
+ ];
80
+ }
@@ -0,0 +1,216 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fetchPublishedVersion } from "./npm-registry.js";
3
+ import { formatCliCommand } from "./brand/cli.js";
4
+ import { commandHeader } from "./brand/index.js";
5
+
6
+ /**
7
+ * @param {string} version
8
+ * @returns {number[]}
9
+ */
10
+ export function parseSemver(version) {
11
+ const match = String(version).trim().match(/^(\d+)\.(\d+)\.(\d+)/);
12
+ if (!match) return null;
13
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
14
+ }
15
+
16
+ /**
17
+ * @param {string} a
18
+ * @param {string} b
19
+ * @returns {-1|0|1|null}
20
+ */
21
+ export function compareSemver(a, b) {
22
+ const left = parseSemver(a);
23
+ const right = parseSemver(b);
24
+ if (!left || !right) return null;
25
+ for (let i = 0; i < 3; i += 1) {
26
+ if (left[i] < right[i]) return -1;
27
+ if (left[i] > right[i]) return 1;
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ /**
33
+ * @param {NodeJS.ProcessEnv} [env]
34
+ * @returns {"npm"|"pnpm"|"yarn"|"bun"}
35
+ */
36
+ export function detectInstallPackageManager(env = process.env) {
37
+ const execPath = env.npm_execpath ?? "";
38
+ const userAgent = env.npm_config_user_agent ?? "";
39
+ if (execPath.includes("pnpm") || userAgent.startsWith("pnpm/")) return "pnpm";
40
+ if (execPath.includes("yarn") || userAgent.startsWith("yarn/")) return "yarn";
41
+ if (execPath.includes("bun") || userAgent.startsWith("bun/")) return "bun";
42
+ return "npm";
43
+ }
44
+
45
+ /**
46
+ * @param {string} packageName
47
+ * @param {string} version
48
+ * @param {"npm"|"pnpm"|"yarn"|"bun"} manager
49
+ * @returns {{ command: string, args: string[], display: string }}
50
+ */
51
+ export function buildGlobalInstallSpec(packageName, version, manager = "npm") {
52
+ const spec = `${packageName}@${version}`;
53
+ switch (manager) {
54
+ case "pnpm":
55
+ return {
56
+ command: "pnpm",
57
+ args: ["add", "-g", spec],
58
+ display: `pnpm add -g ${spec}`
59
+ };
60
+ case "yarn":
61
+ return {
62
+ command: "yarn",
63
+ args: ["global", "add", spec],
64
+ display: `yarn global add ${spec}`
65
+ };
66
+ case "bun":
67
+ return {
68
+ command: "bun",
69
+ args: ["add", "-g", spec],
70
+ display: `bun add -g ${spec}`
71
+ };
72
+ default:
73
+ return {
74
+ command: "npm",
75
+ args: ["install", "-g", spec],
76
+ display: `npm install -g ${spec}`
77
+ };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * @param {object} options
83
+ * @param {string} options.packageName
84
+ * @param {string} options.cliVersion
85
+ * @param {boolean} [options.yes]
86
+ * @param {boolean} [options.json]
87
+ * @param {typeof fetchPublishedVersion} [options.fetchVersion]
88
+ * @param {typeof detectInstallPackageManager} [options.detectManager]
89
+ * @param {(cmd: string, args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [options.runCommand]
90
+ */
91
+ export async function runSelfUpdate({
92
+ packageName,
93
+ cliVersion,
94
+ yes = false,
95
+ json = false,
96
+ fetchVersion = fetchPublishedVersion,
97
+ detectManager = detectInstallPackageManager,
98
+ runCommand = defaultRunCommand
99
+ } = {}) {
100
+ if (!packageName) throw new Error("packageName is required.");
101
+ if (!cliVersion) throw new Error("cliVersion is required.");
102
+
103
+ const latestVersion = await fetchVersion(packageName);
104
+ const cmp = compareSemver(cliVersion, latestVersion);
105
+ const manager = detectManager();
106
+ const install = buildGlobalInstallSpec(packageName, latestVersion, manager);
107
+
108
+ /** @type {"current"|"behind"|"ahead"|"unknown"} */
109
+ let state = "unknown";
110
+ if (cmp === 0) state = "current";
111
+ else if (cmp === -1) state = "behind";
112
+ else if (cmp === 1) state = "ahead";
113
+
114
+ const report = {
115
+ ok: true,
116
+ state,
117
+ installedVersion: cliVersion,
118
+ latestVersion,
119
+ packageName,
120
+ manager,
121
+ installCommand: install.display,
122
+ applied: false,
123
+ wrote: false
124
+ };
125
+
126
+ if (state === "current") {
127
+ report.nextAction = "Already up to date.";
128
+ } else if (state === "ahead") {
129
+ report.nextAction = `Local ${cliVersion} is newer than npm ${latestVersion}.`;
130
+ } else if (state === "behind") {
131
+ report.nextAction = yes
132
+ ? `Updating ${cliVersion} → ${latestVersion}…`
133
+ : `Update available: ${cliVersion} → ${latestVersion}. Run "${formatCliCommand("update --yes")}" or: ${install.display}`;
134
+ } else {
135
+ report.nextAction = `Could not compare versions. Install manually: ${install.display}`;
136
+ }
137
+
138
+ if (json) {
139
+ if (yes && state === "behind") {
140
+ const result = await runCommand(install.command, install.args);
141
+ report.applied = result.status === 0;
142
+ report.wrote = result.status === 0;
143
+ report.ok = result.status === 0;
144
+ report.installStatus = result.status;
145
+ report.stderr = result.stderr.trim() || undefined;
146
+ report.nextAction = result.status === 0
147
+ ? `Updated to ${latestVersion}.`
148
+ : `Install failed (exit ${result.status}). Try: ${install.display}`;
149
+ }
150
+ console.log(JSON.stringify(report));
151
+ return report;
152
+ }
153
+
154
+ console.log(commandHeader("update — Kairo Runtime"));
155
+ console.log(`Installed: ${cliVersion}`);
156
+ console.log(`Latest: ${latestVersion}`);
157
+ console.log(`Status: ${state}`);
158
+
159
+ if (state === "current") {
160
+ console.log("\nAlready up to date.");
161
+ return report;
162
+ }
163
+
164
+ if (state === "ahead") {
165
+ console.log(`\n${report.nextAction}`);
166
+ return report;
167
+ }
168
+
169
+ if (!yes) {
170
+ console.log(`\nUpdate available: ${cliVersion} → ${latestVersion}`);
171
+ console.log(`Run: ${formatCliCommand("update --yes")}`);
172
+ console.log(`Or: ${install.display}`);
173
+ return report;
174
+ }
175
+
176
+ console.log(`\nInstalling ${install.display}…`);
177
+ const result = await runCommand(install.command, install.args);
178
+ report.applied = result.status === 0;
179
+ report.wrote = result.status === 0;
180
+ report.ok = result.status === 0;
181
+ report.installStatus = result.status;
182
+ if (result.stdout.trim()) console.log(result.stdout.trim());
183
+ if (result.stderr.trim()) console.error(result.stderr.trim());
184
+ if (result.status !== 0) {
185
+ throw new Error(`Self-update failed (exit ${result.status}). Try: ${install.display}`);
186
+ }
187
+ console.log(`\nUpdated to ${latestVersion}. Run "${formatCliCommand("--version")}" to confirm.`);
188
+ report.nextAction = `Updated to ${latestVersion}.`;
189
+ return report;
190
+ }
191
+
192
+ function defaultRunCommand(command, args) {
193
+ return new Promise((resolve) => {
194
+ const child = spawn(command, args, {
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ shell: false,
197
+ env: process.env
198
+ });
199
+ let stdout = "";
200
+ let stderr = "";
201
+ child.stdout?.setEncoding("utf8");
202
+ child.stderr?.setEncoding("utf8");
203
+ child.stdout?.on("data", (chunk) => {
204
+ stdout += chunk;
205
+ });
206
+ child.stderr?.on("data", (chunk) => {
207
+ stderr += chunk;
208
+ });
209
+ child.on("error", (error) => {
210
+ resolve({ status: 1, stdout, stderr: error.message });
211
+ });
212
+ child.on("close", (code) => {
213
+ resolve({ status: code ?? 1, stdout, stderr });
214
+ });
215
+ });
216
+ }