@nanhara/hara 0.126.1 → 0.127.1
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 +27 -0
- package/dist/agent/loop.js +19 -14
- package/dist/agent/prompt.js +48 -0
- package/dist/index.js +24 -6
- package/dist/org-fleet/enroll.js +38 -1
- package/dist/profile/profile.js +1 -0
- package/dist/providers/anthropic.js +31 -8
- package/dist/serve/protocol.js +2 -0
- package/dist/serve/server.js +74 -7
- package/dist/serve/task-events.js +45 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.127.1 — 2026-07-20 — managed access lifecycle
|
|
9
|
+
|
|
10
|
+
- Managed enrollment now persists the control plane's authoritative device-token expiry. Hara warns
|
|
11
|
+
during the final 24 hours, reports the boundary in `whoami`/`enroll --status`, and fails closed with
|
|
12
|
+
a focused re-enrollment command once access expires or lifecycle data is corrupt.
|
|
13
|
+
- Serve provider settings expose only expiry metadata—not the token—so Hara Desktop can distinguish
|
|
14
|
+
an authenticated managed route from an expired one.
|
|
15
|
+
- Upgrade with `npm i -g @nanhara/hara@0.127.1`.
|
|
16
|
+
|
|
17
|
+
## 0.127.0 — 2026-07-20 — structured context and typed task lifecycle
|
|
18
|
+
|
|
19
|
+
- **Prompt assembly is now an explicit, ordered runtime layer.** Cache-stable policy, tool, project, and
|
|
20
|
+
session context are separated from turn-specific execution state and memory digests, deduplicated, and
|
|
21
|
+
assembled in a deterministic order. Anthropic requests preserve a stable cache prefix while other
|
|
22
|
+
providers continue to receive the same complete system contract.
|
|
23
|
+
- **Conversation and execution state now travel on separate protocol planes.** Authenticated Serve clients
|
|
24
|
+
can negotiate the versioned `event.task_state` stream for running, waiting, paused, completed, and blocked
|
|
25
|
+
work, with stable task/turn identities, phases, accepted task briefs, checkpoints, approvals, outcomes,
|
|
26
|
+
restore snapshots, and explicit stop/finish transitions.
|
|
27
|
+
- **Mid-turn input uses expected-turn steering.** `session.steer` durably accepts an update only for the
|
|
28
|
+
intended live turn, so Desktop and other clients can distinguish a real refinement from the next queued
|
|
29
|
+
task without parsing model prose or losing an end-of-turn race.
|
|
30
|
+
- Ambient lifecycle events never include command or path previews, and resumed client history removes the
|
|
31
|
+
internal steering-triage wrapper before display. Detailed tool output remains confined to the explicit
|
|
32
|
+
conversation surface.
|
|
33
|
+
- Upgrade with `npm i -g @nanhara/hara@0.127.0`.
|
|
34
|
+
|
|
8
35
|
## 0.126.1 — 2026-07-19 — verified plugin packages and ownership-safe lifecycle
|
|
9
36
|
|
|
10
37
|
- **Plugin packages now fail closed at staging.** Hara validates a bounded manifest schema, portable IDs and
|
package/dist/agent/loop.js
CHANGED
|
@@ -24,6 +24,7 @@ import { prepareHistoryForModel } from "./context-budget.js";
|
|
|
24
24
|
import { rolesDigest } from "../org/roles.js";
|
|
25
25
|
import { applyTaskBrief } from "../session/task.js";
|
|
26
26
|
import { askUserTool } from "../tools/ask_user.js";
|
|
27
|
+
import { PromptAssembler } from "./prompt.js";
|
|
27
28
|
/** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
|
|
28
29
|
const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
|
29
30
|
/** Engine-owned, non-authority helpers. Role filters still govern every deferred target activated by
|
|
@@ -61,8 +62,7 @@ export function needsConfirm(kind, mode) {
|
|
|
61
62
|
return kind === "exec";
|
|
62
63
|
return true; // suggest: confirm edits and exec
|
|
63
64
|
}
|
|
64
|
-
const HARA_SYSTEM = (
|
|
65
|
-
Working directory: ${cwd}
|
|
65
|
+
const HARA_SYSTEM = () => `You are hara, a coding agent running in the user's terminal.
|
|
66
66
|
Be concise and direct. Use the provided tools to read files, edit/write files, and run shell
|
|
67
67
|
commands. Prefer small, verifiable steps; edit existing files with edit_file rather than rewriting
|
|
68
68
|
them whole. Batch INDEPENDENT tool calls in a single response — especially reads (read_file / grep /
|
|
@@ -153,8 +153,9 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
|
|
|
153
153
|
"re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
|
|
154
154
|
"and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
|
|
155
155
|
"directory is Home, ask the user to start Hara from a concrete project instead of enumerating Home or its children.";
|
|
156
|
-
function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
|
|
157
|
-
const
|
|
156
|
+
export function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
|
|
157
|
+
const assembler = new PromptAssembler();
|
|
158
|
+
assembler.add("core", "static", "core", override || HARA_SYSTEM());
|
|
158
159
|
const skills = skillsDigest(cwd);
|
|
159
160
|
const roles = override ? "" : rolesDigest(cwd);
|
|
160
161
|
const intakeContext = !intake?.enabled
|
|
@@ -176,15 +177,17 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
|
|
|
176
177
|
"the interpreted goal, intent, constraints, acceptance checks, and short steps. Use intent `answer` " +
|
|
177
178
|
"for a direct answer, `investigate` for evidence gathering/diagnosis, and `change` when the user asked " +
|
|
178
179
|
"you to modify or deliver something. Do not claim completion until the acceptance checks are verified.");
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
(
|
|
182
|
-
(
|
|
183
|
-
|
|
184
|
-
(projectContext ?
|
|
185
|
-
(memory ?
|
|
186
|
-
(roles ?
|
|
187
|
-
(skills ?
|
|
180
|
+
assembler
|
|
181
|
+
.add("working-directory", "session", "runtime", `Working directory: ${cwd}`)
|
|
182
|
+
.add("gateway-channel", "session", "channel", gatewayNote())
|
|
183
|
+
.add("continuation", "session", "task", continuationSession ? CONTINUATION_SYSTEM : "")
|
|
184
|
+
.add("execution", "session", "task", executionContext)
|
|
185
|
+
.add("project", "session", "project", projectContext ? `# Project context (AGENTS.md)\n${projectContext}` : "")
|
|
186
|
+
.add("memory", "session", "memory", memory ? `# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "")
|
|
187
|
+
.add("roles", "session", "role", roles ? `# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "")
|
|
188
|
+
.add("skills", "session", "skill", skills ? `# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : "")
|
|
189
|
+
.add("task-intake", "turn", "task", intakeContext);
|
|
190
|
+
return assembler.build();
|
|
188
191
|
}
|
|
189
192
|
const RUN_STOPPED = Symbol("agent-run-stopped");
|
|
190
193
|
const REPEATED_FAILURE_LIMIT = 3;
|
|
@@ -618,7 +621,8 @@ async function runAgentInner(history, opts, life) {
|
|
|
618
621
|
? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
|
|
619
622
|
: baseSpecs;
|
|
620
623
|
const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
|
|
621
|
-
const
|
|
624
|
+
const assembledSystem = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
|
|
625
|
+
const system = assembledSystem.text;
|
|
622
626
|
const prepared = prepareHistoryForModel(history, {
|
|
623
627
|
model: activeProvider.model,
|
|
624
628
|
system,
|
|
@@ -714,6 +718,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
714
718
|
}
|
|
715
719
|
return activeProvider.turn({
|
|
716
720
|
system,
|
|
721
|
+
systemParts: assembledSystem.parts,
|
|
717
722
|
history: prepared.history,
|
|
718
723
|
tools: specs,
|
|
719
724
|
// Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const STABILITY_ORDER = {
|
|
3
|
+
static: 0,
|
|
4
|
+
session: 1,
|
|
5
|
+
turn: 2,
|
|
6
|
+
};
|
|
7
|
+
function promptDigest(content) {
|
|
8
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
9
|
+
}
|
|
10
|
+
/** Assemble the model's runtime context as a governed object instead of an ever-growing string.
|
|
11
|
+
*
|
|
12
|
+
* Ordering is intentionally monotonic. If a caller tries to place stable material after a dynamic task
|
|
13
|
+
* suffix, fail during assembly instead of silently destroying provider prefix-cache locality. */
|
|
14
|
+
export class PromptAssembler {
|
|
15
|
+
parts = [];
|
|
16
|
+
ids = new Set();
|
|
17
|
+
lastStability = "static";
|
|
18
|
+
add(id, stability, source, content) {
|
|
19
|
+
const normalizedId = id.trim();
|
|
20
|
+
const normalizedContent = content?.trim();
|
|
21
|
+
if (!normalizedContent)
|
|
22
|
+
return this;
|
|
23
|
+
if (!normalizedId)
|
|
24
|
+
throw new Error("system prompt part id must not be empty");
|
|
25
|
+
if (this.ids.has(normalizedId))
|
|
26
|
+
throw new Error(`duplicate system prompt part id: ${normalizedId}`);
|
|
27
|
+
if (this.parts.length && STABILITY_ORDER[stability] < STABILITY_ORDER[this.lastStability]) {
|
|
28
|
+
throw new Error(`system prompt part '${normalizedId}' (${stability}) cannot follow ${this.lastStability} context`);
|
|
29
|
+
}
|
|
30
|
+
this.ids.add(normalizedId);
|
|
31
|
+
this.lastStability = stability;
|
|
32
|
+
this.parts.push({
|
|
33
|
+
id: normalizedId,
|
|
34
|
+
stability,
|
|
35
|
+
source,
|
|
36
|
+
content: normalizedContent,
|
|
37
|
+
digest: promptDigest(normalizedContent),
|
|
38
|
+
});
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
build() {
|
|
42
|
+
const parts = this.parts.map((part) => ({ ...part }));
|
|
43
|
+
return {
|
|
44
|
+
text: parts.map((part) => part.content).join("\n\n"),
|
|
45
|
+
parts,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { notifyDone } from "./notify.js";
|
|
|
22
22
|
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
23
23
|
import { completionScript } from "./completions.js";
|
|
24
24
|
import { renderSessionMarkdown } from "./export.js";
|
|
25
|
-
import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fleet/enroll.js";
|
|
25
|
+
import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles, deviceTokenExpired, deviceTokenExpiryWarning, } from "./org-fleet/enroll.js";
|
|
26
26
|
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";
|
|
27
27
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
28
28
|
import { routingProvider } from "./agent/route.js";
|
|
@@ -134,7 +134,7 @@ async function buildProvider(cfg, targetOverride) {
|
|
|
134
134
|
// requests" — gateway (deviceToken at the gateway) vs BYOK (user's key direct to the provider).
|
|
135
135
|
const { profile: ap } = profileForConfig(cfg);
|
|
136
136
|
if (ap.kind === "gateway") {
|
|
137
|
-
if (!ap.gatewayUrl || !ap.deviceToken)
|
|
137
|
+
if (!ap.gatewayUrl || !ap.deviceToken || deviceTokenExpired(ap.tokenExpiresAt))
|
|
138
138
|
return null;
|
|
139
139
|
const baseURL = ap.baseURL || `${ap.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
140
140
|
const model = resolveGatewayModel(cfg, ap, process.env, targetOverride?.model);
|
|
@@ -193,8 +193,12 @@ async function buildGuardian(cfg, primary) {
|
|
|
193
193
|
}
|
|
194
194
|
function authHint(cfg) {
|
|
195
195
|
const { profile: ap } = profileForConfig(cfg);
|
|
196
|
-
if (ap.kind === "gateway")
|
|
196
|
+
if (ap.kind === "gateway") {
|
|
197
|
+
if (deviceTokenExpired(ap.tokenExpiresAt)) {
|
|
198
|
+
return `Active profile '${ap.id}' has expired organization access — re-enroll with \`hara profile add ${ap.id} --gateway ${ap.gatewayUrl || "<url>"} --code <code>\`.`;
|
|
199
|
+
}
|
|
197
200
|
return `Active profile '${ap.id}' is a gateway profile but is missing deviceToken — re-enroll with \`hara profile add ${ap.id} --gateway <url> --code <code>\`.`;
|
|
201
|
+
}
|
|
198
202
|
const target = resolveByokProviderTarget(cfg, ap, false);
|
|
199
203
|
const provider = target.provider;
|
|
200
204
|
if (provider === "qwen-oauth")
|
|
@@ -215,6 +219,7 @@ function providerSettingsSnapshot(targetCwd) {
|
|
|
215
219
|
const catalog = providerCatalog();
|
|
216
220
|
if (profile.kind === "gateway") {
|
|
217
221
|
const entry = catalog.find((candidate) => candidate.id === "hara-gateway");
|
|
222
|
+
const tokenExpired = deviceTokenExpired(profile.tokenExpiresAt);
|
|
218
223
|
return {
|
|
219
224
|
current: {
|
|
220
225
|
provider: "hara-gateway",
|
|
@@ -223,11 +228,12 @@ function providerSettingsSnapshot(targetCwd) {
|
|
|
223
228
|
location: entry.location,
|
|
224
229
|
auth: entry.auth,
|
|
225
230
|
keyConfigured: !!profile.deviceToken,
|
|
226
|
-
authenticated: !!profile.gatewayUrl && !!profile.deviceToken,
|
|
231
|
+
authenticated: !!profile.gatewayUrl && !!profile.deviceToken && !tokenExpired,
|
|
227
232
|
profileId: profile.id,
|
|
228
233
|
profileKind: profile.kind,
|
|
229
234
|
profileSource: resolution.source,
|
|
230
235
|
editable: false,
|
|
236
|
+
...(profile.tokenExpiresAt ? { tokenExpiresAt: profile.tokenExpiresAt, tokenExpired } : {}),
|
|
231
237
|
},
|
|
232
238
|
providers: catalog,
|
|
233
239
|
};
|
|
@@ -1664,6 +1670,11 @@ function printWhoami() {
|
|
|
1664
1670
|
out(c.dim(` device: ${p.deviceId.length > 8 ? "…" + p.deviceId.slice(-8) : p.deviceId}\n`));
|
|
1665
1671
|
if (p.availableModels?.length)
|
|
1666
1672
|
out(c.dim(` available: ${p.availableModels.join(", ")}\n`));
|
|
1673
|
+
if (p.tokenExpiresAt)
|
|
1674
|
+
out(c.dim(` expires: ${p.tokenExpiresAt}\n`));
|
|
1675
|
+
const warning = deviceTokenExpiryWarning(p.tokenExpiresAt);
|
|
1676
|
+
if (warning)
|
|
1677
|
+
out(c.yellow(` ⚠ ${warning}\n`));
|
|
1667
1678
|
}
|
|
1668
1679
|
else {
|
|
1669
1680
|
out(c.dim(` provider: ${p.provider}\n`));
|
|
@@ -1795,6 +1806,7 @@ profileCmd
|
|
|
1795
1806
|
defaultModel: e.model || "",
|
|
1796
1807
|
availableModels: e.model ? [e.model] : [],
|
|
1797
1808
|
enrolledAt: e.enrolledAt,
|
|
1809
|
+
tokenExpiresAt: e.expiresAt,
|
|
1798
1810
|
};
|
|
1799
1811
|
upsertProfile(p); // upsert: re-enrolling the same id rotates the token
|
|
1800
1812
|
const r = useProfile(id);
|
|
@@ -1982,8 +1994,8 @@ program
|
|
|
1982
1994
|
.option("--clear", "switch active profile back to personal (does NOT delete the gateway profile)")
|
|
1983
1995
|
.action(async (gatewayUrl, opts) => {
|
|
1984
1996
|
if (opts.status) {
|
|
1985
|
-
|
|
1986
|
-
return
|
|
1997
|
+
printWhoami();
|
|
1998
|
+
return;
|
|
1987
1999
|
}
|
|
1988
2000
|
if (opts.clear) {
|
|
1989
2001
|
// Behavior change: don't *delete* the gateway profile (keeps the token around for re-use);
|
|
@@ -2010,6 +2022,7 @@ program
|
|
|
2010
2022
|
defaultModel: e.model || "",
|
|
2011
2023
|
availableModels: e.model ? [e.model] : [],
|
|
2012
2024
|
enrolledAt: e.enrolledAt,
|
|
2025
|
+
tokenExpiresAt: e.expiresAt,
|
|
2013
2026
|
};
|
|
2014
2027
|
upsertProfile(p);
|
|
2015
2028
|
useProfile(DEFAULT_ORG_ID);
|
|
@@ -3093,6 +3106,11 @@ program.action(async (opts) => {
|
|
|
3093
3106
|
// to suppress everywhere.
|
|
3094
3107
|
if (!opts.print && process.env.HARA_QUIET !== "1") {
|
|
3095
3108
|
out(c.dim(activeProfileLine(__activeP)) + "\n");
|
|
3109
|
+
const expiryWarning = __activeP.kind === "gateway"
|
|
3110
|
+
? deviceTokenExpiryWarning(__activeP.tokenExpiresAt)
|
|
3111
|
+
: null;
|
|
3112
|
+
if (expiryWarning)
|
|
3113
|
+
out(c.yellow(`⚠ ${expiryWarning}\n`));
|
|
3096
3114
|
}
|
|
3097
3115
|
if (__activeP.kind === "gateway" || cfg.provider === "hara-gateway") {
|
|
3098
3116
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
package/dist/org-fleet/enroll.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// plane fleet visibility. Token + endpoint live in ~/.hara/org.json (0600).
|
|
6
6
|
//
|
|
7
7
|
// Protocol (what `hara-control` implements on the other end):
|
|
8
|
-
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
|
|
8
|
+
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?, expires_at?}
|
|
9
9
|
// POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
|
|
10
10
|
// GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
|
|
11
11
|
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
@@ -45,6 +45,7 @@ export function loadEnrollment() {
|
|
|
45
45
|
model: ap.defaultModel || "",
|
|
46
46
|
baseURL: ap.baseURL,
|
|
47
47
|
enrolledAt: ap.enrolledAt || new Date().toISOString(),
|
|
48
|
+
expiresAt: ap.tokenExpiresAt,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -70,6 +71,14 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
|
70
71
|
const deviceToken = (j.device_token ?? j.deviceToken);
|
|
71
72
|
if (!deviceToken)
|
|
72
73
|
throw new Error("enroll response missing device_token");
|
|
74
|
+
const rawExpiresAt = j.expires_at ?? j.expiresAt;
|
|
75
|
+
let expiresAt;
|
|
76
|
+
if (rawExpiresAt !== undefined && rawExpiresAt !== null) {
|
|
77
|
+
if (typeof rawExpiresAt !== "string" || !Number.isFinite(Date.parse(rawExpiresAt))) {
|
|
78
|
+
throw new Error("enroll response contains an invalid expires_at");
|
|
79
|
+
}
|
|
80
|
+
expiresAt = new Date(rawExpiresAt).toISOString();
|
|
81
|
+
}
|
|
73
82
|
return {
|
|
74
83
|
gatewayUrl: gatewayUrl.replace(/\/$/, ""),
|
|
75
84
|
deviceToken,
|
|
@@ -77,8 +86,36 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
|
77
86
|
model: String(j.model ?? ""),
|
|
78
87
|
baseURL: (j.base_url ?? j.baseURL),
|
|
79
88
|
enrolledAt: now,
|
|
89
|
+
expiresAt,
|
|
80
90
|
};
|
|
81
91
|
}
|
|
92
|
+
/** Legacy control planes did not advertise token expiry, so absence remains compatible. New control
|
|
93
|
+
* planes provide it and the CLI can fail early with an actionable re-enrollment message. */
|
|
94
|
+
export function deviceTokenExpired(expiresAt, now = new Date()) {
|
|
95
|
+
if (!expiresAt)
|
|
96
|
+
return false;
|
|
97
|
+
const expiryMs = Date.parse(expiresAt);
|
|
98
|
+
// A present-but-corrupt lifecycle boundary must not silently become a legacy non-expiring token.
|
|
99
|
+
return !Number.isFinite(expiryMs) || expiryMs <= now.getTime();
|
|
100
|
+
}
|
|
101
|
+
/** Only warn near the boundary; healthy week-long tokens should not add startup noise. */
|
|
102
|
+
export function deviceTokenExpiryWarning(expiresAt, now = new Date()) {
|
|
103
|
+
if (!expiresAt)
|
|
104
|
+
return null;
|
|
105
|
+
const expiryMs = Date.parse(expiresAt);
|
|
106
|
+
if (!Number.isFinite(expiryMs))
|
|
107
|
+
return "organization access expiry is unreadable; re-enroll this profile";
|
|
108
|
+
const remainingMs = expiryMs - now.getTime();
|
|
109
|
+
if (remainingMs <= 0)
|
|
110
|
+
return "organization access expired; re-enroll this profile before running a task";
|
|
111
|
+
if (remainingMs > 24 * 60 * 60_000)
|
|
112
|
+
return null;
|
|
113
|
+
const remainingMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
|
|
114
|
+
const remaining = remainingMinutes < 60
|
|
115
|
+
? `${remainingMinutes}m`
|
|
116
|
+
: `${Math.ceil(remainingMinutes / 60)}h`;
|
|
117
|
+
return `organization access expires in ${remaining}; ask your admin for a new enrollment code`;
|
|
118
|
+
}
|
|
82
119
|
/** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
|
|
83
120
|
export async function enrollDevice(gatewayUrl, code, signal) {
|
|
84
121
|
const base = gatewayUrl.replace(/\/$/, "");
|
package/dist/profile/profile.js
CHANGED
|
@@ -98,6 +98,7 @@ function readDefaultOrgFromOrgJson(e) {
|
|
|
98
98
|
defaultModel,
|
|
99
99
|
availableModels: defaultModel ? [defaultModel] : [],
|
|
100
100
|
enrolledAt: e.enrolledAt || new Date().toISOString(),
|
|
101
|
+
tokenExpiresAt: typeof e.expiresAt === "string" ? e.expiresAt : undefined,
|
|
101
102
|
};
|
|
102
103
|
}
|
|
103
104
|
/** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
|
|
@@ -56,13 +56,14 @@ const CACHE = { type: "ephemeral" };
|
|
|
56
56
|
* conversation prefix FROM CACHE instead of re-billing + re-processing the whole prompt — the single
|
|
57
57
|
* biggest latency + cost win as history grows (uncached, a long session pays full input every turn).
|
|
58
58
|
*
|
|
59
|
-
* Anthropic caps breakpoints at 4 and orders the cache prefix `tools → system → messages
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* Anthropic caps breakpoints at 4 and orders the cache prefix `tools → system → messages`. Structured Hara
|
|
60
|
+
* prompts spend up to two marks on the static and session boundaries, leaving the changing turn suffix
|
|
61
|
+
* uncached; legacy strings keep one system mark. We then spend up to two rolling marks near the message
|
|
62
|
+
* tail: the last message (so THIS turn's full prefix is written) and one ~2 back (so the NEXT turn — which
|
|
63
|
+
* appends new messages — still finds a long cached prefix to read).
|
|
63
64
|
* Mutates `messages` in place (toAnthropic hands us a fresh array each turn); returns the request-shaped
|
|
64
65
|
* system. Exported pure for tests. */
|
|
65
|
-
export function applyCacheControl(system, messages) {
|
|
66
|
+
export function applyCacheControl(system, messages, systemParts) {
|
|
66
67
|
const mark = (m) => {
|
|
67
68
|
if (typeof m.content === "string") {
|
|
68
69
|
if (m.content.length)
|
|
@@ -80,7 +81,29 @@ export function applyCacheControl(system, messages) {
|
|
|
80
81
|
for (const i of idxs)
|
|
81
82
|
mark(messages[i]);
|
|
82
83
|
// Only cache system when it's substantial enough to matter (an empty text block would 400).
|
|
83
|
-
|
|
84
|
+
let cachedSystem = system;
|
|
85
|
+
const renderedParts = systemParts?.map((part) => part.content).join("\n\n");
|
|
86
|
+
if (system && systemParts?.length && renderedParts === system) {
|
|
87
|
+
const sections = [];
|
|
88
|
+
for (const part of systemParts) {
|
|
89
|
+
const tail = sections.at(-1);
|
|
90
|
+
if (tail?.stability === part.stability)
|
|
91
|
+
tail.text += `\n\n${part.content}`;
|
|
92
|
+
else
|
|
93
|
+
sections.push({ stability: part.stability, text: part.content });
|
|
94
|
+
}
|
|
95
|
+
cachedSystem = sections.map((section, index) => ({
|
|
96
|
+
type: "text",
|
|
97
|
+
// Anthropic concatenates adjacent text blocks directly. Preserve the authoritative rendered string
|
|
98
|
+
// byte-for-byte across our cache boundaries.
|
|
99
|
+
text: section.text + (index < sections.length - 1 ? "\n\n" : ""),
|
|
100
|
+
...(section.stability === "turn" ? {} : { cache_control: CACHE }),
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
else if (system) {
|
|
104
|
+
// Legacy/custom callers still receive the original single cached system block.
|
|
105
|
+
cachedSystem = [{ type: "text", text: system, cache_control: CACHE }];
|
|
106
|
+
}
|
|
84
107
|
return { system: cachedSystem, messages };
|
|
85
108
|
}
|
|
86
109
|
/** Anthropic models whose only valid `thinking` setting is `{type: "adaptive"}` — they reject any
|
|
@@ -116,9 +139,9 @@ export function createAnthropicProvider(opts) {
|
|
|
116
139
|
return {
|
|
117
140
|
id: "anthropic",
|
|
118
141
|
model: opts.model,
|
|
119
|
-
async turn({ system, history, tools, onText, onReasoning, signal }) {
|
|
142
|
+
async turn({ system, systemParts, history, tools, onText, onReasoning, signal }) {
|
|
120
143
|
const thinking = buildThinkingParam(opts.model, opts.reasoningEffort);
|
|
121
|
-
const { system: cachedSystem, messages } = applyCacheControl(system, toAnthropic(history));
|
|
144
|
+
const { system: cachedSystem, messages } = applyCacheControl(system, toAnthropic(history), systemParts);
|
|
122
145
|
const stream = client.messages.stream({
|
|
123
146
|
model: opts.model,
|
|
124
147
|
max_tokens: 32000,
|
package/dist/serve/protocol.js
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
// Server → client notifications (all carry sessionId):
|
|
34
34
|
// event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
|
|
35
35
|
// event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
|
|
36
|
+
// event.task_state {version,taskId,turnId,objective,state,taskStatus,phase,checkpoint,…}
|
|
37
|
+
// authoritative execution plane; clients feature-detect it via capabilities.events
|
|
36
38
|
export const PROTOCOL_VERSION = 1;
|
|
37
39
|
/** JSON-RPC error codes: standard ones plus hara-specific (-320xx). */
|
|
38
40
|
export const ERR = {
|
package/dist/serve/server.js
CHANGED
|
@@ -27,10 +27,11 @@ import { loadJobs, addJob, removeJob, setEnabled } from "../cron/store.js";
|
|
|
27
27
|
import { parseSchedule, describeSchedule } from "../cron/schedule.js";
|
|
28
28
|
import { loadTasks } from "../tools/task.js";
|
|
29
29
|
import { listPending, resolvePending } from "../gateway/flows-pending.js";
|
|
30
|
-
import { disposeTodoScope, restoreTodos, serializeTodos } from "../tools/todo.js";
|
|
30
|
+
import { disposeTodoScope, onTodosChange, restoreTodos, serializeTodos } from "../tools/todo.js";
|
|
31
31
|
import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
|
|
32
32
|
import { SessionHub, realStore } from "./sessions.js";
|
|
33
33
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
34
|
+
import { taskLifecycleEvent } from "./task-events.js";
|
|
34
35
|
import { readModelContextFileSync } from "../fs-read.js";
|
|
35
36
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
36
37
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
@@ -228,8 +229,15 @@ export function lastAssistantText(history) {
|
|
|
228
229
|
export function historyForClient(history) {
|
|
229
230
|
const out = [];
|
|
230
231
|
for (const m of history) {
|
|
231
|
-
if (m.role === "user")
|
|
232
|
-
|
|
232
|
+
if (m.role === "user") {
|
|
233
|
+
const steeringPrefix = `${INTERJECT_PREFIX}\n\n`;
|
|
234
|
+
out.push({
|
|
235
|
+
role: "user",
|
|
236
|
+
text: m.content.startsWith(steeringPrefix)
|
|
237
|
+
? m.content.slice(steeringPrefix.length)
|
|
238
|
+
: m.content,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
233
241
|
else if (m.role === "assistant" && m.text)
|
|
234
242
|
out.push({ role: "assistant", text: m.text });
|
|
235
243
|
// tool results are omitted — clients see live tool events; persisted detail stays in the store
|
|
@@ -333,6 +341,11 @@ export async function startServe(opts, deps) {
|
|
|
333
341
|
if (ws.readyState === ws.OPEN)
|
|
334
342
|
ws.send(frame);
|
|
335
343
|
};
|
|
344
|
+
const broadcastTaskState = (session, activity) => {
|
|
345
|
+
if (!session.task)
|
|
346
|
+
return;
|
|
347
|
+
broadcast("event.task_state", { ...taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity) });
|
|
348
|
+
};
|
|
336
349
|
// Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
|
|
337
350
|
const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
|
|
338
351
|
const discoveryPath = join(discoveryDir, "serve.json");
|
|
@@ -413,13 +426,37 @@ export async function startServe(opts, deps) {
|
|
|
413
426
|
s.busy = false;
|
|
414
427
|
throw error;
|
|
415
428
|
}
|
|
429
|
+
let lastTaskStateSignature = "";
|
|
430
|
+
const emitTaskState = (activity, todos = s.meta.todos ?? []) => {
|
|
431
|
+
if (!s.task)
|
|
432
|
+
return;
|
|
433
|
+
const event = taskLifecycleEvent(sessionId, s.task, todos, activity);
|
|
434
|
+
const { at: _at, ...stableEvent } = event;
|
|
435
|
+
const signature = JSON.stringify(stableEvent);
|
|
436
|
+
if (signature === lastTaskStateSignature)
|
|
437
|
+
return;
|
|
438
|
+
lastTaskStateSignature = signature;
|
|
439
|
+
broadcast("event.task_state", { ...event });
|
|
440
|
+
};
|
|
416
441
|
broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
|
|
442
|
+
emitTaskState({ state: "running", phase: "starting" });
|
|
417
443
|
const historyStart = s.history.length;
|
|
418
444
|
const before = { input: s.stats.input, output: s.stats.output };
|
|
419
445
|
const sink = {
|
|
420
|
-
text: (d) =>
|
|
421
|
-
|
|
422
|
-
|
|
446
|
+
text: (d) => {
|
|
447
|
+
emitTaskState({ state: "running", phase: "responding" });
|
|
448
|
+
broadcast("event.text", { sessionId, delta: d });
|
|
449
|
+
},
|
|
450
|
+
reasoning: (d) => {
|
|
451
|
+
emitTaskState({ state: "running", phase: "thinking" });
|
|
452
|
+
broadcast("event.reasoning", { sessionId, delta: d });
|
|
453
|
+
},
|
|
454
|
+
tool: (name, preview) => {
|
|
455
|
+
// The task/status plane is safe for ambient surfaces such as an always-on-top companion.
|
|
456
|
+
// Command/path previews remain on the explicit event.tool transcript plane only.
|
|
457
|
+
emitTaskState({ state: "running", phase: "tool", detail: name });
|
|
458
|
+
broadcast("event.tool", { sessionId, name, preview });
|
|
459
|
+
},
|
|
423
460
|
diff: (t) => broadcast("event.diff", { sessionId, text: t }),
|
|
424
461
|
notice: (t) => broadcast("event.notice", { sessionId, text: t }),
|
|
425
462
|
};
|
|
@@ -434,6 +471,13 @@ export async function startServe(opts, deps) {
|
|
|
434
471
|
clearTimeout(timer);
|
|
435
472
|
pendingApprovals.delete(approvalId);
|
|
436
473
|
signal.removeEventListener("abort", onAbort);
|
|
474
|
+
if (!signal.aborted && s.task?.status === "running") {
|
|
475
|
+
emitTaskState({
|
|
476
|
+
state: "running",
|
|
477
|
+
phase: "thinking",
|
|
478
|
+
detail: v === false ? "Approval denied; continuing safely" : "Approval granted; continuing",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
437
481
|
resolve(v);
|
|
438
482
|
};
|
|
439
483
|
const onAbort = () => finish(false);
|
|
@@ -445,15 +489,28 @@ export async function startServe(opts, deps) {
|
|
|
445
489
|
// `signal` composes the owning turn cancellation with runAgent's lifecycle cancellation. Listening
|
|
446
490
|
// only to turnAbort would leave the approval map and Desktop prompt stale after an internal stop.
|
|
447
491
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
492
|
+
emitTaskState({
|
|
493
|
+
state: "waiting",
|
|
494
|
+
phase: "approval",
|
|
495
|
+
detail: q,
|
|
496
|
+
approval: { id: approvalId, question: q },
|
|
497
|
+
});
|
|
448
498
|
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
449
499
|
}
|
|
450
500
|
});
|
|
501
|
+
let stopTodoEvents = () => { };
|
|
451
502
|
try {
|
|
452
503
|
if (!(await refreshSessionProvider(s))) {
|
|
453
504
|
throw new Error(`provider not authenticated for pinned model '${s.meta.model}' at ${s.meta.cwd}`);
|
|
454
505
|
}
|
|
455
506
|
const turnGuardian = deps.buildGuardian ? await deps.buildGuardian(s.meta.cwd) : deps.guardian;
|
|
456
507
|
restoreTodos(s.meta.todos, sessionId);
|
|
508
|
+
stopTodoEvents = onTodosChange((todos) => {
|
|
509
|
+
// Keep the session snapshot current while the turn runs. Steering and task-intake checkpoints can
|
|
510
|
+
// then publish/persist the same checklist the model just wrote instead of regressing to turn-start.
|
|
511
|
+
s.meta.todos = serializeTodos(sessionId);
|
|
512
|
+
emitTaskState({ state: "running", phase: "checkpoint" }, s.meta.todos);
|
|
513
|
+
}, sessionId);
|
|
457
514
|
// Slash skills, CLI parity: "/skill-id request…" expands into the skill-entering message, so a
|
|
458
515
|
// desktop composer's "/" popup triggers the exact behavior the terminal gets. Unknown ids fall
|
|
459
516
|
// through as plain text (the model sees what the user typed).
|
|
@@ -495,10 +552,12 @@ export async function startServe(opts, deps) {
|
|
|
495
552
|
current: () => s.task,
|
|
496
553
|
onUpdate: (next) => {
|
|
497
554
|
s.task = next;
|
|
555
|
+
emitTaskState({ state: "running", phase: "checkpoint" }, serializeTodos(sessionId));
|
|
498
556
|
},
|
|
499
557
|
onCheckpoint: (next) => {
|
|
500
558
|
s.task = next;
|
|
501
559
|
hub.save(s);
|
|
560
|
+
emitTaskState({ state: "running", phase: "checkpoint" }, serializeTodos(sessionId));
|
|
502
561
|
},
|
|
503
562
|
},
|
|
504
563
|
pendingInput: async () => {
|
|
@@ -521,6 +580,7 @@ export async function startServe(opts, deps) {
|
|
|
521
580
|
s.meta.todos = serializeTodos(sessionId);
|
|
522
581
|
s.task = finishTaskExecution(s.task, outcome, s.meta.todos, turnAbort.signal.aborted);
|
|
523
582
|
hub.save(s);
|
|
583
|
+
emitTaskState({ phase: "finished" }, s.meta.todos);
|
|
524
584
|
const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
|
|
525
585
|
// context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
|
|
526
586
|
// clients render a meter without an extra round-trip.
|
|
@@ -544,10 +604,12 @@ export async function startServe(opts, deps) {
|
|
|
544
604
|
if (s.task?.status === "running") {
|
|
545
605
|
s.task = finishTaskExecution(s.task, { status: "error", error: error instanceof Error ? error.message : String(error) }, s.meta.todos ?? [], turnAbort.signal.aborted);
|
|
546
606
|
hub.save(s);
|
|
607
|
+
emitTaskState({ phase: "finished" }, s.meta.todos ?? []);
|
|
547
608
|
}
|
|
548
609
|
throw error;
|
|
549
610
|
}
|
|
550
611
|
finally {
|
|
612
|
+
stopTodoEvents();
|
|
551
613
|
s.abort = null;
|
|
552
614
|
s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
|
|
553
615
|
}
|
|
@@ -682,7 +744,7 @@ export async function startServe(opts, deps) {
|
|
|
682
744
|
provider: runtime.providerId,
|
|
683
745
|
model: runtime.model,
|
|
684
746
|
setupState,
|
|
685
|
-
capabilities: { methods },
|
|
747
|
+
capabilities: { methods, events: ["event.task_state"] },
|
|
686
748
|
}));
|
|
687
749
|
}
|
|
688
750
|
if (!authed.has(ws))
|
|
@@ -749,6 +811,7 @@ export async function startServe(opts, deps) {
|
|
|
749
811
|
return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
|
|
750
812
|
}
|
|
751
813
|
r.session.projectContext = loadAgentContext(r.session.meta.cwd) || undefined;
|
|
814
|
+
broadcastTaskState(r.session, { phase: "restored" });
|
|
752
815
|
return reply(rpcResult(id, {
|
|
753
816
|
sessionId: r.session.meta.id,
|
|
754
817
|
model: r.session.meta.model,
|
|
@@ -791,12 +854,16 @@ export async function startServe(opts, deps) {
|
|
|
791
854
|
return reply(rpcError(id, ERR.BUSY, recorded.reason));
|
|
792
855
|
s.task = recorded.task;
|
|
793
856
|
hub.save(s); // executable inbox entry is durable before ACK
|
|
857
|
+
broadcastTaskState(s, { state: "running", phase: "steering", detail: "Steering accepted" });
|
|
794
858
|
return reply(rpcResult(id, { accepted: true, taskId: s.task.id, turnId: s.task.turnId }));
|
|
795
859
|
}
|
|
796
860
|
case "session.interrupt": {
|
|
797
861
|
const s = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
798
862
|
if (!s)
|
|
799
863
|
return reply(rpcError(id, ERR.NO_SESSION, "no such live session"));
|
|
864
|
+
if (s.abort && s.task?.status === "running") {
|
|
865
|
+
broadcastTaskState(s, { state: "running", phase: "stopping", detail: "Stopping at a safe boundary" });
|
|
866
|
+
}
|
|
800
867
|
s.abort?.abort();
|
|
801
868
|
return reply(rpcResult(id, {}));
|
|
802
869
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const TASK_LIFECYCLE_EVENT_VERSION = 1;
|
|
2
|
+
function bounded(value, max) {
|
|
3
|
+
const normalized = value?.replace(/\s+/g, " ").trim();
|
|
4
|
+
return normalized ? normalized.slice(0, max) : undefined;
|
|
5
|
+
}
|
|
6
|
+
/** Build the single task-state shape consumed by Desktop, IDE clients, and the companion. Conversation
|
|
7
|
+
* streaming remains separate; this event is the authoritative execution/status plane. */
|
|
8
|
+
export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = new Date().toISOString()) {
|
|
9
|
+
const current = todos.find((todo) => todo.status === "in_progress")
|
|
10
|
+
?? todos.find((todo) => todo.status === "pending");
|
|
11
|
+
const detail = bounded(activity.detail, 500);
|
|
12
|
+
const approval = activity.approval
|
|
13
|
+
? {
|
|
14
|
+
id: activity.approval.id,
|
|
15
|
+
question: bounded(activity.approval.question, 4_000) ?? "Approval required",
|
|
16
|
+
}
|
|
17
|
+
: undefined;
|
|
18
|
+
// Runtime phases may only refine an actively running task. Once durable state reaches a terminal or
|
|
19
|
+
// resumable boundary, a late notification cannot visually resurrect it as running/waiting.
|
|
20
|
+
const state = task.status === "running"
|
|
21
|
+
? (activity.state ?? task.status)
|
|
22
|
+
: task.status;
|
|
23
|
+
return {
|
|
24
|
+
version: TASK_LIFECYCLE_EVENT_VERSION,
|
|
25
|
+
sessionId,
|
|
26
|
+
taskId: task.id,
|
|
27
|
+
turnId: task.turnId,
|
|
28
|
+
objective: task.objective,
|
|
29
|
+
state,
|
|
30
|
+
taskStatus: task.status,
|
|
31
|
+
phase: activity.phase,
|
|
32
|
+
at,
|
|
33
|
+
updatedAt: task.updatedAt,
|
|
34
|
+
...(task.lastOutcome ? { lastOutcome: task.lastOutcome } : {}),
|
|
35
|
+
...(task.brief ? { brief: { intent: task.brief.intent, goal: task.brief.goal } } : {}),
|
|
36
|
+
checkpoint: {
|
|
37
|
+
done: todos.filter((todo) => todo.status === "done").length,
|
|
38
|
+
total: todos.length,
|
|
39
|
+
...(current ? { current: bounded(current.activeForm || current.text, 300) } : {}),
|
|
40
|
+
...(current?.owner ? { owner: bounded(current.owner, 120) } : {}),
|
|
41
|
+
},
|
|
42
|
+
...(detail ? { detail } : {}),
|
|
43
|
+
...(approval ? { approval } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|