@nanhara/hara 0.115.0 → 0.116.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,22 @@ 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.116.0 — the desktop-grade serve protocol (models · automation · rename/archive · capabilities)
9
+
10
+ - **Session source stamping.** Sessions now record WHO created them (`interactive` / `gateway` / `cron`,
11
+ env-derived; the cron runner passes the job name along) — and automated sessions get a
12
+ **"name · MM-DD HH:mm" title at creation**, so a cron prompt never becomes a session title again.
13
+ - **`hara serve` protocol, batch 2** (everything the Hara desktop app drives):
14
+ - `models.list` (endpoint model catalog + thinking-effort levels) and `session.set-model`
15
+ (per-session model/effort switch, applies next turn)
16
+ - `automation.list` — cron jobs with last outcome + automated sessions, for the desktop's
17
+ automation timeline
18
+ - `session.rename` + `session.archive` (archived sessions hidden from lists, kept on disk)
19
+ - `initialize` now advertises `capabilities.methods` — clients feature-detect instead of
20
+ probing for method-not-found
21
+ - `session.send` expands `@file` mentions (CLI parity)
22
+ - `session.list` carries `source` / `sourceName` / `archived`
23
+
8
24
  ## 0.115.0 — serve exposes plugins & skills (desktop plugin panel)
9
25
 
