@nanhara/hara 0.117.0 → 0.118.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,17 @@ 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.118.0 — session delete & fork · slash skills over serve
9
+
10
+ - **`session.delete`** — permanent removal (codex thread/delete; archive stays the soft path).
11
+ Lock-aware: refuses when a live other process holds the session; busy-guarded on live turns.
12
+ - **`session.fork`** — duplicate a session's history into a new live session (codex thread/fork),
13
+ rewind's non-destructive sibling: explore a different direction without losing the original.
14
+ Forks persist immediately.
15
+ - **Slash skills over the wire** — `session.send` with a leading `/skill-id request…` expands into
16
+ the CLI's skill-entering flow, so a desktop composer's "/" popup triggers exactly what the
17
+ terminal gets. Unknown ids fall through as plain text.
18
+
8
19
  ## 0.117.0 — serve batch 3: context watermark · compact · rewind · fuzzy file search
9
20
 
10
21
  Codex-desktop parity for the conversation-hygiene set — everything a client needs to keep a long
@@ -21,7 +21,7 @@ import { loadAgentsMd } from "../context/agents-md.js";
21
21
  import { expandMentions } from "../context/mentions.js";
22
22
  import { memoryDigest } from "../memory/store.js";
23
23
  import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
24
- import { loadSkillIndex } from "../skills/skills.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,7 +209,7 @@ 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",
212
+ "session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
201
213
  "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search",
202
214
  "automation.list", "automation.add", "automation.toggle", "automation.delete",
203
215
  ];
@@ -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 ?? [] }));
@@ -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.118.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"