@nanhara/hara 0.117.0 → 0.119.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,25 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.119.0 — project panels: the chat ↔ live-preview split
9
+
10
+ - **Plugin panels become project-aware.** A plugin panel can declare `detect` markers (e.g.
11
+ `.hara/design`, `remotion.config.ts`); `project.panels` returns the panels applicable to a
12
+ project's cwd. In Hara Desktop, opening a design/video project surfaces a preview toggle right
13
+ on the conversation — talk to the agent on the left, watch the live preview react on the right.
14
+ Detect-less panels stay global-only (settings page).
15
+
16
+ ## 0.118.0 — session delete & fork · slash skills over serve
17
+
18
+ - **`session.delete`** — permanent removal (codex thread/delete; archive stays the soft path).
19
+ Lock-aware: refuses when a live other process holds the session; busy-guarded on live turns.
20
+ - **`session.fork`** — duplicate a session's history into a new live session (codex thread/fork),
21
+ rewind's non-destructive sibling: explore a different direction without losing the original.
22
+ Forks persist immediately.
23
+ - **Slash skills over the wire** — `session.send` with a leading `/skill-id request…` expands into
24
+ the CLI's skill-entering flow, so a desktop composer's "/" popup triggers exactly what the
25
+ terminal gets. Unknown ids fall through as plain text.
26
+
8
27
  ## 0.117.0 — serve batch 3: context watermark · compact · rewind · fuzzy file search
9
28
 
10
29
  Codex-desktop parity for the conversation-hygiene set — everything a client needs to keep a long
@@ -174,6 +174,23 @@ export function uninstallPlugin(name) {
174
174
  rmSync(dest, { recursive: true, force: true });
175
175
  return true;
176
176
  }
