@nanhara/hara 0.115.0 → 0.117.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,41 @@ 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.117.0 — serve batch 3: context watermark · compact · rewind · fuzzy file search
9
+
10
+ Codex-desktop parity for the conversation-hygiene set — everything a client needs to keep a long
11
+ session healthy without dropping to the CLI:
12
+
13
+ - **Context watermark everywhere.** Every `session.send` result and `event.turn_end` now carries
14
+ `ctx: { lastInput, window, pct }` — how full the model's window was on the last turn. Clients
15
+ render a live meter with zero extra round-trips. `session.context` returns the same watermark
16
+ plus the spend breakdown (your messages / assistant / per-tool) on demand.
17
+ - **`session.compact`** — the CLI's `/compact`, serve-side: summarize-and-replace with the same
18
+ 8-section brief, working-memory notes kept on the session, and the recently-touched-file restore
19
+ (limited to files under the session's own cwd — serve is multi-session; nothing leaks across
20
+ projects). Busy-guarded like a turn.
21
+ - **`session.rewind`** — fork the thread back to before the n-th-most-recent user turn (codex
22
+ `thread/rollback`). History-only; file edits are not reverted.
23
+ - **`files.search`** — fuzzy project-file lookup (git-aware listing + the CLI's fuzzy ranker),
24
+ powering @-mention autocomplete in the desktop composer. Resolves the search root from an
25
+ explicit `cwd` or a `sessionId`.
26
+
27
+ ## 0.116.0 — the desktop-grade serve protocol (models · automation · rename/archive · capabilities)
28
+
29
+ - **Session source stamping.** Sessions now record WHO created them (`interactive` / `gateway` / `cron`,
30
+ env-derived; the cron runner passes the job name along) — and automated sessions get a
31
+ **"name · MM-DD HH:mm" title at creation**, so a cron prompt never becomes a session title again.
32
+ - **`hara serve` protocol, batch 2** (everything the Hara desktop app drives):
33
+ - `models.list` (endpoint model catalog + thinking-effort levels) and `session.set-model`
34
+ (per-session model/effort switch, applies next turn)
35
+ - `automation.list` — cron jobs with last outcome + automated sessions, for the desktop's
36
+ automation timeline
37
+ - `session.rename` + `session.archive` (archived sessions hidden from lists, kept on disk)
38
+ - `initialize` now advertises `capabilities.methods` — clients feature-detect instead of
39
+ probing for method-not-found
40
+ - `session.send` expands `@file` mentions (CLI parity)
41
+ - `session.list` carries `source` / `sourceName` / `archived`
42
+
8
43
  ## 0.115.0 — serve exposes plugins & skills (desktop plugin panel)
9
44
 
10
45
  - **`hara serve` protocol grows a plugin surface**: `plugins.list` (installed plugins with enabled state +
@@ -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).
@@ -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
@@ -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";
@@ -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";
@@ -757,13 +758,8 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
757
758
  "conventions / user preferences from the logs that are NOT already captured, and persist each with " +
758
759
  "memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
759
760
  "Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
760
- // COMPACT_SYSTEM (the 8-section brief) lives in agent/compact.ts so tests can pin its structure.
761
- const workingSetFromSummary = (s) => s
762
- .split("\n")
763
- .map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
764
- .filter((l) => l.length > 3)
765
- .slice(0, 12)
766
- .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.
767
763
  /** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
768
764
  * /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
769
765
  async function compactConversation(provider, history, meta, stats) {
@@ -1579,6 +1575,9 @@ program
1579
1575
  providerId: cfg.provider,
1580
1576
  model: cfg.model,
1581
1577
  buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
1578
+ buildProviderFor: async (model, effort) => withRouting(await buildProvider({ ...cfg, model, reasoningEffort: effort ?? cfg.reasoningEffort }), cfg),
1579
+ listModels: () => listModels(cfg.baseURL ?? providerDefaultBaseURL(cfg.provider), cfg.apiKey ?? ""),
1580
+ effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
1582
1581
  spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
1583
1582
  guardian: guardianOpt,
1584
1583
  sandbox,
@@ -2251,7 +2250,19 @@ program.action(async (opts) => {
2251
2250
  const prior = rid ? loadSession(rid) : null;
2252
2251
  if (prior?.history)
2253
2252
  history.push(...prior.history);
2254
- meta = prior?.meta ?? { id: rid ?? newSessionId(), cwd, provider: cfg.provider, model: cfg.model, title: "", createdAt: new Date().toISOString(), updatedAt: "" };
2253
+ // Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
2254
+ // automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
2255
+ const src = sessionSourceFromEnv();
2256
+ meta = prior?.meta ?? {
2257
+ id: rid ?? newSessionId(),
2258
+ cwd,
2259
+ provider: cfg.provider,
2260
+ model: cfg.model,
2261
+ title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
2262
+ createdAt: new Date().toISOString(),
2263
+ updatedAt: "",
2264
+ ...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
2265
+ };
2255
2266
  // Apply per-session pinned model on headless resume (mirrors the interactive path).
2256
2267
  // --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
2257
2268
  if (prior) {
@@ -2431,6 +2442,7 @@ program.action(async (opts) => {
2431
2442
  title: "",
2432
2443
  createdAt: new Date().toISOString(),
2433
2444
  updatedAt: "",
2445
+ source: "interactive",
2434
2446
  };
2435
2447
  // Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
2436
2448
  // corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
@@ -3,16 +3,27 @@
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
- // 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
11
13
  // session.interrupt {sessionId} → {}
12
14
  // approval.reply {approvalId,allow,always?} → {}
13
15
  // plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
14
16
  // plugins.set {name,enabled} → {name,enabled} (applies to future sessions/turns)
15
17
  // skills.list {cwd?} → {skills:[{id,description,source}]}
18
+ // automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
19
+ // sessions:[{id,title,source,sourceName,updatedAt,…}]}
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}
24
+ // session.rename {sessionId,title} → {sessionId,title}
25
+ // session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
26
+ // session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
16
27
  // Server → client notifications (all carry sessionId):
17
28
  // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
18
29
  // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
@@ -5,15 +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";
21
+ import { expandMentions } from "../context/mentions.js";
14
22
  import { memoryDigest } from "../memory/store.js";
15
23
  import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
16
24
  import { loadSkillIndex } from "../skills/skills.js";
25
+ import { loadJobs, saveJobs, addJob } from "../cron/store.js";
26
+ import { parseSchedule, describeSchedule } from "../cron/schedule.js";
17
27
  import { SessionHub, realStore } from "./sessions.js";
18
28
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
19
29
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
@@ -68,7 +78,7 @@ export async function startServe(opts, deps) {
68
78
  writeFileSync(discoveryPath, JSON.stringify({ host: opts.host, port, token, pid: process.pid, version: deps.version }, null, 2), { mode: 0o600 });
69
79
  }
70
80
  /** Run one turn on a session, streaming events to all authed clients. */
71
- const runTurn = async (s, text) => {
81
+ const runTurn = async (s, text, images) => {
72
82
  const sessionId = s.meta.id;
73
83
  s.busy = true;
74
84
  s.abort = new AbortController();
@@ -94,7 +104,9 @@ export async function startServe(opts, deps) {
94
104
  broadcast("approval.request", { sessionId, approvalId, question: q });
95
105
  });
96
106
  try {
97
- s.history.push({ role: "user", content: text });
107
+ // @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
108
+ // 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 } : {}) });
98
110
  await runAgent(s.history, {
99
111
  provider: s.provider,
100
112
  ctx: {
@@ -115,14 +127,57 @@ export async function startServe(opts, deps) {
115
127
  hub.save(s);
116
128
  const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
117
129
  const reply = lastAssistantText(s.history);
118
- broadcast("event.turn_end", { sessionId, reply, usage });
119
- return { reply, usage };
130
+ // context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern)
131
+ // clients render a meter without an extra round-trip.
132
+ const ctx = ctxOf(s);
133
+ broadcast("event.turn_end", { sessionId, reply, usage, ctx });
134
+ return { reply, usage, ctx };
120
135
  }
121
136
  finally {
122
137
  s.busy = false;
123
138
  s.abort = null;
124
139
  }
125
140
  };
141
+ /** Context watermark for a session: how full the model's window was on the last turn. */
142
+ const ctxOf = (s) => {
143
+ const lastInput = s.stats.lastInput ?? 0;
144
+ return { lastInput, window: contextWindow(s.meta.model), pct: ctxPctFor(s.meta.model, lastInput) };
145
+ };
146
+ /** Summarize + replace a session's history — the CLI's /compact, serve-side (codex thread/compact).
147
+ * Mirrors index.ts compactConversation; the file restore is limited to files under the session's own
148
+ * cwd because serve is multi-session (recentTouched is process-wide and must not leak across projects). */
149
+ const compactSession = async (s) => {
150
+ const r = await s.provider.turn({
151
+ system: COMPACT_SYSTEM,
152
+ history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
153
+ tools: [],
154
+ onText: () => { },
155
+ });
156
+ if (r.stop === "error")
157
+ return null;
158
+ const summary = r.text.trim();
159
+ if (!summary)
160
+ return null;
161
+ s.meta.workingSet = workingSetFromSummary(summary);
162
+ s.history.length = 0;
163
+ s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
164
+ const touched = recentTouched(20).filter((f) => f.startsWith(s.meta.cwd)).slice(0, 5);
165
+ const restore = buildFileRestore(touched, (f) => {
166
+ try {
167
+ return readFileSync(f, "utf8");
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ });
173
+ if (restore)
174
+ s.history.push({ role: "user", content: restore });
175
+ s.stats.input += r.usage?.input ?? 0;
176
+ s.stats.output += r.usage?.output ?? 0;
177
+ s.stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary
178
+ hub.save(s);
179
+ return summary;
180
+ };
126
181
  wss.on("connection", (ws) => {
127
182
  ws.on("message", async (raw) => {
128
183
  const parsed = parseFrame(String(raw));
@@ -137,13 +192,22 @@ export async function startServe(opts, deps) {
137
192
  if (typeof p.token !== "string" || !sameToken(p.token, token))
138
193
  return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
139
194
  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 }));
195
+ // capability negotiation (codex app-server pattern): the server ADVERTISES its method set so
196
+ // clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
197
+ // (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
198
+ const methods = [
199
+ "session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
200
+ "session.rename", "session.archive", "session.compact", "session.rewind", "session.context",
201
+ "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search",
202
+ "automation.list", "automation.add", "automation.toggle", "automation.delete",
203
+ ];
204
+ return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
141
205
  }
142
206
  if (!authed.has(ws))
143
207
  return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
144
208
  switch (req.method) {
145
209
  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 })) }));
210
+ 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
211
  case "session.create": {
148
212
  const provider = await deps.buildSessionProvider();
149
213
  if (!provider)
@@ -175,7 +239,10 @@ export async function startServe(opts, deps) {
175
239
  return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
176
240
  if (s.busy)
177
241
  return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
178
- const r = await runTurn(s, p.text);
242
+ const images = Array.isArray(p.images)
243
+ ? p.images.filter((im) => im && typeof im.path === "string").map((im) => ({ path: im.path, mediaType: typeof im.mediaType === "string" ? im.mediaType : "image/png" }))
244
+ : undefined;
245
+ const r = await runTurn(s, p.text, images);
179
246
  return reply(rpcResult(id, r));
180
247
  }
181
248
  case "session.interrupt": {
@@ -195,7 +262,7 @@ export async function startServe(opts, deps) {
195
262
  }
196
263
  case "plugins.list": {
197
264
  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 })) }));
