@kal-elsam/kairo-runtime 0.11.0 → 0.13.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/global-template/components/agent-skills/LICENSE +21 -0
- package/global-template/components/agent-skills/PROVENANCE.md +26 -0
- package/global-template/components/agent-skills/skills/context-engineering/SKILL.md +289 -0
- package/global-template/components/agent-skills/skills/frontend-ui-engineering/SKILL.md +328 -0
- package/global-template/components/agent-skills/skills/observability-and-instrumentation/SKILL.md +203 -0
- package/global-template/components/agent-skills/skills/performance-optimization/SKILL.md +396 -0
- package/global-template/components/agent-skills/skills/source-driven-development/SKILL.md +194 -0
- package/global-template/components/catalog.json +29 -0
- package/package.json +5 -2
- package/scripts/cockpit-smoke.mjs +2 -1
- package/src/cli.js +136 -8
- package/src/global/component-builders.js +3 -1
- package/src/global/components/agent-skills.js +27 -0
- package/src/global/ink/cockpit-control-center.js +107 -4
- package/src/global/ink/cockpit-scan.js +20 -2
- package/src/global/ink/ecosystem-updates-display.js +37 -0
- package/src/global/ink/launch-input.js +32 -1
- package/src/global/ink/obsidian-vault-display.js +37 -0
- package/src/global/ink/orchestrator-app.js +2 -1
- package/src/global/ink/orchestrator-state.js +17 -2
- package/src/global/ink/system-resources-display.js +109 -0
- package/src/global/ink/use-orchestrator-data.js +40 -4
- package/src/global/ink/ux/live-overview.js +11 -1
- package/src/global/mcp/kairo-mcp.js +230 -0
- package/src/global/observability/build-companion-snapshot.js +302 -0
- package/src/global/observability/build-observability-snapshot.js +24 -0
- package/src/global/observability/ecosystem-updates.js +224 -0
- package/src/global/observability/gentle-bundle-export.js +71 -0
- package/src/global/observability/gentle-bundle-import.js +122 -0
- package/src/global/observability/gentle-probe.js +155 -0
- package/src/global/observability/graphify-ops.js +133 -0
- package/src/global/observability/graphify-parse-cache.js +90 -0
- package/src/global/observability/graphify-probe.js +185 -0
- package/src/global/observability/hermes-activity.js +163 -0
- package/src/global/observability/hermes-probe.js +171 -0
- package/src/global/observability/index.js +124 -0
- package/src/global/observability/obsidian-knowledge-preview.js +214 -0
- package/src/global/observability/obsidian-knowledge-views.js +227 -0
- package/src/global/observability/obsidian-publisher.js +181 -0
- package/src/global/observability/obsidian-status.js +76 -0
- package/src/global/observability/obsidian-vault.js +259 -0
- package/src/global/observability/passive-snapshot-flight.js +93 -0
- package/src/global/observability/probe-contract.js +38 -0
- package/src/global/observability/probe-registry.js +30 -0
- package/src/global/observability/resource-advisor.js +71 -0
- package/src/global/observability/system-resources.js +171 -0
- package/src/global/runtime/alerts/alert-cli.js +31 -0
- package/src/global/runtime/alerts/alert-store.js +29 -6
- package/src/global/runtime/alerts/alert-validate.js +25 -1
- package/src/global/runtime/alerts/controlled-alert-actions.js +56 -0
- package/src/global/runtime/execution-adapters/claude.js +2 -1
- package/src/global/runtime/execution-adapters/codex.js +2 -1
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +3 -14
- package/src/global/runtime/execution-adapters/cursor.js +2 -1
- package/src/global/runtime/execution-adapters/opencode.js +2 -1
- package/src/global/runtime/execution-adapters/pi.js +2 -1
- package/src/global/runtime/review/index.js +1 -1
- package/src/global/runtime/review/review-cli.js +113 -3
- package/src/global/runtime/review/review-git.js +142 -11
- package/src/global/runtime/review/review-patch.js +2 -0
- package/src/global/runtime/review/review-receipts.js +12 -7
- package/src/global/runtime/review/review-runner.js +2 -2
- package/src/global/runtime/review/review-types.js +8 -5
- package/src/global/runtime/review/review-validate.js +5 -1
- package/src/global/runtime/run-cli.js +2 -0
- package/src/global/runtime/run-manager.js +39 -18
- package/src/global/runtime/run-permissions.js +231 -0
- package/src/global/runtime/run-profile.js +2 -0
- package/src/global/runtime/run-supervisor.js +77 -37
- package/src/global/runtime/run-types.js +2 -0
- package/src/global/updates-cli.js +41 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
import { isPathInside } from "../component-paths.js";
|
|
5
|
+
|
|
6
|
+
/** Obsidian vault subfolder Kairo may read — never the whole vault. */
|
|
7
|
+
export const KAIRO_VAULT_SUBDIR = "Kairo";
|
|
8
|
+
|
|
9
|
+
/** Directory basenames refused anywhere under the Kairo tree. */
|
|
10
|
+
export const EXCLUDED_DIR_NAMES = Object.freeze([
|
|
11
|
+
".obsidian", ".git", ".trash", "attachments", "Attachment", "Assets", "assets"
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const SECRET_BASENAME = /(?:^|\.)(env|secret|secrets|credentials|token|tokens|key|keys|pem|p12|pfx)(?:\.|$)/i;
|
|
15
|
+
const MARKDOWN_EXT = /\.md$/i;
|
|
16
|
+
const MAX_NOTES = 200;
|
|
17
|
+
const MAX_WALK_DEPTH = 8;
|
|
18
|
+
|
|
19
|
+
function envelope(partial = {}) {
|
|
20
|
+
return {
|
|
21
|
+
state: "error",
|
|
22
|
+
vaultPath: null,
|
|
23
|
+
kairoRoot: null,
|
|
24
|
+
notes: [],
|
|
25
|
+
diagnostics: [],
|
|
26
|
+
error: null,
|
|
27
|
+
...partial
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Absolute vault path only — no home expansion, no relative paths. */
|
|
32
|
+
export function normalizeVaultPath(raw) {
|
|
33
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
34
|
+
return { ok: false, reason: "vaultPath required" };
|
|
35
|
+
}
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
if (!isAbsolute(trimmed)) {
|
|
38
|
+
return { ok: false, reason: "vaultPath must be absolute" };
|
|
39
|
+
}
|
|
40
|
+
const resolved = resolve(trimmed);
|
|
41
|
+
if (resolved.includes("\0")) {
|
|
42
|
+
return { ok: false, reason: "vaultPath invalid" };
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, path: resolved };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isExcludedDirName(name) {
|
|
48
|
+
return EXCLUDED_DIR_NAMES.includes(String(name ?? ""));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isSecretBasename(name) {
|
|
52
|
+
return SECRET_BASENAME.test(String(name ?? ""));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isAllowedKairoNoteName(name) {
|
|
56
|
+
const base = basename(String(name ?? ""));
|
|
57
|
+
if (!MARKDOWN_EXT.test(base)) return false;
|
|
58
|
+
if (isSecretBasename(base)) return false;
|
|
59
|
+
if (base.startsWith(".")) return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Candidate must resolve inside kairoRoot (realpath). Symlinks that escape fail.
|
|
65
|
+
* Relative segments with `..` that leave Kairo/ fail before IO when possible.
|
|
66
|
+
*/
|
|
67
|
+
export async function assertInsideKairoRoot(candidatePath, kairoRoot, {
|
|
68
|
+
lstatFn = lstat,
|
|
69
|
+
realpathFn = realpath,
|
|
70
|
+
existsFn = existsSync
|
|
71
|
+
} = {}) {
|
|
72
|
+
const root = resolve(kairoRoot);
|
|
73
|
+
const claimed = resolve(candidatePath);
|
|
74
|
+
if (!isPathInside(root, claimed) && claimed !== root) {
|
|
75
|
+
return { ok: false, reason: "path escapes Kairo/" };
|
|
76
|
+
}
|
|
77
|
+
if (!existsFn(claimed)) {
|
|
78
|
+
return { ok: true, path: claimed, missing: true };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const st = await lstatFn(claimed);
|
|
82
|
+
if (st.isSymbolicLink()) {
|
|
83
|
+
const target = await realpathFn(claimed);
|
|
84
|
+
if (!isPathInside(root, target) && target !== root) {
|
|
85
|
+
return { ok: false, reason: "symlink escapes Kairo/" };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, path: target, symlink: true };
|
|
88
|
+
}
|
|
89
|
+
const real = await realpathFn(claimed);
|
|
90
|
+
if (!isPathInside(root, real) && real !== root) {
|
|
91
|
+
return { ok: false, reason: "realpath escapes Kairo/" };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, path: real };
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, reason: "path unreadable" };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function refuseVaultSymlink(vaultPath, { lstatFn = lstat } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
if ((await lstatFn(vaultPath)).isSymbolicLink()) {
|
|
102
|
+
return "vault root is a symlink; refusing";
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
return "vault unreadable";
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read-only inspect of an Obsidian vault's Kairo/ subtree.
|
|
112
|
+
* Never writes; never reads Engram/Graphify stores; never opens .obsidian.
|
|
113
|
+
*/
|
|
114
|
+
export async function inspectObsidianVault({
|
|
115
|
+
vaultPath,
|
|
116
|
+
lstatFn = lstat,
|
|
117
|
+
readdirFn = readdir,
|
|
118
|
+
realpathFn = realpath,
|
|
119
|
+
existsFn = existsSync,
|
|
120
|
+
maxNotes = MAX_NOTES
|
|
121
|
+
} = {}) {
|
|
122
|
+
const norm = normalizeVaultPath(vaultPath);
|
|
123
|
+
if (!norm.ok) {
|
|
124
|
+
return envelope({ state: "unavailable", error: norm.reason, diagnostics: [norm.reason] });
|
|
125
|
+
}
|
|
126
|
+
const root = norm.path;
|
|
127
|
+
if (!existsFn(root)) {
|
|
128
|
+
return envelope({
|
|
129
|
+
state: "missing", vaultPath: root, error: "vault missing",
|
|
130
|
+
diagnostics: ["vault path does not exist"]
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const vaultSym = await refuseVaultSymlink(root, { lstatFn });
|
|
134
|
+
if (vaultSym) {
|
|
135
|
+
return envelope({
|
|
136
|
+
state: "error", vaultPath: root, error: vaultSym, diagnostics: [vaultSym]
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let vaultReal;
|
|
141
|
+
try { vaultReal = await realpathFn(root); }
|
|
142
|
+
catch {
|
|
143
|
+
return envelope({
|
|
144
|
+
state: "error", vaultPath: root, error: "vault unreadable",
|
|
145
|
+
diagnostics: ["vault realpath failed"]
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const kairoRoot = join(vaultReal, KAIRO_VAULT_SUBDIR);
|
|
150
|
+
if (!existsFn(kairoRoot)) {
|
|
151
|
+
return envelope({
|
|
152
|
+
state: "partial", vaultPath: vaultReal, kairoRoot,
|
|
153
|
+
error: null, diagnostics: ["Kairo/ subdirectory missing"]
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const kairoGate = await assertInsideKairoRoot(kairoRoot, kairoRoot, {
|
|
158
|
+
lstatFn, realpathFn, existsFn
|
|
159
|
+
});
|
|
160
|
+
if (!kairoGate.ok) {
|
|
161
|
+
return envelope({
|
|
162
|
+
state: "error", vaultPath: vaultReal, kairoRoot,
|
|
163
|
+
error: kairoGate.reason, diagnostics: [kairoGate.reason]
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (kairoGate.symlink) {
|
|
167
|
+
return envelope({
|
|
168
|
+
state: "error", vaultPath: vaultReal, kairoRoot,
|
|
169
|
+
error: "Kairo/ is a symlink; refusing",
|
|
170
|
+
diagnostics: ["Kairo/ is a symlink; refusing"]
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const notes = [];
|
|
175
|
+
const diagnostics = [];
|
|
176
|
+
await walkKairoNotes(kairoGate.path, kairoGate.path, {
|
|
177
|
+
notes, diagnostics, depth: 0, maxNotes,
|
|
178
|
+
lstatFn, readdirFn, realpathFn, existsFn
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return envelope({
|
|
182
|
+
state: "available",
|
|
183
|
+
vaultPath: vaultReal,
|
|
184
|
+
kairoRoot: kairoGate.path,
|
|
185
|
+
notes,
|
|
186
|
+
diagnostics,
|
|
187
|
+
error: null
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function walkKairoNotes(dir, kairoRoot, ctx) {
|
|
192
|
+
if (ctx.notes.length >= ctx.maxNotes || ctx.depth > MAX_WALK_DEPTH) return;
|
|
193
|
+
let entries;
|
|
194
|
+
try {
|
|
195
|
+
entries = await ctx.readdirFn(dir, { withFileTypes: true });
|
|
196
|
+
} catch {
|
|
197
|
+
ctx.diagnostics.push("directory unreadable");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const ordered = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
202
|
+
for (const entry of ordered) {
|
|
203
|
+
if (ctx.notes.length >= ctx.maxNotes) break;
|
|
204
|
+
const name = entry.name;
|
|
205
|
+
if (name === "." || name === ".." || name.includes("\0")) continue;
|
|
206
|
+
if (isExcludedDirName(name)) continue;
|
|
207
|
+
|
|
208
|
+
const full = join(dir, name);
|
|
209
|
+
const gate = await assertInsideKairoRoot(full, kairoRoot, {
|
|
210
|
+
lstatFn: ctx.lstatFn, realpathFn: ctx.realpathFn, existsFn: ctx.existsFn
|
|
211
|
+
});
|
|
212
|
+
if (!gate.ok) {
|
|
213
|
+
ctx.diagnostics.push(`skipped unsafe path (${gate.reason})`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (gate.missing) continue;
|
|
217
|
+
|
|
218
|
+
let st;
|
|
219
|
+
try { st = await ctx.lstatFn(full); }
|
|
220
|
+
catch {
|
|
221
|
+
ctx.diagnostics.push("entry unreadable");
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (st.isSymbolicLink()) {
|
|
226
|
+
ctx.diagnostics.push("skipped symlink");
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (st.isDirectory()) {
|
|
230
|
+
await walkKairoNotes(gate.path, kairoRoot, { ...ctx, depth: ctx.depth + 1 });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!st.isFile()) continue;
|
|
234
|
+
if (!isAllowedKairoNoteName(name)) continue;
|
|
235
|
+
|
|
236
|
+
const rel = gate.path.slice(kairoRoot.length).replace(/^[\\/]/, "").split(sep).join("/");
|
|
237
|
+
ctx.notes.push({
|
|
238
|
+
relativePath: rel,
|
|
239
|
+
title: basename(name, ".md")
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Pure helper for callers composing relative note paths under Kairo/. */
|
|
245
|
+
export function resolveKairoNotePath(kairoRoot, relativePath) {
|
|
246
|
+
const root = resolve(kairoRoot);
|
|
247
|
+
const parts = String(relativePath ?? "").split(/[/\\]/).filter(Boolean);
|
|
248
|
+
if (parts.length === 0 || parts.some((p) => p === ".." || p.includes("\0"))) {
|
|
249
|
+
return { ok: false, reason: "invalid relativePath" };
|
|
250
|
+
}
|
|
251
|
+
if (parts.some((p) => isExcludedDirName(p) || isSecretBasename(p))) {
|
|
252
|
+
return { ok: false, reason: "relativePath excluded" };
|
|
253
|
+
}
|
|
254
|
+
const claimed = resolve(join(root, ...parts));
|
|
255
|
+
if (!isPathInside(root, claimed)) {
|
|
256
|
+
return { ok: false, reason: "path escapes Kairo/" };
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, path: claimed };
|
|
259
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { buildObservabilitySnapshot } from "./build-observability-snapshot.js";
|
|
2
|
+
import { listObservabilityProbes } from "./probe-registry.js";
|
|
3
|
+
|
|
4
|
+
/** Short TTL for passive Cockpit/MCP observability only — never suite authority. */
|
|
5
|
+
export const PASSIVE_SNAPSHOT_TTL_MS = 5_000;
|
|
6
|
+
export const PASSIVE_SNAPSHOT_MAX_ENTRIES = 8;
|
|
7
|
+
|
|
8
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
9
|
+
const flights = new Map();
|
|
10
|
+
/** @type {Map<string, { value: unknown, expiresAt: number }>} */
|
|
11
|
+
const completed = new Map();
|
|
12
|
+
|
|
13
|
+
export function resetPassiveSnapshotFlightForTests() {
|
|
14
|
+
flights.clear();
|
|
15
|
+
completed.clear();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function passiveSnapshotFlightSizeForTests() {
|
|
19
|
+
return completed.size;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function passiveSnapshotInFlightSizeForTests() {
|
|
23
|
+
return flights.size;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildPassiveSnapshotKey(context = {}, {
|
|
27
|
+
listProviders = listObservabilityProbes
|
|
28
|
+
} = {}) {
|
|
29
|
+
const workspace = String(context.workspaceRoot ?? context.cwd ?? "");
|
|
30
|
+
const head = String(context.headSha ?? "");
|
|
31
|
+
const providers = listProviders().map((p) => p.id).sort().join(",");
|
|
32
|
+
return `${workspace}\0${head}\0${providers}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function touchCompleted(key, entry) {
|
|
36
|
+
completed.delete(key);
|
|
37
|
+
completed.set(key, entry);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function evictOldestCompleted(maxEntries) {
|
|
41
|
+
while (completed.size > maxEntries) {
|
|
42
|
+
const oldest = completed.keys().next().value;
|
|
43
|
+
if (oldest == null) break;
|
|
44
|
+
completed.delete(oldest);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Single-flight + short TTL for passive observability snapshots.
|
|
50
|
+
* force skips completed hits but joins identical in-flight work.
|
|
51
|
+
* Errors clear that key's flight and completed entry so the next call rebuilds
|
|
52
|
+
* (a failed force refresh must not leave a stale completed hit).
|
|
53
|
+
* LRU applies only to completed values — never evicts active flights.
|
|
54
|
+
*/
|
|
55
|
+
export async function runPassiveObservabilitySnapshot(context = {}, {
|
|
56
|
+
force = false,
|
|
57
|
+
build = buildObservabilitySnapshot,
|
|
58
|
+
now = Date.now,
|
|
59
|
+
listProviders = listObservabilityProbes,
|
|
60
|
+
ttlMs = PASSIVE_SNAPSHOT_TTL_MS,
|
|
61
|
+
maxEntries = PASSIVE_SNAPSHOT_MAX_ENTRIES
|
|
62
|
+
} = {}) {
|
|
63
|
+
const key = buildPassiveSnapshotKey(context, { listProviders });
|
|
64
|
+
const inflight = flights.get(key);
|
|
65
|
+
if (inflight) return inflight;
|
|
66
|
+
|
|
67
|
+
const cached = completed.get(key);
|
|
68
|
+
if (!force && cached && cached.expiresAt > now()) {
|
|
69
|
+
touchCompleted(key, cached);
|
|
70
|
+
return cached.value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const promise = Promise.resolve()
|
|
74
|
+
.then(() => build(context))
|
|
75
|
+
.then((value) => {
|
|
76
|
+
if (flights.get(key) === promise) {
|
|
77
|
+
flights.delete(key);
|
|
78
|
+
touchCompleted(key, { value, expiresAt: now() + ttlMs });
|
|
79
|
+
evictOldestCompleted(maxEntries);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
})
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
if (flights.get(key) === promise) {
|
|
85
|
+
flights.delete(key);
|
|
86
|
+
completed.delete(key);
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
flights.set(key, promise);
|
|
92
|
+
return promise;
|
|
93
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Observability probe contract — read-only; no lifecycle mutations. */
|
|
2
|
+
|
|
3
|
+
export const OBSERVABILITY_PROBE_STATES = Object.freeze([
|
|
4
|
+
"missing", "available", "incompatible", "error"
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
export function assertObservabilityProbeContract(probe) {
|
|
8
|
+
if (probe == null || typeof probe !== "object" || Array.isArray(probe)) {
|
|
9
|
+
throw new Error("Observability probe must be an object.");
|
|
10
|
+
}
|
|
11
|
+
if (typeof probe.id !== "string" || !probe.id) {
|
|
12
|
+
throw new Error("Observability probe id must be a non-empty string.");
|
|
13
|
+
}
|
|
14
|
+
if (typeof probe.probe !== "function") {
|
|
15
|
+
throw new Error(`Observability probe "${probe.id}" is missing probe().`);
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(probe.declaredEvents)) {
|
|
18
|
+
throw new Error(`Observability probe "${probe.id}" declaredEvents must be an array.`);
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(probe.declaredActions)) {
|
|
21
|
+
throw new Error(`Observability probe "${probe.id}" declaredActions must be an array.`);
|
|
22
|
+
}
|
|
23
|
+
return probe;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeProbeResult(raw, fallbackId) {
|
|
27
|
+
const id = typeof raw?.id === "string" && raw.id ? raw.id : fallbackId;
|
|
28
|
+
const state = OBSERVABILITY_PROBE_STATES.includes(raw?.state) ? raw.state : "error";
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
state,
|
|
32
|
+
version: raw?.version ?? null,
|
|
33
|
+
contractCompatible: typeof raw?.contractCompatible === "boolean" ? raw.contractCompatible : null,
|
|
34
|
+
diagnostics: Array.isArray(raw?.diagnostics) ? raw.diagnostics.map(String) : [],
|
|
35
|
+
evidence: Array.isArray(raw?.evidence) ? raw.evidence : [],
|
|
36
|
+
error: raw?.error == null ? null : String(raw.error)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { assertObservabilityProbeContract } from "./probe-contract.js";
|
|
2
|
+
|
|
3
|
+
const probes = new Map();
|
|
4
|
+
|
|
5
|
+
export function registerObservabilityProbe(probe) {
|
|
6
|
+
assertObservabilityProbeContract(probe);
|
|
7
|
+
if (probes.has(probe.id)) {
|
|
8
|
+
throw new Error(`Observability probe "${probe.id}" is already registered.`);
|
|
9
|
+
}
|
|
10
|
+
const frozen = Object.freeze({
|
|
11
|
+
id: probe.id,
|
|
12
|
+
probe: probe.probe.bind(probe),
|
|
13
|
+
declaredEvents: Object.freeze([...(probe.declaredEvents ?? [])]),
|
|
14
|
+
declaredActions: Object.freeze([...(probe.declaredActions ?? [])])
|
|
15
|
+
});
|
|
16
|
+
probes.set(probe.id, frozen);
|
|
17
|
+
return frozen;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getObservabilityProbe(id) {
|
|
21
|
+
return probes.get(id) ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listObservabilityProbes() {
|
|
25
|
+
return [...probes.values()];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resetObservabilityProbesForTests() {
|
|
29
|
+
probes.clear();
|
|
30
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic Local Resource Advisor — observe and recommend only.
|
|
3
|
+
* Never deletes, compacts, or kills. Deep-scan is explicit opt-in.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function recommendSystemResources(resources, { deepScan = false } = {}) {
|
|
7
|
+
const recommendations = [];
|
|
8
|
+
if (resources == null || typeof resources !== "object") {
|
|
9
|
+
return { recommendations, deepScan: false };
|
|
10
|
+
}
|
|
11
|
+
const state = typeof resources.state === "string" ? resources.state : "error";
|
|
12
|
+
if (state === "unavailable" || state === "error" || state === "incompatible") {
|
|
13
|
+
return { recommendations, deepScan: Boolean(deepScan) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const diskPct = resources.disk?.freePercent;
|
|
17
|
+
if (typeof diskPct === "number" && Number.isFinite(diskPct)) {
|
|
18
|
+
if (diskPct < 10) {
|
|
19
|
+
recommendations.push({
|
|
20
|
+
id: "free-disk-critical",
|
|
21
|
+
severity: "critical",
|
|
22
|
+
title: "Free disk space",
|
|
23
|
+
detail: "Disk free below 10%. Clear caches manually after review — Kairo performs no removals."
|
|
24
|
+
});
|
|
25
|
+
} else if (diskPct < 20) {
|
|
26
|
+
recommendations.push({
|
|
27
|
+
id: "free-disk-warning",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
title: "Disk space running low",
|
|
30
|
+
detail: "Disk free below 20%. Prefer quitting unused apps before large downloads."
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ramPct = resources.memory?.freePercent;
|
|
36
|
+
if (typeof ramPct === "number" && Number.isFinite(ramPct) && ramPct < 15) {
|
|
37
|
+
recommendations.push({
|
|
38
|
+
id: "quit-heavy-apps",
|
|
39
|
+
severity: "warning",
|
|
40
|
+
title: "Quit heavy apps",
|
|
41
|
+
detail: "Memory free below 15%. Quit unused agents/browsers; Kairo never terminates processes."
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tracked = Array.isArray(resources.processes?.tracked) ? resources.processes.tracked : [];
|
|
46
|
+
const busy = tracked.filter((entry) =>
|
|
47
|
+
entry != null && typeof entry === "object"
|
|
48
|
+
&& typeof entry.name === "string"
|
|
49
|
+
&& Number.isInteger(entry.count) && entry.count >= 2
|
|
50
|
+
);
|
|
51
|
+
if (busy.length > 0) {
|
|
52
|
+
const names = busy.map((entry) => entry.name).join(", ");
|
|
53
|
+
recommendations.push({
|
|
54
|
+
id: "inspect-tracked-apps",
|
|
55
|
+
severity: "info",
|
|
56
|
+
title: "Inspect local heavy apps",
|
|
57
|
+
detail: `Tracked pressure: ${names}. Review in Activity Monitor — no auto-quit.`
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (deepScan === true) {
|
|
62
|
+
recommendations.push({
|
|
63
|
+
id: "deep-scan-known-caches",
|
|
64
|
+
severity: "info",
|
|
65
|
+
title: "Inspect known local caches",
|
|
66
|
+
detail: "Opt-in deep scan only. Lists known cache/DB locations for human review — performs no removals."
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { recommendations, deepScan: deepScan === true };
|
|
71
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { probeCommand as defaultProbeCommand } from "../cli-probe.js";
|
|
2
|
+
|
|
3
|
+
export const SYSTEM_RESOURCES_TIMEOUT_MS = 2000;
|
|
4
|
+
export const PROCESS_ALLOWLIST = Object.freeze([
|
|
5
|
+
"cursor", "codex", "chatgpt", "brave", "teams", "obsidian", "ollama"
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const BIN = Object.freeze({
|
|
9
|
+
sysctl: "/usr/sbin/sysctl", vmStat: "/usr/bin/vm_stat", pagesize: "/usr/bin/pagesize",
|
|
10
|
+
df: "/bin/df", ps: "/bin/ps"
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const emptyProcesses = () => ({ totalCount: 0, zombieCount: 0, tracked: [] });
|
|
14
|
+
|
|
15
|
+
function envelope(state, nowMs, diagnostics = [], partial = {}) {
|
|
16
|
+
return {
|
|
17
|
+
state, sampledAt: new Date(nowMs).toISOString(), diagnostics: diagnostics.map(String),
|
|
18
|
+
memory: null, swap: null, disk: null, processes: emptyProcesses(),
|
|
19
|
+
thermal: { state: "unavailable" }, ssdWear: { state: "unavailable" }, ...partial
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function invoke(probeCommand, cmd, args, timeoutMs) {
|
|
24
|
+
try { return { ok: true, value: probeCommand(cmd, args, { timeoutMs }) }; }
|
|
25
|
+
catch { return { ok: false }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function classify(inv) {
|
|
29
|
+
if (!inv.ok) return { kind: "spawn_error" };
|
|
30
|
+
const r = inv.value;
|
|
31
|
+
if (r?.timedOut) return { kind: "timeout" };
|
|
32
|
+
if (r?.error) {
|
|
33
|
+
return /EACCES|EPERM|permission/i.test(String(r.error))
|
|
34
|
+
? { kind: "permission_denied" } : { kind: "spawn_error" };
|
|
35
|
+
}
|
|
36
|
+
if (!r?.ok) {
|
|
37
|
+
return /permission|denied|Operation not permitted/i.test(String(r.stderr ?? ""))
|
|
38
|
+
? { kind: "permission_denied" } : { kind: "exit" };
|
|
39
|
+
}
|
|
40
|
+
return { kind: "ok", stdout: String(r.stdout ?? "") };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseBytesUnit(raw) {
|
|
44
|
+
const m = String(raw).trim().match(/^([\d.]+)\s*([KMGTP]?)/i);
|
|
45
|
+
if (!m || !Number.isFinite(Number(m[1]))) return null;
|
|
46
|
+
const mul = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4, P: 1024 ** 5 };
|
|
47
|
+
return Math.round(Number(m[1]) * (mul[(m[2] || "B").toUpperCase()] ?? 1));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseSwap(stdout) {
|
|
51
|
+
const grab = (label) => {
|
|
52
|
+
const m = stdout.match(new RegExp(`${label}\\s*=\\s*([\\d.]+\\s*[KMGTP]?)`, "i"));
|
|
53
|
+
return m ? parseBytesUnit(m[1]) : null;
|
|
54
|
+
};
|
|
55
|
+
const totalBytes = grab("total"), usedBytes = grab("used"), freeBytes = grab("free");
|
|
56
|
+
return totalBytes == null || usedBytes == null || freeBytes == null
|
|
57
|
+
? null : { totalBytes, usedBytes, freeBytes };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseDf(stdout) {
|
|
61
|
+
const lines = String(stdout).trim().split(/\r?\n/).filter(Boolean);
|
|
62
|
+
if (lines.length < 2) return null;
|
|
63
|
+
const cols = lines.at(-1).trim().split(/\s+/);
|
|
64
|
+
const totalKb = Number(cols[1]), availKb = Number(cols[3]);
|
|
65
|
+
if (!Number.isFinite(totalKb) || !Number.isFinite(availKb) || totalKb <= 0) return null;
|
|
66
|
+
const totalBytes = totalKb * 1024, freeBytes = availKb * 1024;
|
|
67
|
+
return { totalBytes, freeBytes, freePercent: Math.round((freeBytes / totalBytes) * 1000) / 10 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function matchAllowlist(comm) {
|
|
71
|
+
const name = String(comm ?? "").toLowerCase();
|
|
72
|
+
return PROCESS_ALLOWLIST.find((key) => name.includes(key)) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseProcessTable(stdout) {
|
|
76
|
+
let totalCount = 0, zombieCount = 0;
|
|
77
|
+
const counts = Object.fromEntries(PROCESS_ALLOWLIST.map((k) => [k, 0]));
|
|
78
|
+
for (const line of String(stdout).split(/\r?\n/)) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed) continue;
|
|
81
|
+
const m = trimmed.match(/^(\d+)\s+(\S+)\s+(.+)$/);
|
|
82
|
+
if (!m) continue;
|
|
83
|
+
totalCount += 1;
|
|
84
|
+
if (/^Z/i.test(m[2])) zombieCount += 1;
|
|
85
|
+
const key = matchAllowlist(m[3]);
|
|
86
|
+
if (key) counts[key] += 1;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
totalCount, zombieCount,
|
|
90
|
+
tracked: PROCESS_ALLOWLIST.filter((k) => counts[k] > 0).map((k) => ({ name: k, count: counts[k] }))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function probeMemory(probeCommand, timeoutMs) {
|
|
95
|
+
const mem = classify(invoke(probeCommand, BIN.sysctl, ["-n", "hw.memsize"], timeoutMs));
|
|
96
|
+
if (mem.kind !== "ok") return { ok: false, kind: mem.kind };
|
|
97
|
+
const totalBytes = Number(String(mem.stdout).trim());
|
|
98
|
+
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return { ok: false, kind: "parse" };
|
|
99
|
+
const page = classify(invoke(probeCommand, BIN.pagesize, [], timeoutMs));
|
|
100
|
+
if (page.kind !== "ok") return { ok: false, kind: page.kind };
|
|
101
|
+
const pageSize = Number(String(page.stdout).trim());
|
|
102
|
+
if (!Number.isFinite(pageSize) || pageSize <= 0) return { ok: false, kind: "parse" };
|
|
103
|
+
const vm = classify(invoke(probeCommand, BIN.vmStat, [], timeoutMs));
|
|
104
|
+
if (vm.kind !== "ok") return { ok: false, kind: vm.kind };
|
|
105
|
+
const free = vm.stdout.match(/Pages free:\s+(\d+)/i);
|
|
106
|
+
const spec = vm.stdout.match(/Pages speculative:\s+(\d+)/i);
|
|
107
|
+
if (!free) return { ok: false, kind: "parse" };
|
|
108
|
+
const freeBytes = (Number(free[1]) + (spec ? Number(spec[1]) : 0)) * pageSize;
|
|
109
|
+
return { ok: true, value: { totalBytes, freePercent: Math.round((freeBytes / totalBytes) * 1000) / 10 } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runProbe(fn, probeCommand, deadlineMs) {
|
|
113
|
+
const left = Math.max(0, deadlineMs - Date.now());
|
|
114
|
+
if (left <= 0) return { ok: false, kind: "timeout" };
|
|
115
|
+
return fn(probeCommand, left);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function probeSwap(probeCommand, timeoutMs) {
|
|
119
|
+
const cls = classify(invoke(probeCommand, BIN.sysctl, ["-n", "vm.swapusage"], timeoutMs));
|
|
120
|
+
if (cls.kind !== "ok") return { ok: false, kind: cls.kind };
|
|
121
|
+
const value = parseSwap(cls.stdout);
|
|
122
|
+
return value ? { ok: true, value } : { ok: false, kind: "parse" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function probeDisk(probeCommand, timeoutMs) {
|
|
126
|
+
let cls = classify(invoke(probeCommand, BIN.df, ["-k", "/System/Volumes/Data"], timeoutMs));
|
|
127
|
+
if (cls.kind !== "ok") cls = classify(invoke(probeCommand, BIN.df, ["-k", "/"], timeoutMs));
|
|
128
|
+
if (cls.kind !== "ok") return { ok: false, kind: cls.kind };
|
|
129
|
+
const value = parseDf(cls.stdout);
|
|
130
|
+
return value ? { ok: true, value } : { ok: false, kind: "parse" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function probeProcesses(probeCommand, timeoutMs) {
|
|
134
|
+
const cls = classify(invoke(probeCommand, BIN.ps, ["-axc", "-o", "pid=,stat=,comm="], timeoutMs));
|
|
135
|
+
if (cls.kind !== "ok") return { ok: false, kind: cls.kind };
|
|
136
|
+
return { ok: true, value: parseProcessTable(cls.stdout) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read-only local system resources (macOS first). Absolute bins only; never throws. */
|
|
140
|
+
export async function loadSystemResources({
|
|
141
|
+
platform = process.platform, nowMs = Date.now(),
|
|
142
|
+
timeoutMs = SYSTEM_RESOURCES_TIMEOUT_MS, probeCommand = defaultProbeCommand
|
|
143
|
+
} = {}) {
|
|
144
|
+
try {
|
|
145
|
+
if (platform !== "darwin") {
|
|
146
|
+
return envelope("unavailable", nowMs, ["system resources unsupported on this platform"]);
|
|
147
|
+
}
|
|
148
|
+
const diagnostics = [];
|
|
149
|
+
const deadlineMs = Date.now() + timeoutMs;
|
|
150
|
+
const memory = runProbe(probeMemory, probeCommand, deadlineMs);
|
|
151
|
+
const swap = runProbe(probeSwap, probeCommand, deadlineMs);
|
|
152
|
+
const disk = runProbe(probeDisk, probeCommand, deadlineMs);
|
|
153
|
+
const processes = runProbe(probeProcesses, probeCommand, deadlineMs);
|
|
154
|
+
for (const [label, result] of [["memory", memory], ["swap", swap], ["disk", disk], ["processes", processes]]) {
|
|
155
|
+
if (!result.ok) diagnostics.push(`system ${label} ${result.kind}`);
|
|
156
|
+
}
|
|
157
|
+
const anyOk = memory.ok || swap.ok || disk.ok || processes.ok;
|
|
158
|
+
if (!anyOk) {
|
|
159
|
+
return envelope("error", nowMs, diagnostics.length ? diagnostics : ["system resources probe failed"]);
|
|
160
|
+
}
|
|
161
|
+
const available = memory.ok && swap.ok && disk.ok && processes.ok;
|
|
162
|
+
return envelope(available ? "available" : "partial", nowMs, diagnostics, {
|
|
163
|
+
memory: memory.ok ? memory.value : null,
|
|
164
|
+
swap: swap.ok ? swap.value : null,
|
|
165
|
+
disk: disk.ok ? disk.value : null,
|
|
166
|
+
processes: processes.ok ? processes.value : emptyProcesses()
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
return envelope("error", nowMs, ["system resources error"]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolveHomeDir } from "../../paths.js";
|
|
2
|
+
import { printJson } from "../../json-output.js";
|
|
3
|
+
import { commandHeader } from "../../brand/index.js";
|
|
4
|
+
import { formatCliCommand } from "../../brand/cli.js";
|
|
5
|
+
import { controlledDismissAlert, controlledResolveAlert } from "./controlled-alert-actions.js";
|
|
6
|
+
|
|
7
|
+
export async function runGlobalAlerts(options) {
|
|
8
|
+
const homeDir = resolveHomeDir();
|
|
9
|
+
const action = options.alertsAction;
|
|
10
|
+
const alertId = options.alertId;
|
|
11
|
+
if ((action !== "resolve" && action !== "dismiss") || !alertId) {
|
|
12
|
+
throw new Error(`Use: ${formatCliCommand("alerts resolve|dismiss <alertId> --confirm-…")}`);
|
|
13
|
+
}
|
|
14
|
+
const isResolve = action === "resolve";
|
|
15
|
+
const confirmed = Boolean(isResolve ? options.confirmResolve : options.confirmDismiss);
|
|
16
|
+
const result = await (isResolve ? controlledResolveAlert : controlledDismissAlert)({
|
|
17
|
+
alertId, confirmed, source: "cli", homeDir
|
|
18
|
+
});
|
|
19
|
+
if (options.json) {
|
|
20
|
+
printJson({
|
|
21
|
+
ok: result.ok, code: result.code, alertId, state: result.alert?.state ?? null,
|
|
22
|
+
permissionAuthority: result.permissionAuthority, diagnostics: result.diagnostics
|
|
23
|
+
});
|
|
24
|
+
} else if (result.ok) {
|
|
25
|
+
console.log(`${commandHeader(`alerts ${action}`)}\n${alertId} → ${result.alert.state}`);
|
|
26
|
+
} else {
|
|
27
|
+
console.error(`alerts ${action} failed (${result.code}).`);
|
|
28
|
+
}
|
|
29
|
+
if (!result.ok) process.exitCode = 2;
|
|
30
|
+
return result;
|
|
31
|
+
}
|