@nanhara/hara 0.114.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,29 @@ 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
+
24
+ ## 0.115.0 — serve exposes plugins & skills (desktop plugin panel)
25
+
26
+ - **`hara serve` protocol grows a plugin surface**: `plugins.list` (installed plugins with enabled state +
27
+ contribution counts), `plugins.set` (enable/disable by name — applies to future sessions/turns), and
28
+ `skills.list` (the skill index for a cwd). This powers the hara desktop app's plugin manager panel;
29
+ any WS client gets it for free.
30
+
8
31
  ## 0.114.0 — long files read in slices · repeat-guard anti-spinning · `hara serve` (the desktop/IDE backbone)
9
32
 
10
33
  - **Long files no longer flood the context.** `read_file` now returns cat-n numbered lines with
@@ -1,12 +1,3 @@
1
- // Cron result delivery — push a finished job's output to a chat channel (openclaw/hermes parity),
2
- // WITHOUT needing the gateway process: adapters are constructed one-shot from the same env vars the
3
- // gateway uses, send once, and are dropped. Spec format: "<target>:<id>" —
4
- // telegram:<chatId> (HARA_TELEGRAM_TOKEN)
5
- // feishu:<chatId> (HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET)
6
- // webhook:<url> (plain POST {name,status,text} JSON — for anything else)
7
- // WeChat is intentionally absent: its transport needs the long-lived gateway session, so a one-shot
8
- // cron process can't speak it — use feishu/telegram/webhook for cron delivery.
9
- // Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
10
1
  /** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
11
2
  export function parseDeliver(spec) {
12
3
  const i = spec.indexOf(":");
@@ -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);
@@ -0,0 +1,89 @@
1
+ // hara gateway flows — user-configured rules that intercept inbound gateway messages and route matching
2
+ // ones to an agent task + a delivery target, instead of the gateway's default DM-driver reply. This turns
3
+ // any chat gateway (Telegram / WeChat / Feishu / Slack / …) into an automation trigger: "when a message
4
+ // matching <trigger> arrives, run <agent task> and deliver the result to <target>".
5
+ //
6
+ // Opt-in: config lives in the user's ~/.hara/flows.json — no file, no flows (zero behaviour change).
7
+ // Platform-agnostic: matching only reads the generic InboundMsg fields each adapter populates (chatType,
8
+ // mentions), so a flow works on whatever platform surfaces the data it asks for.
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { deliverResult } from "../cron/deliver.js";
13
+ const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
14
+ /** Load ~/.hara/flows.json — accepts a bare array or `{ "flows": [...] }`. Missing/malformed → [] (never throws). */
15
+ export function loadFlows() {
16
+ try {
17
+ const parsed = JSON.parse(readFileSync(join(homedir(), ".hara", "flows.json"), "utf8"));
18
+ const flows = Array.isArray(parsed) ? parsed : parsed?.flows;
19
+ return Array.isArray(flows) ? flows.filter((f) => f && f.enabled !== false && f.name && f.do) : [];
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ }
25
+ /** Pure predicate: does message `m` on `platform` satisfy rule `r`'s trigger? */
26
+ export function matchFlow(r, m, platform) {
27
+ const on = r.on ?? {};
28
+ if (on.platform && on.platform.toLowerCase() !== platform.toLowerCase())
29
+ return false;
30
+ const chats = asArray(on.chat);
31
+ if (chats.length && !chats.includes(String(m.chatId)))
32
+ return false;
33
+ if (on.chatType && on.chatType !== "any") {
34
+ if (!m.chatType || m.chatType !== on.chatType)
35
+ return false; // rule wants a specific kind the adapter didn't confirm
36
+ }
37
+ if (on.mention && on.mention !== "any") {
38
+ const ms = m.mentions ?? [];
39
+ if (on.mention === "self") {
40
+ if (!ms.some((x) => x.isSelf))
41
+ return false;
42
+ }
43
+ else {
44
+ const want = asArray(on.mention);
45
+ if (!ms.some((x) => x.id && want.includes(x.id)))
46
+ return false;
47
+ }
48
+ }
49
+ const kws = asArray(on.keyword);
50
+ if (kws.length && !kws.some((k) => (m.text ?? "").includes(k)))
51
+ return false;
52
+ return true;
53
+ }
54
+ /** Compose the agent prompt for a matched flow (English scaffolding; the user's do/guard carry the intent). */
55
+ export function buildFlowPrompt(r, m) {
56
+ return (r.do +
57
+ (r.guard ? `\n\nConstraint: ${r.guard}` : "") +
58
+ `\n\n--- Triggering message ---\nchat ${m.chatId}${m.chatType ? ` (${m.chatType})` : ""} · from ${m.userName || m.userId}\n${m.text}`);
59
+ }
60
+ /** Try to handle `m` via configured flows. Returns true if ≥1 rule matched (caller should STOP default routing).
61
+ * The agent run + delivery are fire-and-forget so a slow LLM call never blocks the gateway's event loop.
62
+ * `runAgent` runs the prompt (injected by the gateway so we reuse its session/env plumbing); `reply` (optional)
63
+ * sends text back to the originating chat. */
64
+ export async function dispatchFlows(m, platform, runAgent, reply) {
65
+ const matched = loadFlows().filter((r) => matchFlow(r, m, platform));
66
+ if (!matched.length)
67
+ return false;
68
+ for (const r of matched) {
69
+ console.error(`hara flow: "${r.name}" matched · ${platform} ${m.chatType ?? "?"} ${m.chatId}`);
70
+ void (async () => {
71
+ try {
72
+ const output = (await runAgent(buildFlowPrompt(r, m))).trim();
73
+ if (!output)
74
+ return;
75
+ if (r.deliver) {
76
+ const err = await deliverResult(r.deliver, output);
77
+ if (err)
78
+ console.error(`hara flow "${r.name}": deliver failed — ${err}`);
79
+ }
80
+ if (r.reply && reply)
81
+ await reply(output).catch(() => { });
82
+ }
83
+ catch (e) {
84
+ console.error(`hara flow "${r.name}": ${e instanceof Error ? e.message : String(e)}`);
85
+ }
86
+ })();
87
+ }
88
+ return true;
89
+ }
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,13 +3,23 @@
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}]}
10
11
  // session.send {sessionId,text} → (streams events, then) {reply,usage}