265
+ 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
266
  }
200
267
  case "plugins.set": {
201
268
  if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
@@ -205,10 +272,160 @@ export async function startServe(opts, deps) {
205
272
  setPluginEnabled(p.name, p.enabled);
206
273
  return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
207
274
  }
275
+ case "session.rename": {
276
+ if (typeof p.sessionId !== "string" || typeof p.title !== "string")
277
+ return reply(rpcError(id, ERR.PARAMS, "sessionId + title required"));
278
+ if (!hub.rename(p.sessionId, p.title.slice(0, 120)))
279
+ return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
280
+ return reply(rpcResult(id, { sessionId: p.sessionId, title: p.title.slice(0, 120) }));
281
+ }
282
+ case "session.archive": {
283
+ if (typeof p.sessionId !== "string" || typeof p.archived !== "boolean")
284
+ return reply(rpcError(id, ERR.PARAMS, "sessionId + archived required"));
285
+ if (!hub.setArchived(p.sessionId, p.archived))
286
+ return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
287
+ return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
288
+ }
289
+ case "models.list": {
290
+ const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
291
+ return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
292
+ }
293
+ case "session.set-model": {
294
+ // per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
295
+ // provider; takes effect on the NEXT turn. Refused mid-turn.
296
+ if (typeof p.sessionId !== "string")
297
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
298
+ const s = hub.get(p.sessionId);
299
+ if (!s)
300
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
301
+ if (s.busy)
302
+ return reply(rpcError(id, ERR.BUSY, "a turn is running — switch after it finishes"));
303
+ const model = typeof p.model === "string" && p.model ? p.model : s.meta.model;
304
+ const effort = typeof p.effort === "string" && p.effort ? p.effort : undefined;
305
+ if (!deps.buildProviderFor)
306
+ return reply(rpcError(id, ERR.METHOD, "model switching not supported by this server"));
307
+ const provider = await deps.buildProviderFor(model, effort);
308
+ if (!provider)
309
+ return reply(rpcError(id, ERR.INTERNAL, `could not build provider for ${model}`));
310
+ s.provider = provider;
311
+ s.meta.model = model;
312
+ s.effort = effort;
313
+ return reply(rpcResult(id, { sessionId: s.meta.id, model, effort: effort ?? null }));
314
+ }
315
+ case "automation.list": {
316
+ // The automation timeline's data: cron jobs with their last outcome, plus this machine's
317
+ // automated sessions (source=cron/gateway) so the desktop can render results and "continue
318
+ // as conversation". Read-only.
319
+ 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) }));
320
+ 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 }));
321
+ return reply(rpcResult(id, { jobs, sessions: automated }));
322
+ }
323
+ case "automation.add": {
324
+ if (typeof p.name !== "string" || !p.name || typeof p.schedule !== "string" || typeof p.task !== "string" || !p.task) {
325
+ return reply(rpcError(id, ERR.PARAMS, "name + schedule + task required"));
326
+ }
327
+ const sched = parseSchedule(p.schedule, Date.now());
328
+ if ("error" in sched)
329
+ return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
330
+ const job = addJob({
331
+ name: p.name.slice(0, 60),
332
+ schedule: sched,
333
+ task: p.task,
334
+ mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
335
+ cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
336
+ ...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
337
+ createdAt: Date.now(),
338
+ });
339
+ return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
340
+ }
341
+ case "automation.toggle": {
342
+ if (typeof p.id !== "string" || typeof p.enabled !== "boolean")
343
+ return reply(rpcError(id, ERR.PARAMS, "id + enabled required"));
344
+ const jobs = loadJobs();
345
+ const job = jobs.find((j) => j.id === p.id);
346
+ if (!job)
347
+ return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
348
+ job.enabled = p.enabled;
349
+ saveJobs(jobs);
350
+ return reply(rpcResult(id, { id: job.id, enabled: job.enabled }));
351
+ }
352
+ case "automation.delete": {
353
+ if (typeof p.id !== "string")
354
+ return reply(rpcError(id, ERR.PARAMS, "id required"));
355
+ const jobs = loadJobs();
356
+ if (!jobs.some((j) => j.id === p.id))
357
+ return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
358
+ saveJobs(jobs.filter((j) => j.id !== p.id));
359
+ return reply(rpcResult(id, { id: p.id, deleted: true }));
360
+ }
208
361
  case "skills.list": {
209
362
  const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
210
363
  return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
211
364
  }
