herm-tui 1.0.0-dev.1
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/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +82 -0
- package/scripts/postinstall.ts +29 -0
- package/src/app/gateway.tsx +83 -0
- package/src/app/gatewayEvents.ts +203 -0
- package/src/app/launch.ts +41 -0
- package/src/app/skin.tsx +31 -0
- package/src/app/spawnHistory.ts +75 -0
- package/src/app/tabs.ts +23 -0
- package/src/app/turnReducer.ts +390 -0
- package/src/app/useAppKeys.ts +268 -0
- package/src/app/useAtRefPopover.ts +99 -0
- package/src/app/useInputHistory.ts +66 -0
- package/src/app/useSession.ts +102 -0
- package/src/app/useSlashCommands.ts +70 -0
- package/src/app/useSlashPopover.ts +48 -0
- package/src/app.tsx +917 -0
- package/src/commands/slash.ts +151 -0
- package/src/components/avatar/AnimatedAvatar.tsx +66 -0
- package/src/components/avatar/eikon.ts +144 -0
- package/src/components/avatar/states/error.ts +1155 -0
- package/src/components/avatar/states/idle.ts +1155 -0
- package/src/components/avatar/states/index.ts +30 -0
- package/src/components/avatar/states/listening.ts +1155 -0
- package/src/components/avatar/states/speaking.ts +1155 -0
- package/src/components/avatar/states/thinking.ts +1155 -0
- package/src/components/avatar/states/working.ts +1155 -0
- package/src/components/chat/AtRefPopover.tsx +54 -0
- package/src/components/chat/CodeBlock.tsx +67 -0
- package/src/components/chat/Composer.tsx +347 -0
- package/src/components/chat/DiffBlock.tsx +116 -0
- package/src/components/chat/ErrorBlock.tsx +70 -0
- package/src/components/chat/MediaChip.tsx +114 -0
- package/src/components/chat/MessageItem.tsx +282 -0
- package/src/components/chat/MessageList.tsx +114 -0
- package/src/components/chat/PromptCard.tsx +359 -0
- package/src/components/chat/SlashPopover.tsx +158 -0
- package/src/components/chat/ThoughtCloud.tsx +185 -0
- package/src/components/chat/TypingIndicator.tsx +25 -0
- package/src/components/chat/tool/Subagent.tsx +75 -0
- package/src/components/chat/tool/frame.tsx +69 -0
- package/src/components/chat/tool/index.tsx +65 -0
- package/src/components/chat/tool/preview.ts +57 -0
- package/src/components/sidebar/ContextGauge.tsx +102 -0
- package/src/components/sidebar/Sidebar.tsx +143 -0
- package/src/components/tabs/TabBar.tsx +50 -0
- package/src/components/ui/FileLink.tsx +52 -0
- package/src/config/index.ts +156 -0
- package/src/config/lane.ts +161 -0
- package/src/config/models.ts +95 -0
- package/src/config/rules.ts +80 -0
- package/src/config/schema.ts +308 -0
- package/src/dialogs/alert.tsx +52 -0
- package/src/dialogs/chafa.tsx +72 -0
- package/src/dialogs/confirm.tsx +58 -0
- package/src/dialogs/curator.tsx +153 -0
- package/src/dialogs/eikon-picker.tsx +95 -0
- package/src/dialogs/help.tsx +80 -0
- package/src/dialogs/history.tsx +92 -0
- package/src/dialogs/info.tsx +115 -0
- package/src/dialogs/keys.tsx +170 -0
- package/src/dialogs/logs.tsx +42 -0
- package/src/dialogs/message.tsx +38 -0
- package/src/dialogs/model-picker.tsx +123 -0
- package/src/dialogs/new-profile.tsx +69 -0
- package/src/dialogs/new-task.tsx +103 -0
- package/src/dialogs/profile.tsx +55 -0
- package/src/dialogs/rollback.tsx +190 -0
- package/src/dialogs/spawn-history.tsx +80 -0
- package/src/dialogs/text-prompt.tsx +68 -0
- package/src/dialogs/theme-picker.tsx +50 -0
- package/src/home/index.ts +23 -0
- package/src/home/store.ts +267 -0
- package/src/index.tsx +113 -0
- package/src/keys/catalog.ts +115 -0
- package/src/keys/chord.ts +125 -0
- package/src/keys/conflicts.ts +48 -0
- package/src/keys/context.tsx +112 -0
- package/src/keys/index.ts +5 -0
- package/src/keys/list.ts +94 -0
- package/src/keys/oc-compat.ts +87 -0
- package/src/tabs/Agents.tsx +607 -0
- package/src/tabs/Analytics.tsx +154 -0
- package/src/tabs/Chat.tsx +50 -0
- package/src/tabs/Config.tsx +605 -0
- package/src/tabs/Context.tsx +599 -0
- package/src/tabs/Cron.tsx +294 -0
- package/src/tabs/Env.tsx +227 -0
- package/src/tabs/Kanban.tsx +367 -0
- package/src/tabs/Memory.tsx +294 -0
- package/src/tabs/Sessions.tsx +786 -0
- package/src/tabs/Skills.tsx +507 -0
- package/src/tabs/Toolsets.tsx +266 -0
- package/src/theme/builtin.ts +78 -0
- package/src/theme/context.tsx +106 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/resolve.ts +134 -0
- package/src/theme/syntax.ts +31 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +119 -0
- package/src/types/message.ts +97 -0
- package/src/ui/ChafaImage.tsx +64 -0
- package/src/ui/Splash.tsx +118 -0
- package/src/ui/borders.ts +28 -0
- package/src/ui/command.tsx +104 -0
- package/src/ui/dialog-select.tsx +164 -0
- package/src/ui/dialog.tsx +102 -0
- package/src/ui/fmt.ts +82 -0
- package/src/ui/kv.tsx +28 -0
- package/src/ui/shell.tsx +45 -0
- package/src/ui/spinner.tsx +59 -0
- package/src/ui/splash-art.ts +123 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/ticker.tsx +90 -0
- package/src/ui/toast.tsx +130 -0
- package/src/utils/categorical.ts +77 -0
- package/src/utils/chafa.ts +173 -0
- package/src/utils/clipboard.ts +67 -0
- package/src/utils/context-segments.ts +317 -0
- package/src/utils/control.ts +495 -0
- package/src/utils/drop.ts +25 -0
- package/src/utils/editor.ts +33 -0
- package/src/utils/fuzzy.ts +45 -0
- package/src/utils/gateway-client.ts +253 -0
- package/src/utils/gateway-types.ts +282 -0
- package/src/utils/git.ts +57 -0
- package/src/utils/hermes-analytics.ts +134 -0
- package/src/utils/hermes-home.ts +821 -0
- package/src/utils/hermes-kanban.ts +154 -0
- package/src/utils/hermes-profiles.ts +217 -0
- package/src/utils/interpolate.ts +31 -0
- package/src/utils/math-unicode.ts +818 -0
- package/src/utils/memory-activity.ts +140 -0
- package/src/utils/open-file.ts +13 -0
- package/src/utils/paths.ts +52 -0
- package/src/utils/perf.ts +235 -0
- package/src/utils/preferences.ts +150 -0
- package/src/utils/sessions-db.ts +396 -0
- package/src/utils/subagent-tree.ts +146 -0
- package/src/utils/terminal-reset.ts +129 -0
- package/src/utils/tips.ts +67 -0
- package/src/utils/tokens.ts +87 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Window onto the shared ~/.hermes/kanban.db.
|
|
2
|
+
//
|
|
3
|
+
// Kanban is deliberately profile-agnostic (the board IS the
|
|
4
|
+
// coordination primitive between profiles), so this reads the
|
|
5
|
+
// HERMES_HOME-relative path and shows every tenant's tasks.
|
|
6
|
+
//
|
|
7
|
+
// Reads are sidecar SQLite (WAL lets us read alongside the
|
|
8
|
+
// dispatcher's IMMEDIATE write txns). Writes route through
|
|
9
|
+
// `shell.exec → hermes kanban <verb>` so upstream kanban_db.py owns
|
|
10
|
+
// the state machine: recompute_ready, cycle detection, event log,
|
|
11
|
+
// notify subscriptions. herm is the operator surface for that CLI —
|
|
12
|
+
// create/assign/comment/unblock/archive/dispatch — not a competing
|
|
13
|
+
// implementation.
|
|
14
|
+
|
|
15
|
+
import { Database } from "bun:sqlite"
|
|
16
|
+
import { existsSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs"
|
|
17
|
+
import { hermesPath } from "./hermes-home"
|
|
18
|
+
|
|
19
|
+
export const STATUSES = ["triage", "todo", "ready", "running", "blocked", "done"] as const
|
|
20
|
+
export type Status = typeof STATUSES[number]
|
|
21
|
+
|
|
22
|
+
export type Task = {
|
|
23
|
+
id: string; title: string; body: string | null
|
|
24
|
+
assignee: string | null; status: Status; priority: number
|
|
25
|
+
created_at: number; updated_at: number; completed_at: number | null
|
|
26
|
+
result: string | null; error: string | null
|
|
27
|
+
tenant: string | null; pid: number | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Detail = Task & {
|
|
31
|
+
parents: string[]; children: string[]
|
|
32
|
+
comments: Array<{ author: string; body: string; at: number }>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let ro: Database | null | undefined
|
|
36
|
+
const db = (): Database | null => {
|
|
37
|
+
if (ro !== undefined) return ro
|
|
38
|
+
try { ro = new Database(hermesPath("kanban.db"), { readonly: true }) }
|
|
39
|
+
catch { ro = null }
|
|
40
|
+
return ro
|
|
41
|
+
}
|
|
42
|
+
export const resetKanban = () => { ro?.close(); ro = undefined }
|
|
43
|
+
|
|
44
|
+
// completed_at / started_at / created_at → updated_at proxy. The
|
|
45
|
+
// tasks table has no updated_at; newest-of-the-three is close enough
|
|
46
|
+
// for sort-by-recency without joining task_events on every list.
|
|
47
|
+
const AT = "COALESCE(completed_at, started_at, created_at)"
|
|
48
|
+
|
|
49
|
+
const toTask = (r: Record<string, unknown>): Task => ({
|
|
50
|
+
id: String(r.id), title: String(r.title ?? ""),
|
|
51
|
+
body: (r.body as string) ?? null,
|
|
52
|
+
assignee: (r.assignee as string) ?? null,
|
|
53
|
+
status: (r.status as Status) ?? "todo",
|
|
54
|
+
priority: Number(r.priority) || 0,
|
|
55
|
+
created_at: Number(r.created_at) || 0,
|
|
56
|
+
updated_at: Number(r.updated_at) || 0,
|
|
57
|
+
completed_at: (r.completed_at as number) ?? null,
|
|
58
|
+
result: (r.result as string) ?? null,
|
|
59
|
+
error: (r.last_spawn_error as string) ?? null,
|
|
60
|
+
tenant: (r.tenant as string) ?? null,
|
|
61
|
+
pid: (r.worker_pid as number) ?? null,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** All non-archived tasks, grouped by status column. Each column
|
|
65
|
+
* sorted by (priority desc, updated_at desc) so the dispatcher's
|
|
66
|
+
* pick-next ordering roughly matches the top of `ready`. */
|
|
67
|
+
export function board(): Map<Status, Task[]> {
|
|
68
|
+
const out = new Map<Status, Task[]>(STATUSES.map(s => [s, []]))
|
|
69
|
+
const conn = db()
|
|
70
|
+
if (!conn) return out
|
|
71
|
+
try {
|
|
72
|
+
const rows = conn.query(
|
|
73
|
+
`SELECT id, title, body, assignee, status, priority, tenant,
|
|
74
|
+
created_at, completed_at, result, last_spawn_error, worker_pid,
|
|
75
|
+
${AT} AS updated_at
|
|
76
|
+
FROM tasks WHERE status != 'archived'
|
|
77
|
+
ORDER BY priority DESC, updated_at DESC`,
|
|
78
|
+
).all() as Array<Record<string, unknown>>
|
|
79
|
+
for (const r of rows) {
|
|
80
|
+
const t = toTask(r)
|
|
81
|
+
out.get(t.status)?.push(t)
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function detail(id: string): Detail | null {
|
|
88
|
+
const conn = db()
|
|
89
|
+
if (!conn) return null
|
|
90
|
+
try {
|
|
91
|
+
const row = conn.query(
|
|
92
|
+
`SELECT *, ${AT} AS updated_at FROM tasks WHERE id = ?`,
|
|
93
|
+
).get(id) as Record<string, unknown> | null
|
|
94
|
+
if (!row) return null
|
|
95
|
+
const parents = (conn.query(
|
|
96
|
+
"SELECT parent_id FROM task_links WHERE child_id = ?",
|
|
97
|
+
).all(id) as Array<{ parent_id: string }>).map(r => r.parent_id)
|
|
98
|
+
const children = (conn.query(
|
|
99
|
+
"SELECT child_id FROM task_links WHERE parent_id = ?",
|
|
100
|
+
).all(id) as Array<{ child_id: string }>).map(r => r.child_id)
|
|
101
|
+
const comments = (conn.query(
|
|
102
|
+
"SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY created_at",
|
|
103
|
+
).all(id) as Array<{ author: string; body: string; created_at: number }>)
|
|
104
|
+
.map(c => ({ author: c.author, body: c.body, at: c.created_at }))
|
|
105
|
+
return { ...toTask(row), parents, children, comments }
|
|
106
|
+
} catch { return null }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Candidate assignee names for the picker — union of profiles-on-disk
|
|
110
|
+
* and any assignee already referenced on the board (a task can be
|
|
111
|
+
* assigned to a profile that no longer exists; still show it so the
|
|
112
|
+
* operator can reassign *away* from it). `(unassigned)` is prepended
|
|
113
|
+
* at the call site. */
|
|
114
|
+
export function assignees(): string[] {
|
|
115
|
+
const seen = new Set<string>()
|
|
116
|
+
const dir = hermesPath("profiles")
|
|
117
|
+
if (existsSync(dir))
|
|
118
|
+
for (const e of readdirSync(dir, { withFileTypes: true }))
|
|
119
|
+
if (e.isDirectory()) seen.add(e.name)
|
|
120
|
+
const conn = db()
|
|
121
|
+
if (conn) try {
|
|
122
|
+
for (const r of conn.query(
|
|
123
|
+
"SELECT DISTINCT assignee FROM tasks WHERE assignee IS NOT NULL AND status != 'archived'",
|
|
124
|
+
).all() as Array<{ assignee: string }>) seen.add(r.assignee)
|
|
125
|
+
} catch {}
|
|
126
|
+
return [...seen].sort()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Tail of the worker log at ~/.hermes/kanban/logs/<id>.log. Mirrors
|
|
130
|
+
* kanban_db.read_worker_log's seek-from-end + skip-partial-line. */
|
|
131
|
+
export function tailLog(id: string, bytes = 16_384): string | null {
|
|
132
|
+
const path = hermesPath(`kanban/logs/${id}.log`)
|
|
133
|
+
if (!existsSync(path)) return null
|
|
134
|
+
try {
|
|
135
|
+
const size = statSync(path).size
|
|
136
|
+
const want = Math.min(size, bytes)
|
|
137
|
+
const fd = openSync(path, "r")
|
|
138
|
+
const buf = Buffer.alloc(want)
|
|
139
|
+
readSync(fd, buf, 0, want, size - want)
|
|
140
|
+
closeSync(fd)
|
|
141
|
+
let out = buf.toString("utf-8")
|
|
142
|
+
if (size > bytes) {
|
|
143
|
+
const nl = out.indexOf("\n")
|
|
144
|
+
if (nl >= 0 && nl < out.length - 1) out = out.slice(nl + 1)
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
} catch { return null }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** POSIX single-quote for shell.exec argv building. Wraps only when
|
|
151
|
+
* the string contains shell metacharacters (keeps test assertions
|
|
152
|
+
* and toast messages readable for plain ids). */
|
|
153
|
+
export const q = (s: string): string =>
|
|
154
|
+
/^[A-Za-z0-9._\/:+=-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Profile discovery — direct filesystem reads, no gateway RPC needed.
|
|
2
|
+
//
|
|
3
|
+
// Profiles are inherently local: each is an isolated HERMES_HOME
|
|
4
|
+
// directory under `<root>/profiles/<name>/`, where <root> is the
|
|
5
|
+
// *default* hermes home (`~/.hermes` in the common case, even when
|
|
6
|
+
// herm itself is running under a named profile).
|
|
7
|
+
//
|
|
8
|
+
// `is_active` is NOT a property of a profile on disk — it depends on
|
|
9
|
+
// which HERMES_HOME the *gateway* was launched under, which may differ
|
|
10
|
+
// from herm's own process env. Callers pass the gateway-reported home
|
|
11
|
+
// (from `config.get key=profile`) and this module compares paths.
|
|
12
|
+
//
|
|
13
|
+
// All write ops (create/delete/rename/export/use) route through
|
|
14
|
+
// `shell.exec → hermes profile <verb>` in src/tabs/Agents.tsx so the
|
|
15
|
+
// authoritative CLI owns validation, skill seeding, wrapper aliases
|
|
16
|
+
// and gateway cleanup.
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
19
|
+
import { readdir } from "node:fs/promises"
|
|
20
|
+
import { homedir } from "node:os"
|
|
21
|
+
import { join, basename, dirname } from "node:path"
|
|
22
|
+
import { Database } from "bun:sqlite"
|
|
23
|
+
import type { Source } from "./hermes-home"
|
|
24
|
+
|
|
25
|
+
export type ProfileInfo = {
|
|
26
|
+
name: string
|
|
27
|
+
path: string
|
|
28
|
+
is_default: boolean
|
|
29
|
+
is_active: boolean
|
|
30
|
+
is_sticky: boolean
|
|
31
|
+
gateway_running: boolean
|
|
32
|
+
model: string | null
|
|
33
|
+
provider: string | null
|
|
34
|
+
has_env: boolean
|
|
35
|
+
skill_count: number
|
|
36
|
+
has_alias: boolean
|
|
37
|
+
soul_preview: string
|
|
38
|
+
sources: {
|
|
39
|
+
dir: Source
|
|
40
|
+
config: Source
|
|
41
|
+
soul: Source
|
|
42
|
+
env: Source
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const home = () => process.env.HOME || homedir()
|
|
47
|
+
const hermesHome = () => process.env.HERMES_HOME || join(home(), ".hermes")
|
|
48
|
+
|
|
49
|
+
// If HERMES_HOME is itself a named profile (…/profiles/<name>),
|
|
50
|
+
// the root is two levels up; otherwise HERMES_HOME is the root.
|
|
51
|
+
function root(): string {
|
|
52
|
+
const hh = hermesHome()
|
|
53
|
+
const parent = dirname(hh)
|
|
54
|
+
return basename(parent) === "profiles" ? dirname(parent) : hh
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Derive a profile name from an absolute HERMES_HOME path. Accepts
|
|
58
|
+
// the gateway-reported home so "active" reflects the gateway's view,
|
|
59
|
+
// not herm's own process environment.
|
|
60
|
+
export function profileNameFrom(hh: string): string {
|
|
61
|
+
const parent = dirname(hh)
|
|
62
|
+
return basename(parent) === "profiles" ? basename(hh) : "default"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function activeProfileName(): string {
|
|
66
|
+
return profileNameFrom(hermesHome())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `hermes profile use <name>` writes the sticky default here.
|
|
70
|
+
export function stickyDefault(): string | null {
|
|
71
|
+
try {
|
|
72
|
+
const raw = readFileSync(join(root(), "active_profile"), "utf-8").trim()
|
|
73
|
+
return raw || null
|
|
74
|
+
} catch { return null }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
78
|
+
|
|
79
|
+
function readModel(dir: string): [string | null, string | null] {
|
|
80
|
+
try {
|
|
81
|
+
const raw = readFileSync(join(dir, "config.yaml"), "utf-8")
|
|
82
|
+
// Poor-man's YAML for two nested keys — avoids pulling the yaml lib
|
|
83
|
+
// here (hermes-home.ts already owns that dependency for full parsing).
|
|
84
|
+
const block = raw.split(/^model:\s*$/m)[1]?.split(/^\S/m)[0] ?? ""
|
|
85
|
+
const m = block.match(/^\s+(?:default|model):\s*(.+)$/m)?.[1]?.trim()
|
|
86
|
+
?? raw.match(/^model:\s*(\S.+)$/m)?.[1]?.trim()
|
|
87
|
+
const p = block.match(/^\s+provider:\s*(.+)$/m)?.[1]?.trim()
|
|
88
|
+
const clean = (s?: string) => s?.replace(/^["']|["']$/g, "") ?? null
|
|
89
|
+
return [clean(m), clean(p)]
|
|
90
|
+
} catch { return [null, null] }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function countSkills(dir: string): Promise<number> {
|
|
94
|
+
const glob = new Bun.Glob("**/SKILL.md")
|
|
95
|
+
let n = 0
|
|
96
|
+
try {
|
|
97
|
+
for await (const _ of glob.scan({ cwd: join(dir, "skills"), onlyFiles: true })) n++
|
|
98
|
+
} catch { /* missing dir */ }
|
|
99
|
+
return n
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function gatewayRunning(dir: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
// Upstream moved to JSON pidfiles ({"pid":N,"kind":…}); older builds
|
|
105
|
+
// wrote a bare integer. Accept either.
|
|
106
|
+
const raw = readFileSync(join(dir, "gateway.pid"), "utf-8").trim()
|
|
107
|
+
const pid = raw.startsWith("{") ? Number((JSON.parse(raw) as { pid?: number }).pid) : Number(raw)
|
|
108
|
+
if (!Number.isFinite(pid) || pid <= 0) return false
|
|
109
|
+
process.kill(pid, 0)
|
|
110
|
+
return true
|
|
111
|
+
} catch { return false }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Strip the leading H1 (everyone's SOUL.md starts `# <Name>\n\n`) and
|
|
115
|
+
// following blank lines so the preview shows actual content, not the
|
|
116
|
+
// filename repeated as a heading.
|
|
117
|
+
function soul(dir: string): string {
|
|
118
|
+
try {
|
|
119
|
+
const raw = readFileSync(join(dir, "SOUL.md"), "utf-8")
|
|
120
|
+
const body = raw.replace(/^#[^\n]*\n+/, "").replace(/^\s+/, "")
|
|
121
|
+
return body.slice(0, 400)
|
|
122
|
+
} catch { return "" }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const src = (file: string, label: string): Source =>
|
|
126
|
+
({ file, relative: file.replace(home() + "/", "~/"), label })
|
|
127
|
+
|
|
128
|
+
async function info(name: string, dir: string, active: string, sticky: string | null): Promise<ProfileInfo> {
|
|
129
|
+
const [model, provider] = readModel(dir)
|
|
130
|
+
const alias = join(home(), ".local", "bin", name)
|
|
131
|
+
return {
|
|
132
|
+
name,
|
|
133
|
+
path: dir,
|
|
134
|
+
is_default: name === "default",
|
|
135
|
+
is_active: name === active,
|
|
136
|
+
is_sticky: name === sticky,
|
|
137
|
+
gateway_running: gatewayRunning(dir),
|
|
138
|
+
model, provider,
|
|
139
|
+
has_env: existsSync(join(dir, ".env")),
|
|
140
|
+
skill_count: await countSkills(dir),
|
|
141
|
+
has_alias: name !== "default" && existsSync(alias),
|
|
142
|
+
soul_preview: soul(dir),
|
|
143
|
+
sources: {
|
|
144
|
+
dir: src(dir, dir.replace(home() + "/", "~/")),
|
|
145
|
+
config: src(join(dir, "config.yaml"), "config.yaml"),
|
|
146
|
+
soul: src(join(dir, "SOUL.md"), "SOUL.md"),
|
|
147
|
+
env: src(join(dir, ".env"), ".env"),
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// `activeHome`: the gateway's HERMES_HOME (from `config.get key=profile`).
|
|
153
|
+
// Falls back to this process's env so the list is still usable offline.
|
|
154
|
+
export async function listProfiles(activeHome?: string): Promise<ProfileInfo[]> {
|
|
155
|
+
const r = root()
|
|
156
|
+
const active = profileNameFrom(activeHome ?? hermesHome())
|
|
157
|
+
const sticky = stickyDefault()
|
|
158
|
+
const jobs: Promise<ProfileInfo>[] = []
|
|
159
|
+
if (existsSync(r)) jobs.push(info("default", r, active, sticky))
|
|
160
|
+
const pr = join(r, "profiles")
|
|
161
|
+
if (existsSync(pr)) {
|
|
162
|
+
for (const e of await readdir(pr, { withFileTypes: true })) {
|
|
163
|
+
if (!e.isDirectory() || !ID_RE.test(e.name)) continue
|
|
164
|
+
jobs.push(info(e.name, join(pr, e.name), active, sticky))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return Promise.all(jobs)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Pre-flight UX only — the authoritative check is the CLI's own
|
|
171
|
+
// validation when `hermes profile create` runs. This just lets the
|
|
172
|
+
// dialog show inline error text before the user hits Enter.
|
|
173
|
+
export function validateName(name: string, existing: string[]): string | null {
|
|
174
|
+
if (!ID_RE.test(name)) return "must match [a-z0-9][a-z0-9_-]{0,63}"
|
|
175
|
+
if (existing.includes(name)) return "already exists"
|
|
176
|
+
if (["hermes", "default", "test", "tmp", "root", "sudo"].includes(name)) return "reserved name"
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Lazy per-profile stats ───────────────────────────────────────────
|
|
181
|
+
//
|
|
182
|
+
// Counts that require opening that profile's state.db or reading its
|
|
183
|
+
// cron/jobs.json. Not part of listProfiles() — fetched on selection so
|
|
184
|
+
// a 10-profile list doesn't open 10 sqlite connections per refresh.
|
|
185
|
+
|
|
186
|
+
export type ProfileStats = {
|
|
187
|
+
sessions: number | null
|
|
188
|
+
messages: number | null
|
|
189
|
+
crons: number | null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function profileStats(dir: string): Promise<ProfileStats> {
|
|
193
|
+
let sessions: number | null = null
|
|
194
|
+
let messages: number | null = null
|
|
195
|
+
const dbPath = join(dir, "state.db")
|
|
196
|
+
if (existsSync(dbPath)) {
|
|
197
|
+
try {
|
|
198
|
+
const db = new Database(dbPath, { readonly: true })
|
|
199
|
+
const r = db.query("SELECT COUNT(*) AS s FROM sessions WHERE message_count > 0")
|
|
200
|
+
.get() as { s: number }
|
|
201
|
+
const m = db.query("SELECT COALESCE(SUM(message_count), 0) AS m FROM sessions")
|
|
202
|
+
.get() as { m: number }
|
|
203
|
+
sessions = r.s
|
|
204
|
+
messages = m.m
|
|
205
|
+
db.close()
|
|
206
|
+
} catch { /* schema drift or locked — leave null */ }
|
|
207
|
+
}
|
|
208
|
+
let crons: number | null = null
|
|
209
|
+
try {
|
|
210
|
+
const jobs = await Bun.file(join(dir, "cron", "jobs.json")).json() as unknown
|
|
211
|
+
crons = Array.isArray(jobs) ? jobs.length
|
|
212
|
+
: jobs && typeof jobs === "object" && Array.isArray((jobs as { jobs?: unknown[] }).jobs)
|
|
213
|
+
? (jobs as { jobs: unknown[] }).jobs.length
|
|
214
|
+
: 0
|
|
215
|
+
} catch { crons = existsSync(join(dir, "cron")) ? 0 : null }
|
|
216
|
+
return { sessions, messages, crons }
|
|
217
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `{!cmd}` inline shell interpolation in user prompts. Each span
|
|
2
|
+
// is executed via the gateway's `shell.exec` RPC (not local spawn)
|
|
3
|
+
// so it honors `terminal.backend` — a `{!ls}` in a docker/ssh-backed
|
|
4
|
+
// session runs inside that environment, same as the agent's own
|
|
5
|
+
// terminal tool. Stdout+stderr are trimmed and spliced in place;
|
|
6
|
+
// a failed/timed-out command substitutes `(error)`.
|
|
7
|
+
|
|
8
|
+
import type { Gateway } from "../app/gateway"
|
|
9
|
+
|
|
10
|
+
export const INTERP_RE = /\{!(.+?)\}/g
|
|
11
|
+
|
|
12
|
+
export const hasInterp = (s: string) => /\{!.+?\}/.test(s)
|
|
13
|
+
|
|
14
|
+
type Sh = { stdout?: string; stderr?: string; code?: number }
|
|
15
|
+
|
|
16
|
+
export async function interpolate(gw: Gateway, text: string): Promise<string> {
|
|
17
|
+
const hits = [...text.matchAll(INTERP_RE)]
|
|
18
|
+
if (hits.length === 0) return text
|
|
19
|
+
const outs = await Promise.all(hits.map(m =>
|
|
20
|
+
gw.request<Sh>("shell.exec", { command: m[1] })
|
|
21
|
+
.then(r => [r.stdout, r.stderr].filter(Boolean).join("\n").trim())
|
|
22
|
+
.catch(() => "(error)"),
|
|
23
|
+
))
|
|
24
|
+
// Splice back-to-front so earlier match indices stay valid.
|
|
25
|
+
let out = text
|
|
26
|
+
for (let i = hits.length - 1; i >= 0; i--) {
|
|
27
|
+
const m = hits[i]
|
|
28
|
+
out = out.slice(0, m.index) + outs[i] + out.slice(m.index + m[0].length)
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|