10
26
  - **`hara serve` protocol grows a plugin surface**: `plugins.list` (installed plugins with enabled state +
@@ -55,7 +55,9 @@ export function runJobOnce(job) {
55
55
  const [cmd, argv] = job.mode === "command"
56
56
  ? ["bash", ["-lc", job.task]]
57
57
  : [self[0], [...self.slice(1), ...(job.mode === "org" ? ["org", job.task] : ["-p", job.task, "--approval", "full-auto"])]];
58
- const child = spawn(cmd, argv, { cwd: job.cwd, env: { ...process.env, HARA_CRON: "1" } });
58
+ // HARA_CRON_NAME rides along so the child session's meta gets a human title ("job name · time")
59
+ // instead of the raw prompt (session store's automated-title strategy).
60
+ const child = spawn(cmd, argv, { cwd: job.cwd, env: { ...process.env, HARA_CRON: "1", HARA_CRON_NAME: job.name } });
59
61
  let tail = ""; // last few KB, for chat delivery (the full stream goes to the log file)
60
62
  const append = (d) => {
61
63
  tail = (tail + d.toString()).slice(-4_000);
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ import { EXPLORE_SYSTEM } from "./tools/agent.js";
45
45
  import { createAnthropicProvider } from "./providers/anthropic.js";
46
46
  import { createOpenAIProvider } from "./providers/openai.js";
47
47
  import { resolvePlatform } from "./providers/registry.js";
48
+ import { levelsFor } from "./tui/model-picker.js";
48
49
  import { listModels } from "./providers/models.js";
49
50
  import { listJobs, tailJob, killJob } from "./exec/jobs.js";
50
51
  /** Render the background-job list for /jobs (user-facing view of what the agent has running in the
@@ -67,7 +68,7 @@ import { getEmbedder } from "./search/embed.js";
67
68
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
68
69
  import { searchHybrid } from "./search/hybrid.js";
69
70
  import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
70
- import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
71
+ import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
71
72
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
72
73
  import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
73
74
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
@@ -1579,6 +1580,9 @@ program
1579
1580
  providerId: cfg.provider,
1580
1581
  model: cfg.model,
1581
1582
  buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
1583
+ buildProviderFor: async (model, effort) => withRouting(await buildProvider({ ...cfg, model, reasoningEffort: effort ?? cfg.reasoningEffort }), cfg),
1584
+ listModels: () => listModels(cfg.baseURL ?? providerDefaultBaseURL(cfg.provider), cfg.apiKey ?? ""),
1585
+ effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
1582
1586
  spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
1583
1587
  guardian: guardianOpt,
1584
1588
  sandbox,
@@ -2251,7 +2255,19 @@ program.action(async (opts) => {
2251
2255
  const prior = rid ? loadSession(rid) : null;
2252
2256
  if (prior?.history)
2253
2257
  history.push(...prior.history);
2254
- meta = prior?.meta ?? { id: rid ?? newSessionId(), cwd, provider: cfg.provider, model: cfg.model, title: "", createdAt: new Date().toISOString(), updatedAt: "" };
2258
+ // Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
2259
+ // automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
2260
+ const src = sessionSourceFromEnv();
2261
+ meta = prior?.meta ?? {
2262
+ id: rid ?? newSessionId(),
2263
+ cwd,
2264
+ provider: cfg.provider,
2265
+ model: cfg.model,
2266
+ title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
2267
+ createdAt: new Date().toISOString(),
2268
+ updatedAt: "",
2269
+ ...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
2270
+ };
2255
2271
  // Apply per-session pinned model on headless resume (mirrors the interactive path).
2256
2272
  // --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
2257
2273
  if (prior) {
@@ -2431,6 +2447,7 @@ program.action(async (opts) => {
2431
2447
  title: "",
2432
2448
  createdAt: new Date().toISOString(),
2433
2449
  updatedAt: "",
2450
+ source: "interactive",
2434
2451
  };
2435
2452
  // Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
2436
2453
  // corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
@@ -3,7 +3,8 @@
3
3
  // Everything here is PURE (parse + frame builders + error codes) and unit-tested.
4
4
  //
5
5
  // Client → server requests:
6
- // initialize {token} → {name,version,protocol,cwd,provider,model}
6
+ // initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,
7
+ // capabilities:{methods:[…]}} (feature detection)
7
8
  // session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
8
9
  // session.create {cwd?,approval?} → {sessionId,model}
9
10
  // session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
@@ -13,6 +14,12 @@
13
14
  // plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
14
15
  // plugins.set {name,enabled} → {name,enabled} (applies to future sessions/turns)
15
16
  // skills.list {cwd?} → {skills:[{id,description,source}]}
17
+ // automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
18
+ // sessions:[{id,title,source,sourceName,updatedAt,…}]}
19
+ // models.list {} → {models:[…], current, effortLevels:[…]}
20
+ // session.rename {sessionId,title} → {sessionId,title}
21
+ // session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
22
+ // session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
16
23
  // Server → client notifications (all carry sessionId):
17
24
  // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
18
25
  // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
@@ -11,9 +11,11 @@ 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
13
  import { loadAgentsMd } from "../context/agents-md.js";
14
+ import { expandMentions } from "../context/mentions.js";
14
15
  import { memoryDigest } from "../memory/store.js";
15
16
  import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
16
17
  import { loadSkillIndex } from "../skills/skills.js";
18
+ import { loadJobs } from "../cron/store.js";
17
19
  import { SessionHub, realStore } from "./sessions.js";
18
20
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
19
21
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
@@ -94,7 +96,8 @@ export async function startServe(opts, deps) {
94
96
  broadcast("approval.request", { sessionId, approvalId, question: q });
95
97
  });
96
98
  try {
97
- s.history.push({ role: "user", content: text });
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) });
98
101
  await runAgent(s.history, {
99
102
  provider: s.provider,
100
103
  ctx: {
@@ -137,13 +140,20 @@ export async function startServe(opts, deps) {
137
140
  if (typeof p.token !== "string" || !sameToken(p.token, token))
138
141
  return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
139
142
  authed.add(ws);
140
- return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model }));
143
+ // capability negotiation (codex app-server pattern): the server ADVERTISES its method set so
144
+ // clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
145
+ // (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
146
+ const methods = [
147
+ "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",
149
+ ];
150
+ return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
141
151
  }
142
152
  if (!authed.has(ws))
143
153
  return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
144
154
  switch (req.method) {
145
155
  case "session.list":
146
- return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt })) }));
156
+ return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).filter((m) => !m.archived || p.archived === true).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt, source: m.source ?? "interactive", sourceName: m.sourceName, archived: m.archived ?? false })) }));
147
157
  case "session.create": {
148
158
  const provider = await deps.buildSessionProvider();
149
159
  if (!provider)
@@ -195,7 +205,7 @@ export async function startServe(opts, deps) {
195
205
  }
196
206
  case "plugins.list": {
197
207
  const on = new Set(enabledPlugins().map((pl) => pl.name));
198
- return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length })) }));
208
+ return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length, panels: pl.manifest.panels ?? [] })) }));
199
209
  }
200
210
  case "plugins.set": {
201
211
  if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
@@ -205,6 +215,54 @@ export async function startServe(opts, deps) {
205
215
  setPluginEnabled(p.name, p.enabled);
206
216
  return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
207
217
  }
218
+ case "session.rename": {
219
+ if (typeof p.sessionId !== "string" || typeof p.title !== "string")
220
+ return reply(rpcError(id, ERR.PARAMS, "sessionId + title required"));
221
+ if (!hub.rename(p.sessionId, p.title.slice(0, 120)))
222
+ return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
223
+ return reply(rpcResult(id, { sessionId: p.sessionId, title: p.title.slice(0, 120) }));
224
+ }
225
+ case "session.archive": {
226
+ if (typeof p.sessionId !== "string" || typeof p.archived !== "boolean")
227
+ return reply(rpcError(id, ERR.PARAMS, "sessionId + archived required"));
228
+ if (!hub.setArchived(p.sessionId, p.archived))
229
+ return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
230
+ return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
231
+ }
232
+ case "models.list": {
233
+ const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
234
+ return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
235
+ }
236
+ case "session.set-model": {
237
+ // per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
238
+ // provider; takes effect on the NEXT turn. Refused mid-turn.
239
+ if (typeof p.sessionId !== "string")
240
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
241
+ const s = hub.get(p.sessionId);
242
+ if (!s)
243
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
244
+ if (s.busy)
245
+ return reply(rpcError(id, ERR.BUSY, "a turn is running — switch after it finishes"));
246
+ const model = typeof p.model === "string" && p.model ? p.model : s.meta.model;
247
+ const effort = typeof p.effort === "string" && p.effort ? p.effort : undefined;
248
+ if (!deps.buildProviderFor)
249
+ return reply(rpcError(id, ERR.METHOD, "model switching not supported by this server"));
250
+ const provider = await deps.buildProviderFor(model, effort);
251
+ if (!provider)
252
+ return reply(rpcError(id, ERR.INTERNAL, `could not build provider for ${model}`));
253
+ s.provider = provider;
254
+ s.meta.model = model;
255
+ s.effort = effort;
256
+ return reply(rpcResult(id, { sessionId: s.meta.id, model, effort: effort ?? null }));
257
+ }
258
+ case "automation.list": {
259
+ // The automation timeline's data: cron jobs with their last outcome, plus this machine's
260
+ // automated sessions (source=cron/gateway) so the desktop can render results and "continue
261
+ // 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 }));
263
+ 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
+ return reply(rpcResult(id, { jobs, sessions: automated }));
265
+ }
208
266
  case "skills.list": {
209
267
  const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
210
268
  return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
@@ -22,6 +22,7 @@ export class SessionHub {
22
22
  title: "",
23
23
  createdAt: new Date().toISOString(),
24
24
  updatedAt: "",
25
+ source: "interactive", // serve sessions are user-driven (desktop/IDE clients)
25
26
  };
26
27
  this.store.acquire(meta.id); // fresh id — always ours; registers the single-writer claim
27
28
  const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
@@ -58,6 +59,36 @@ export class SessionHub {
58
59
  }
59
60
  this.store.save(s.meta, s.history);
60
61
  }
62
+ /** Rename a session (live or on-disk). Returns false when the id is unknown. */
63
+ rename(id, title) {
64
+ const live = this.sessions.get(id);
65
+ if (live) {
66
+ live.meta.title = title;
67
+ this.store.save(live.meta, live.history);
68
+ return true;
69
+ }
70
+ const prior = this.store.load(id);
71
+ if (!prior)
72
+ return false;
73
+ prior.meta.title = title;
74
+ this.store.save(prior.meta, prior.history);
75
+ return true;
76
+ }
77
+ /** Archive/unarchive (hidden from lists, kept on disk). Returns false when unknown. */
78
+ setArchived(id, on) {
79
+ const live = this.sessions.get(id);
80
+ if (live) {
81
+ live.meta.archived = on;
82
+ this.store.save(live.meta, live.history);
83
+ return true;
84
+ }
85
+ const prior = this.store.load(id);
86
+ if (!prior)
87
+ return false;
88
+ prior.meta.archived = on;
89
+ this.store.save(prior.meta, prior.history);
90
+ return true;
91
+ }
61
92
  /** Release all locks (server shutdown). In-flight turns are aborted by the caller first. */
62
93
  releaseAll() {
63
94
  for (const id of this.sessions.keys())
@@ -3,6 +3,21 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
+ /** Derive the session source from the spawn environment — the gateway subprocess runs with
7
+ * HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
8
+ export function sessionSourceFromEnv() {
9
+ if (process.env.HARA_CRON)
10
+ return { source: "cron", sourceName: process.env.HARA_CRON_NAME || undefined };
11
+ if (process.env.HARA_GATEWAY)
12
+ return { source: "gateway", sourceName: process.env.HARA_GATEWAY };
13
+ return { source: "interactive" };
14
+ }
15
+ /** Title for a NON-interactive session: "name · MM-DD HH:mm" — the raw prompt never becomes a title. */
16
+ export function automatedTitle(source, sourceName, at = new Date()) {
17
+ const pad = (n) => String(n).padStart(2, "0");
18
+ const stamp = `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
19
+ return `${sourceName || source} · ${stamp}`;
20
+ }
6
21
  function sessionsDir() {
7
22
  const d = join(homedir(), ".hara", "sessions");
8
23
  mkdirSync(d, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.115.0",
3
+ "version": "0.116.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"