@nanhara/hara 0.116.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,36 @@ 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
+
19
+ ## 0.117.0 — serve batch 3: context watermark · compact · rewind · fuzzy file search
20
+
21
+ Codex-desktop parity for the conversation-hygiene set — everything a client needs to keep a long
22
+ session healthy without dropping to the CLI:
23
+
24
+ - **Context watermark everywhere.** Every `session.send` result and `event.turn_end` now carries
25
+ `ctx: { lastInput, window, pct }` — how full the model's window was on the last turn. Clients
26
+ render a live meter with zero extra round-trips. `session.context` returns the same watermark
27
+ plus the spend breakdown (your messages / assistant / per-tool) on demand.
28
+ - **`session.compact`** — the CLI's `/compact`, serve-side: summarize-and-replace with the same
29
+ 8-section brief, working-memory notes kept on the session, and the recently-touched-file restore
30
+ (limited to files under the session's own cwd — serve is multi-session; nothing leaks across
31
+ projects). Busy-guarded like a turn.
32
+ - **`session.rewind`** — fork the thread back to before the n-th-most-recent user turn (codex
33
+ `thread/rollback`). History-only; file edits are not reverted.
34
+ - **`files.search`** — fuzzy project-file lookup (git-aware listing + the CLI's fuzzy ranker),
35
+ powering @-mention autocomplete in the desktop composer. Resolves the search root from an
36
+ explicit `cwd` or a `sessionId`.
37
+
8
38
  ## 0.116.0 — the desktop-grade serve protocol (models · automation · rename/archive · capabilities)
9
39
 
10
40
  - **Session source stamping.** Sessions now record WHO created them (`interactive` / `gateway` / `cron`,
@@ -25,6 +25,17 @@ export const COMPACT_SYSTEM = "Summarize the conversation so far into a structur
25
25
  "7. All user messages — EVERY user message so far (excluding tool results), verbatim and in order; abbreviate only huge pasted blobs with […]. These are the ground truth of intent.\n" +
26
26
  "8. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
27
27
  "Be specific and concrete. Drop the <analysis>; output only the headed summary.";
28
+ /** Working-memory notes distilled from a compaction summary — short lines that survive the history wipe
29
+ * (stored on SessionMeta.workingSet, injected into subsequent turns). Shared by the CLI /compact path
30
+ * and serve's session.compact. */
31
+ export function workingSetFromSummary(s) {
32
+ return s
33
+ .split("\n")
34
+ .map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
35
+ .filter((l) => l.length > 3)
36
+ .slice(0, 12)
37
+ .map((l) => l.slice(0, 140));
38
+ }
28
39
  /** Post-compaction file restore (Claude Code's TW5): re-attach the CURRENT content of the files the
29
40
  * conversation was just working with, so the summary isn't the model's only anchor — it doesn't have
30
41
  * to re-read its own working set next turn (and can't act on a stale memory of an edited file).
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
24
24
  import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
25
25
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
26
26
  import { routingProvider } from "./agent/route.js";
27
- import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore } from "./agent/compact.js";
27
+ import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "./agent/compact.js";
28
28
  import { recentTouched } from "./agent/touched.js";
29
29
  import { INTERJECT_PREFIX } from "./agent/reminders.js";
30
30
  import { checkForUpdate } from "./update-check.js";
@@ -758,13 +758,8 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
758
758
  "conventions / user preferences from the logs that are NOT already captured, and persist each with " +
759
759
  "memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
760
760
  "Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
761
- // COMPACT_SYSTEM (the 8-section brief) lives in agent/compact.ts so tests can pin its structure.
762
- const workingSetFromSummary = (s) => s
763
- .split("\n")
764
- .map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
765
- .filter((l) => l.length > 3)
766
- .slice(0, 12)
767
- .map((l) => l.slice(0, 140));
761
+ // COMPACT_SYSTEM (the 8-section brief) + workingSetFromSummary live in agent/compact.ts so tests can
762
+ // pin their structure and serve's session.compact shares the exact same distillation.
768
763
  /** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
769
764
  * /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
770
765
  async function compactConversation(provider, history, meta, stats) {
@@ -8,7 +8,8 @@
8
8
  // session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
9
9
  // session.create {cwd?,approval?} → {sessionId,model}
10
10
  // session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
11
- // session.send {sessionId,text} → (streams events, then) {reply,usage}
11
+ // session.send {sessionId,text,images?} → (streams events, then) {reply,usage}
12
+ // images: [{path,mediaType?}] — pasted screenshots etc., inlined for vision models
12
13
  // session.interrupt {sessionId} → {}
13
14
  // approval.reply {approvalId,allow,always?} → {}
14
15
  // plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
@@ -17,6 +18,9 @@
17
18
  // automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
18
19
  // sessions:[{id,title,source,sourceName,updatedAt,…}]}
19
20
  // models.list {} → {models:[…], current, effortLevels:[…]}
21
+ // automation.add {name,schedule,task,mode?,cwd?,tz?} → {id,name,schedule}
22
+ // automation.toggle {id,enabled} → {id,enabled}
23
+ // automation.delete {id} → {id,deleted}
20
24
  // session.rename {sessionId,title} → {sessionId,title}
21
25
  // session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
22
26
  // session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
@@ -5,17 +5,25 @@
5
5
  // (no import cycle back into the CLI entry).
6
6
  import { WebSocketServer } from "ws";
7
7
  import { randomBytes, randomUUID, timingSafeEqual, createHash } from "node:crypto";
8
- import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
8
+ import { writeFileSync, unlinkSync, mkdirSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
12
12
  import { runAgent } from "../agent/loop.js";
13
+ import { COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "../agent/compact.js";
14
+ import { rewindTo } from "../agent/rewind.js";
15
+ import { analyzeContext } from "../agent/context-report.js";
16
+ import { recentTouched } from "../agent/touched.js";
17
+ import { contextWindow, ctxPctFor } from "../statusbar.js";
18
+ import { listProjectFiles } from "../fs-walk.js";
19
+ import { fuzzyRank } from "../fuzzy.js";
13
20
  import { loadAgentsMd } from "../context/agents-md.js";
14
21
  import { expandMentions } from "../context/mentions.js";
15
22
  import { memoryDigest } from "../memory/store.js";
16
23
  import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
17
- import { loadSkillIndex } from "../skills/skills.js";
18
- import { loadJobs } from "../cron/store.js";
24
+ import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
25
+ import { loadJobs, saveJobs, addJob } from "../cron/store.js";
26
+ import { parseSchedule, describeSchedule } from "../cron/schedule.js";
19
27
  import { SessionHub, realStore } from "./sessions.js";
20
28
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
21
29
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
@@ -70,7 +78,7 @@ export async function startServe(opts, deps) {
70
78
  writeFileSync(discoveryPath, JSON.stringify({ host: opts.host, port, token, pid: process.pid, version: deps.version }, null, 2), { mode: 0o600 });
71
79
  }
72
80
  /** Run one turn on a session, streaming events to all authed clients. */
73
- const runTurn = async (s, text) => {
81
+ const runTurn = async (s, text, images) => {
74
82
  const sessionId = s.meta.id;
75
83
  s.busy = true;
76
84
  s.abort = new AbortController();
@@ -96,8 +104,21 @@ export async function startServe(opts, deps) {
96
104
  broadcast("approval.request", { sessionId, approvalId, question: q });
97
105
  });
98
106
  try {
99
- // @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works)
100
- s.history.push({ role: "user", content: expandMentions(text, s.meta.cwd) });
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
+ }
119
+ // @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
120
+ // Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
121
+ s.history.push({ role: "user", content: expandMentions(content, s.meta.cwd), ...(images && images.length ? { images } : {}) });
101
122
  await runAgent(s.history, {
102
123
  provider: s.provider,
103
124
  ctx: {
@@ -118,14 +139,57 @@ export async function startServe(opts, deps) {
118
139
  hub.save(s);
119
140
  const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
120
141
  const reply = lastAssistantText(s.history);
121
- broadcast("event.turn_end", { sessionId, reply, usage });
122
- return { reply, usage };
142
+ // context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern)
143
+ // clients render a meter without an extra round-trip.
144
+ const ctx = ctxOf(s);
145
+ broadcast("event.turn_end", { sessionId, reply, usage, ctx });
146
+ return { reply, usage, ctx };
123
147
  }
124
148
  finally {
125
149
  s.busy = false;
126
150
  s.abort = null;
127
151
  }
128
152
  };
153
+ /** Context watermark for a session: how full the model's window was on the last turn. */
154
+ const ctxOf = (s) => {
155
+ const lastInput = s.stats.lastInput ?? 0;
156
+ return { lastInput, window: contextWindow(s.meta.model), pct: ctxPctFor(s.meta.model, lastInput) };
157
+ };
158
+ /** Summarize + replace a session's history — the CLI's /compact, serve-side (codex thread/compact).
159
+ * Mirrors index.ts compactConversation; the file restore is limited to files under the session's own
160
+ * cwd because serve is multi-session (recentTouched is process-wide and must not leak across projects). */
161
+ const compactSession = async (s) => {
162
+ const r = await s.provider.turn({
163
+ system: COMPACT_SYSTEM,
164
+ history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
165
+ tools: [],
166
+ onText: () => { },
167
+ });
168
+ if (r.stop === "error")
169
+ return null;
170
+ const summary = r.text.trim();
171
+ if (!summary)
172
+ return null;
173
+ s.meta.workingSet = workingSetFromSummary(summary);
174
+ s.history.length = 0;
175
+ s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
176
+ const touched = recentTouched(20).filter((f) => f.startsWith(s.meta.cwd)).slice(0, 5);
177
+ const restore = buildFileRestore(touched, (f) => {
178
+ try {
179
+ return readFileSync(f, "utf8");
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ });
185
+ if (restore)
186
+ s.history.push({ role: "user", content: restore });
187
+ s.stats.input += r.usage?.input ?? 0;
188
+ s.stats.output += r.usage?.output ?? 0;
189
+ s.stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary
190
+ hub.save(s);
191
+ return summary;
192
+ };
129
193
  wss.on("connection", (ws) => {
130
194
  ws.on("message", async (raw) => {
131
195
  const parsed = parseFrame(String(raw));
@@ -145,7 +209,9 @@ export async function startServe(opts, deps) {
145
209
  // (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
146
210
  const methods = [
147
211
  "session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
148
- "approval.reply", "plugins.list", "plugins.set", "skills.list", "automation.list", "models.list",
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",
214
+ "automation.list", "automation.add", "automation.toggle", "automation.delete",
149
215
  ];
150
216
  return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
151
217
  }
@@ -185,7 +251,10 @@ export async function startServe(opts, deps) {
185
251
  return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
186
252
  if (s.busy)
187
253
  return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
188
- const r = await runTurn(s, p.text);
254
+ const images = Array.isArray(p.images)
255
+ ? p.images.filter((im) => im && typeof im.path === "string").map((im) => ({ path: im.path, mediaType: typeof im.mediaType === "string" ? im.mediaType : "image/png" }))
256
+ : undefined;
257
+ const r = await runTurn(s, p.text, images);
189
258
  return reply(rpcResult(id, r));
190
259
  }
191
260
  case "session.interrupt": {
@@ -229,6 +298,31 @@ export async function startServe(opts, deps) {
229
298
  return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
230
299
  return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
231
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
+ }
232
326
  case "models.list": {
233
327
  const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
234
328
  return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
@@ -259,14 +353,116 @@ export async function startServe(opts, deps) {
259
353
  // The automation timeline's data: cron jobs with their last outcome, plus this machine's
260
354
  // automated sessions (source=cron/gateway) so the desktop can render results and "continue
261
355
  // as conversation". Read-only.
262
- const jobs = loadJobs().map((j) => ({ id: j.id, name: j.name, mode: j.mode, cwd: j.cwd, enabled: j.enabled, deliver: j.deliver, lastRunAt: j.lastRunAt, lastStatus: j.lastStatus, lastError: j.lastError }));
356
+ const jobs = loadJobs().map((j) => ({ id: j.id, name: j.name, mode: j.mode, cwd: j.cwd, enabled: j.enabled, deliver: j.deliver, lastRunAt: j.lastRunAt, lastStatus: j.lastStatus, lastError: j.lastError, schedule: describeSchedule(j.schedule) }));
263
357
  const automated = hub.list().filter((m) => m.source === "cron" || m.source === "gateway").map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, source: m.source, sourceName: m.sourceName, updatedAt: m.updatedAt }));
264
358
  return reply(rpcResult(id, { jobs, sessions: automated }));
265
359
  }
360
+ case "automation.add": {
361
+ if (typeof p.name !== "string" || !p.name || typeof p.schedule !== "string" || typeof p.task !== "string" || !p.task) {
362
+ return reply(rpcError(id, ERR.PARAMS, "name + schedule + task required"));
363
+ }
364
+ const sched = parseSchedule(p.schedule, Date.now());
365
+ if ("error" in sched)
366
+ return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
367
+ const job = addJob({
368
+ name: p.name.slice(0, 60),
369
+ schedule: sched,
370
+ task: p.task,
371
+ mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
372
+ cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
373
+ ...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
374
+ createdAt: Date.now(),
375
+ });
376
+ return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
377
+ }
378
+ case "automation.toggle": {
379
+ if (typeof p.id !== "string" || typeof p.enabled !== "boolean")
380
+ return reply(rpcError(id, ERR.PARAMS, "id + enabled required"));
381
+ const jobs = loadJobs();
382
+ const job = jobs.find((j) => j.id === p.id);
383
+ if (!job)
384
+ return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
385
+ job.enabled = p.enabled;
386
+ saveJobs(jobs);
387
+ return reply(rpcResult(id, { id: job.id, enabled: job.enabled }));
388
+ }
389
+ case "automation.delete": {
390
+ if (typeof p.id !== "string")
391
+ return reply(rpcError(id, ERR.PARAMS, "id required"));
392
+ const jobs = loadJobs();
393
+ if (!jobs.some((j) => j.id === p.id))
394
+ return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
395
+ saveJobs(jobs.filter((j) => j.id !== p.id));
396
+ return reply(rpcResult(id, { id: p.id, deleted: true }));
397
+ }
266
398
  case "skills.list": {
267
399
  const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
268
400
  return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
269
401
  }
402
+ case "files.search": {
403
+ // fuzzy file lookup for the composer's @-mention autocomplete (codex fuzzyFileSearch).
404
+ // Relative POSIX paths; empty query returns the first files as a browse list.
405
+ const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
406
+ const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
407
+ const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
408
+ const all = listProjectFiles(cwd);
409
+ const query = typeof p.query === "string" ? p.query : "";
410
+ const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
411
+ return reply(rpcResult(id, { files, cwd }));
412
+ }
413
+ case "session.context": {
414
+ // context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
415
+ if (typeof p.sessionId !== "string")
416
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
417
+ const s = hub.get(p.sessionId);
418
+ if (!s)
419
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
420
+ const report = analyzeContext(s.history);
421
+ return reply(rpcResult(id, { sessionId: s.meta.id, ...ctxOf(s), total: report.total, rows: report.rows.slice(0, 8) }));
422
+ }
423
+ case "session.compact": {
424
+ // manual compaction (codex thread/compact/start): summarize + replace history, keep working
425
+ // notes, restore this-cwd touched files. Busy-guarded like a turn — it IS a provider call.
426
+ if (typeof p.sessionId !== "string")
427
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
428
+ const s = hub.get(p.sessionId);
429
+ if (!s)
430
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
431
+ if (s.busy)
432
+ return reply(rpcError(id, ERR.BUSY, "a turn is running — compact after it finishes"));
433
+ if (s.history.length < 2)
434
+ return reply(rpcError(id, ERR.PARAMS, "nothing to compact yet"));
435
+ s.busy = true;
436
+ try {
437
+ broadcast("event.notice", { sessionId: s.meta.id, text: "✻ Compacting conversation…" });
438
+ const summary = await compactSession(s);
439
+ if (!summary)
440
+ return reply(rpcError(id, ERR.INTERNAL, "compaction failed — try again or /clear"));
441
+ broadcast("event.notice", { sessionId: s.meta.id, text: `(compacted — history replaced with a summary; ${s.meta.workingSet?.length ?? 0} notes kept)` });
442
+ return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
443
+ }
444
+ finally {
445
+ s.busy = false;
446
+ }
447
+ }
448
+ case "session.rewind": {
449
+ // fork the thread back to before the n-th-most-recent user turn (codex thread/rollback;
450
+ // n=1 drops the last exchange). History-only — file edits are not reverted.
451
+ if (typeof p.sessionId !== "string" || !Number.isInteger(p.n))
452
+ return reply(rpcError(id, ERR.PARAMS, "sessionId + n required"));
453
+ const s = hub.get(p.sessionId);
454
+ if (!s)
455
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
456
+ if (s.busy)
457
+ return reply(rpcError(id, ERR.BUSY, "a turn is running — rewind after it finishes"));
458
+ const next = rewindTo(s.history, p.n);
459
+ if (!next)
460
+ return reply(rpcError(id, ERR.PARAMS, `n out of range (1..${s.history.filter((m) => m.role === "user").length})`));
461
+ s.history.length = 0;
462
+ s.history.push(...next);
463
+ hub.save(s);
464
+ return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
465
+ }
270
466
  default:
271
467
  return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
272
468
  }
@@ -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.116.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"