177
+ /** Panels applicable to a project cwd — a panel matches when any of its `detect` markers exists under
178
+ * the cwd. Pure over the given plugin list (testable); `panelsForProject` binds it to enabled plugins. */
179
+ export function matchPanels(plugins, cwd) {
180
+ const out = [];
181
+ for (const pl of plugins) {
182
+ for (const panel of pl.manifest.panels ?? []) {
183
+ if (!panel.detect?.length)
184
+ continue; // global-only panels stay off project surfaces
185
+ if (panel.detect.some((m) => existsSync(join(cwd, m))))
186
+ out.push({ plugin: pl.name, panel });
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+ export function panelsForProject(cwd) {
192
+ return matchPanels(enabledPlugins(), cwd);
193
+ }
177
194
  /** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
178
195
  export function setPluginEnabled(name, on) {
179
196
  const p = join(homedir(), ".hara", "config.json");
@@ -20,8 +20,8 @@ import { fuzzyRank } from "../fuzzy.js";
20
20
  import { loadAgentsMd } from "../context/agents-md.js";
21
21
  import { expandMentions } from "../context/mentions.js";
22
22
  import { memoryDigest } from "../memory/store.js";
23
- import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
24
- import { loadSkillIndex } from "../skills/skills.js";
23
+ import { listInstalled, enabledPlugins, setPluginEnabled, panelsForProject } from "../plugins/plugins.js";
24
+ import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
25
25
  import { loadJobs, saveJobs, addJob } from "../cron/store.js";
26
26
  import { parseSchedule, describeSchedule } from "../cron/schedule.js";
27
27
  import { SessionHub, realStore } from "./sessions.js";
@@ -104,9 +104,21 @@ export async function startServe(opts, deps) {
104
104
  broadcast("approval.request", { sessionId, approvalId, question: q });
105
105
  });
106
106
  try {
107
+ // Slash skills, CLI parity: "/skill-id request…" expands into the skill-entering message, so a
108
+ // desktop composer's "/" popup triggers the exact behavior the terminal gets. Unknown ids fall
109
+ // through as plain text (the model sees what the user typed).
110
+ let content = text;
111
+ const slash = /^\/([a-z0-9][\w-]*)(?:\s+([\s\S]*))?$/.exec(text.trim());
112
+ if (slash) {
113
+ const sk = loadSkillIndex(s.meta.cwd).find((k) => k.id === slash[1]);
114
+ if (sk) {
115
+ const rest = slash[2]?.trim();
116
+ content = `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${rest ? ` — request: ${rest}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`;
117
+ }
118
+ }
107
119
  // @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
108
120
  // Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
109
- s.history.push({ role: "user", content: expandMentions(text, s.meta.cwd), ...(images && images.length ? { images } : {}) });
121
+ s.history.push({ role: "user", content: expandMentions(content, s.meta.cwd), ...(images && images.length ? { images } : {}) });
110
122
  await runAgent(s.history, {
111
123
  provider: s.provider,
112
124
  ctx: {
@@ -197,8 +209,8 @@ export async function startServe(opts, deps) {
197
209
  // (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
198
210
  const methods = [
199
211
  "session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
200
- "session.rename", "session.archive", "session.compact", "session.rewind", "session.context",
201
- "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search",
212
+ "session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
213
+ "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
202
214
  "automation.list", "automation.add", "automation.toggle", "automation.delete",
203
215
  ];
204
216
  return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
@@ -286,6 +298,31 @@ export async function startServe(opts, deps) {
286
298
  return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
287
299
  return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
288
300
  }
301
+ case "session.fork": {
302
+ // duplicate the conversation into a new session (codex thread/fork) — rewind's
303
+ // non-destructive sibling: explore a different direction without losing the original
304
+ if (typeof p.sessionId !== "string")
305
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
306
+ const provider = await deps.buildSessionProvider();
307
+ if (!provider)
308
+ return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
309
+ const r = hub.fork(p.sessionId, { provider, providerId: deps.providerId, approval: deps.approval, projectContext: undefined });
310
+ if ("missing" in r)
311
+ return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
312
+ r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
313
+ return reply(rpcResult(id, { sessionId: r.session.meta.id, title: r.session.meta.title, model: r.session.meta.model, history: historyForClient(r.session.history) }));
314
+ }
315
+ case "session.delete": {
316
+ // permanent removal (codex thread/delete) — archive is the soft path; this one is forever
317
+ if (typeof p.sessionId !== "string")
318
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
319
+ const r = hub.delete(p.sessionId);
320
+ if (r === "busy")
321
+ return reply(rpcError(id, ERR.BUSY, "a turn is running — delete after it finishes"));
322
+ if (r === "missing")
323
+ return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId} (or held by another process)`));
324
+ return reply(rpcResult(id, { sessionId: p.sessionId, deleted: true }));
325
+ }
289
326
  case "models.list": {
290
327
  const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
291
328
  return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
@@ -362,6 +399,14 @@ export async function startServe(opts, deps) {
362
399
  const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
363
400
  return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
364
401
  }
402
+ case "project.panels": {
403
+ // panels applicable to a project (plugin manifest `detect` markers under the cwd) — powers
404
+ // the desktop's chat ↔ live-preview split for design/video projects.
405
+ const ps = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
406
+ const pcwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (ps?.meta.cwd ?? opts.cwd);
407
+ const panels = panelsForProject(pcwd).map(({ plugin, panel }) => ({ plugin, id: panel.id, title: panel.title, command: panel.command, args: panel.args ?? [], port: panel.port }));
408
+ return reply(rpcResult(id, { cwd: pcwd, panels }));
409
+ }
365
410
  case "files.search": {
366
411
  // fuzzy file lookup for the composer's @-mention autocomplete (codex fuzzyFileSearch).
367
412
  // Relative POSIX paths; empty query returns the first files as a browse list.
@@ -1,4 +1,4 @@
1
- import { newSessionId, saveSession, loadSession, listSessions, acquireSessionLock, releaseSessionLock, deriveTitle, } from "../session/store.js";
1
+ import { newSessionId, saveSession, loadSession, listSessions, acquireSessionLock, releaseSessionLock, deleteSession, deriveTitle, } from "../session/store.js";
2
2
  /** The real ~/.hara/sessions store (default). */
3
3
  export const realStore = {
4
4
  load: loadSession,
@@ -6,6 +6,7 @@ export const realStore = {
6
6
  list: listSessions,
7
7
  acquire: acquireSessionLock,
8
8
  release: releaseSessionLock,
9
+ delete: deleteSession,
9
10
  };
10
11
  export class SessionHub {
11
12
  store;
@@ -89,6 +90,53 @@ export class SessionHub {
89
90
  this.store.save(prior.meta, prior.history);
90
91
  return true;
91
92
  }
93
+ /** Fork: duplicate a session's history into a NEW session (codex thread/fork) — the non-destructive
94
+ * sibling of rewind. Source may be live or on-disk; the fork is always a fresh live session. */
95
+ fork(id, o) {
96
+ const live = this.sessions.get(id);
97
+ const src = live ?? this.store.load(id);
98
+ if (!src)
99
+ return { missing: true };
100
+ const meta = {
101
+ id: newSessionId(),
102
+ cwd: src.meta.cwd,
103
+ provider: o.providerId,
104
+ model: src.meta.model,
105
+ title: src.meta.title ? `${src.meta.title} ⑂` : "",
106
+ createdAt: new Date().toISOString(),
107
+ updatedAt: "",
108
+ source: "interactive",
109
+ };
110
+ this.store.acquire(meta.id);
111
+ const s = {
112
+ meta,
113
+ history: [...src.history],
114
+ provider: o.provider,
115
+ approval: o.approval,
116
+ autoApprove: new Set(),
117
+ stats: { input: 0, output: 0 },
118
+ projectContext: o.projectContext,
119
+ busy: false,
120
+ abort: null,
121
+ };
122
+ this.sessions.set(meta.id, s);
123
+ this.store.save(meta, s.history); // persist immediately — a fork should survive a crash unsent
124
+ return { session: s };
125
+ }
126
+ /** Permanently delete (live or on-disk). Refuses a busy live session. Returns:
127
+ * "gone" on success, "busy" when a turn is running, "missing" when unknown/held elsewhere. */
128
+ delete(id) {
129
+ const live = this.sessions.get(id);
130
+ if (live?.busy)
131
+ return "busy";
132
+ const ok = this.store.delete(id);
133
+ if (!ok && !live)
134
+ return "missing";
135
+ if (live)
136
+ this.sessions.delete(id);
137
+ this.store.release(id);
138
+ return "gone";
139
+ }
92
140
  /** Release all locks (server shutdown). In-flight turns are aborted by the caller first. */
93
141
  releaseAll() {
94
142
  for (const id of this.sessions.keys())
@@ -72,6 +72,25 @@ export function releaseSessionLock(id) {
72
72
  /* best-effort */
73
73
  }
74
74
  }
75
+ /** Permanently delete a session from disk (codex thread/delete — unlike archive, irreversible).
76
+ * Refuses when a LIVE other process holds the lock; removes the session file and any lock we may hold.
77
+ * Returns false when the session doesn't exist or is held elsewhere. */
78
+ export function deleteSession(id) {
79
+ const f = sessionFile(id);
80
+ if (!existsSync(f))
81
+ return false;
82
+ const lock = acquireSessionLock(id);
83
+ if (!lock.ok)
84
+ return false;
85
+ try {
86
+ rmSync(f);
87
+ rmSync(lockFile(id), { force: true });
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
75
94
  /** A full UUID per session (the stable identity). */
76
95
  export const newSessionId = () => randomUUID();
77
96
  /** First segment of the UUID — a compact label for the status bar / `/sessions`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.117.0",
3
+ "version": "0.119.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"