@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 +11 -0
- package/dist/serve/server.js +40 -3
- package/dist/serve/sessions.js +49 -1
- package/dist/session/store.js +19 -0
- package/package.json +1 -1
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
|
package/dist/serve/server.js
CHANGED
|
@@ -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(
|
|
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 ?? [] }));
|
package/dist/serve/sessions.js
CHANGED
|
@@ -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())
|
package/dist/session/store.js
CHANGED
|
@@ -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`. */
|