11
12
  // session.interrupt {sessionId} → {}
12
13
  // approval.reply {approvalId,allow,always?} → {}
14
+ // plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
15
+ // plugins.set {name,enabled} → {name,enabled} (applies to future sessions/turns)
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)
13
23
  // Server → client notifications (all carry sessionId):
14
24
  // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
15
25
  // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
@@ -11,7 +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";
16
+ import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
17
+ import { loadSkillIndex } from "../skills/skills.js";
18
+ import { loadJobs } from "../cron/store.js";
15
19
  import { SessionHub, realStore } from "./sessions.js";
16
20
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
17
21
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
@@ -92,7 +96,8 @@ export async function startServe(opts, deps) {
92
96
  broadcast("approval.request", { sessionId, approvalId, question: q });
93
97
  });
94
98
  try {
95
- 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) });
96
101
  await runAgent(s.history, {
97
102
  provider: s.provider,
98
103
  ctx: {
@@ -135,13 +140,20 @@ export async function startServe(opts, deps) {
135
140
  if (typeof p.token !== "string" || !sameToken(p.token, token))
136
141
  return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
137
142
  authed.add(ws);
138
- 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 } }));
139
151
  }
140
152
  if (!authed.has(ws))
141
153
  return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
142
154
  switch (req.method) {
143
155
  case "session.list":
144
- 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 })) }));
145
157
  case "session.create": {
146
158
  const provider = await deps.buildSessionProvider();
147
159
  if (!provider)
@@ -191,6 +203,70 @@ export async function startServe(opts, deps) {
191
203
  resolve(p.always === true ? "always" : p.allow === true);
192
204
  return reply(rpcResult(id, {})); // idempotent — a late/duplicate reply is a no-op
193
205
  }
206
+ case "plugins.list": {
207
+ const on = new Set(enabledPlugins().map((pl) => pl.name));
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 ?? [] })) }));
209
+ }
210
+ case "plugins.set": {
211
+ if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
212
+ return reply(rpcError(id, ERR.PARAMS, "name + enabled required"));
213
+ if (!listInstalled().some((pl) => pl.name === p.name))
214
+ return reply(rpcError(id, ERR.PARAMS, `no installed plugin "${p.name}"`));
215
+ setPluginEnabled(p.name, p.enabled);
216
+ return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
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
+ }
266
+ case "skills.list": {
267
+ const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
268
+ return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
269
+ }
194
270
  default:
195
271
  return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
196
272
  }
@@ -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.114.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"