365
+ case "files.search": {
366
+ // fuzzy file lookup for the composer's @-mention autocomplete (codex fuzzyFileSearch).
367
+ // Relative POSIX paths; empty query returns the first files as a browse list.
368
+ const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
369
+ const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
370
+ const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
371
+ const all = listProjectFiles(cwd);
372
+ const query = typeof p.query === "string" ? p.query : "";
373
+ const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
374
+ return reply(rpcResult(id, { files, cwd }));
375
+ }
376
+ case "session.context": {
377
+ // context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
378
+ if (typeof p.sessionId !== "string")
379
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
380
+ const s = hub.get(p.sessionId);
381
+ if (!s)
382
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
383
+ const report = analyzeContext(s.history);
384
+ return reply(rpcResult(id, { sessionId: s.meta.id, ...ctxOf(s), total: report.total, rows: report.rows.slice(0, 8) }));
385
+ }
386
+ case "session.compact": {
387
+ // manual compaction (codex thread/compact/start): summarize + replace history, keep working
388
+ // notes, restore this-cwd touched files. Busy-guarded like a turn — it IS a provider call.
389
+ if (typeof p.sessionId !== "string")
390
+ return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
391
+ const s = hub.get(p.sessionId);
392
+ if (!s)
393
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
394
+ if (s.busy)
395
+ return reply(rpcError(id, ERR.BUSY, "a turn is running — compact after it finishes"));
396
+ if (s.history.length < 2)
397
+ return reply(rpcError(id, ERR.PARAMS, "nothing to compact yet"));
398
+ s.busy = true;
399
+ try {
400
+ broadcast("event.notice", { sessionId: s.meta.id, text: "✻ Compacting conversation…" });
401
+ const summary = await compactSession(s);
402
+ if (!summary)
403
+ return reply(rpcError(id, ERR.INTERNAL, "compaction failed — try again or /clear"));
404
+ broadcast("event.notice", { sessionId: s.meta.id, text: `(compacted — history replaced with a summary; ${s.meta.workingSet?.length ?? 0} notes kept)` });
405
+ return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
406
+ }
407
+ finally {
408
+ s.busy = false;
409
+ }
410
+ }
411
+ case "session.rewind": {
412
+ // fork the thread back to before the n-th-most-recent user turn (codex thread/rollback;
413
+ // n=1 drops the last exchange). History-only — file edits are not reverted.
414
+ if (typeof p.sessionId !== "string" || !Number.isInteger(p.n))
415
+ return reply(rpcError(id, ERR.PARAMS, "sessionId + n required"));
416
+ const s = hub.get(p.sessionId);
417
+ if (!s)
418
+ return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
419
+ if (s.busy)
420
+ return reply(rpcError(id, ERR.BUSY, "a turn is running — rewind after it finishes"));
421
+ const next = rewindTo(s.history, p.n);
422
+ if (!next)
423
+ return reply(rpcError(id, ERR.PARAMS, `n out of range (1..${s.history.filter((m) => m.role === "user").length})`));
424
+ s.history.length = 0;
425
+ s.history.push(...next);
426
+ hub.save(s);
427
+ return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
428
+ }
212
429
  default:
213
430
  return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
214
431
  }
@@ -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.117.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"