@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 +23 -0
- package/dist/cron/deliver.js +0 -9
- package/dist/cron/runner.js +3 -1
- package/dist/gateway/flows.js +89 -0
- package/dist/index.js +19 -2
- package/dist/serve/protocol.js +11 -1
- package/dist/serve/server.js +79 -3
- package/dist/serve/sessions.js +31 -0
- package/dist/session/store.js +15 -0
- package/package.json +1 -1
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
|
package/dist/cron/deliver.js
CHANGED
|
@@ -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(":");
|
package/dist/cron/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/serve/protocol.js
CHANGED
|
@@ -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}
|
|
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}
|
package/dist/serve/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/serve/sessions.js
CHANGED
|
@@ -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())
|
package/dist/session/store.js
CHANGED
|
@@ -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 });
|