@seqyuan/ennote 0.3.0 → 0.5.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.
Files changed (51) hide show
  1. package/README.md +106 -13
  2. package/bin/ennote.js +417 -2
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +4 -4
  5. package/out/__next._full.txt +16 -16
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +8 -8
  8. package/out/__next._tree.txt +4 -4
  9. package/out/_next/static/chunks/01j7xr5p-4q8-.js +8 -0
  10. package/out/_next/static/chunks/0_yt3a8a9j1yd.js +1 -0
  11. package/out/_next/static/chunks/{10l572mh6n3-9.css → 0bxrnkjb.apni.css} +3 -2
  12. package/out/_next/static/chunks/{0j5eflqzx5_i2.css → 0k-~g5-_lbznv.css} +1 -1
  13. package/out/_next/static/chunks/0l9r.o-l3s.5h.js +1 -0
  14. package/out/_next/static/chunks/0r3uxu723bo0o.css +4 -0
  15. package/out/_not-found/__next._full.txt +14 -14
  16. package/out/_not-found/__next._head.txt +4 -4
  17. package/out/_not-found/__next._index.txt +8 -8
  18. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  19. package/out/_not-found/__next._not-found.txt +3 -3
  20. package/out/_not-found/__next._tree.txt +3 -3
  21. package/out/_not-found.html +1 -1
  22. package/out/_not-found.txt +14 -14
  23. package/out/graphs/__next._full.txt +16 -16
  24. package/out/graphs/__next._head.txt +4 -4
  25. package/out/graphs/__next._index.txt +8 -8
  26. package/out/graphs/__next._tree.txt +4 -4
  27. package/out/graphs/__next.graphs.__PAGE__.txt +4 -4
  28. package/out/graphs/__next.graphs.txt +3 -3
  29. package/out/graphs.html +1 -1
  30. package/out/graphs.txt +16 -16
  31. package/out/index.html +2 -2
  32. package/out/index.txt +16 -16
  33. package/out/roles/__next._full.txt +16 -16
  34. package/out/roles/__next._head.txt +4 -4
  35. package/out/roles/__next._index.txt +8 -8
  36. package/out/roles/__next._tree.txt +4 -4
  37. package/out/roles/__next.roles.__PAGE__.txt +4 -4
  38. package/out/roles/__next.roles.txt +3 -3
  39. package/out/roles.html +1 -1
  40. package/out/roles.txt +16 -16
  41. package/package.json +14 -3
  42. package/skills/bio-workspace/SKILL.md +11 -0
  43. package/skills/bio-workspace/skill.json +7 -0
  44. package/skills/skill-creator/SKILL.md +87 -0
  45. package/skills/skill-creator/skill.json +7 -0
  46. package/out/_next/static/chunks/0ix0n-428htqq.js +0 -1
  47. package/out/_next/static/chunks/0z-plgq8f5mn..css +0 -4
  48. package/out/_next/static/chunks/149rrpod0rni6.js +0 -8
  49. /package/out/_next/static/{IO1CimuE81yW8NkO4976t → I9_aKPkRc3su-s0AsXKQM}/_buildManifest.js +0 -0
  50. /package/out/_next/static/{IO1CimuE81yW8NkO4976t → I9_aKPkRc3su-s0AsXKQM}/_clientMiddlewareManifest.js +0 -0
  51. /package/out/_next/static/{IO1CimuE81yW8NkO4976t → I9_aKPkRc3su-s0AsXKQM}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -1,6 +1,40 @@
1
1
  # Ennote
2
2
 
3
- AI-native bioinformatics agent workspace.
3
+ 本地优先、可审计的 Agent 运行时,核心能力是 **Agent Flow**。
4
+
5
+ Ennote 把长任务编排成可恢复的 Agent Flow:Task 图、Role 绑定、预算、审批、
6
+ checkpoint 和恢复状态都持久化在本地 SQLite 中。它不依赖账号或云端即可启动、
7
+ 计算和保存结果;生信是参考工作流,而不是平台承诺。
8
+
9
+ ## 核心特点
10
+
11
+ ### Agent Flow
12
+
13
+ - 用 Task 图表达依赖、分支、收敛和只读 fan-out,而不是把复杂流程塞进一条提示词。
14
+ - Agent Flow 是不可变版本;每次运行冻结 Role、Skill、模型和权限配置。
15
+ - 每个 Task 是独立子 Run,带自己的预算、审批和输出契约。
16
+ - Task 的 `writes` 声明只影响调度(reader/writer lane 与 disjoint writers),**不是沙箱隔离边界**。
17
+ - 串行执行、checkpoint 续跑、取消与恢复都已落地,并经过真实 Provider 验证。
18
+ - Graph 编辑器、版本校验、导入/导出共用同一套文件格式。
19
+
20
+ ### 可审计执行
21
+
22
+ - append-only Run 事件、commit-before-publish、durable SQLite 历史。
23
+ - 危险工具调用走审批;支持 standing grants 与冻结的策略快照。
24
+ - 失败/中断 Run 可恢复,Session 可 rewind 和 branch,不覆盖已有历史。
25
+ - 每个请求、Run、审批和 Artifact 都有稳定的本地标识,便于回溯“谁在什么时候做了什么”。
26
+
27
+ ### 本地优先
28
+
29
+ - `ennoworker` 只监听 loopback,`ennogate` 负责认证、静态 UI 和进程监督。
30
+ - 没有账号、Relay 或 Connect Token 也能完整使用 localhost/LAN Web。
31
+ - 云端不可用只会影响远程访问,不会停止 Worker、取消 Run 或删除数据。
32
+ - LAN 访问默认关闭;需要显式开启,并优先使用 TLS。
33
+
34
+ ### 可组合
35
+
36
+ - 不可变 Role、Skills、MCP 工具、typed Artifact 和托管委托。
37
+ - 生信示例(`skills/bio-workspace`)展示如何把本地数据、shell 工具和 Agent Flow 组合起来。
4
38
 
5
39
  ## 架构
6
40
 
@@ -9,9 +43,12 @@ AI-native bioinformatics agent workspace.
9
43
  ```
10
44
  Browser → ennogate (Go, :45131) → ennoworker (Go, :0, loopback)
11
45
  守门人 劳动者
12
- 认证 / 代理 / 静态文件 SQLite / Agent / 工具 / Skill
46
+ 认证 / 代理 / 静态文件 SQLite / Agent Flow / 审批 / 工具 / Skill
13
47
  ```
14
48
 
49
+ Worker 是 Project、Session、Message、Run、Attention、审批、Role/模型配置、
50
+ Workspace、Artifact 和委托状态的唯一权威。Web UI 只是同一套 Worker API 的客户端。
51
+
15
52
  ## 快速开始
16
53
 
17
54
  ```bash
@@ -32,7 +69,56 @@ ennote start
32
69
  - npm 包(`@seqyuan/ennote`)只包含 CLI 脚本和编译后的静态前端(约 12 MB)。
33
70
  - 首次 `ennote start` 时,CLI 会从本仓库([seqyuan/ennote](https://github.com/seqyuan/ennote))的 GitHub Release 按当前平台/架构下载 Go 二进制(ennogate + ennoworker),缓存到 `~/.ennote/bin/`,后续启动不再下载。
34
71
  - 支持平台:linux-x64 / linux-arm64 / darwin-x64 / darwin-arm64。
35
- - 发布流程:打版本 tag(`vX.Y.Z`)后,[release workflow](.github/workflows/release.yml) 自动构建四平台二进制、上传到该 tag 对应的 GitHub Release,并发布 npm 包。
72
+ - 发布流程:打版本 tag(`vX.Y.Z`)后,[release workflow](.github/workflows/release.yml) 运行测试、构建四平台二进制、验证 npm 版本已生效,最后创建 GitHub Release。
73
+
74
+ ### 审查 Agent Pack
75
+
76
+ `ennote pack inspect` 调用正在运行的 Worker 做只读审查:不安装、不执行、不验证签名、不写磁盘。
77
+
78
+ ```bash
79
+ ennote pack inspect ~/Downloads/cell-qc.enpack # 归档
80
+ ennote pack inspect ~/src/cell-qc # 目录包(需含 pack.yaml)
81
+ ennote pack inspect ~/Downloads/cell-qc.enpack --json # 原始 inspect 结果
82
+ ennote pack inspect ~/Downloads/cell-qc.enpack --documents # 附带 flow/role/skill 正文
83
+ ```
84
+
85
+ 输出包含 `manifestDigest`(`pack.yaml` 原始字节)和 `packDigest`(排序后的 payload 行,排除顶层 `pack.sig`)、flow/role/skill 摘要、能力信封与 warnings。`format: flow` 与 `format: graph` 会如实标注,不会被悄悄降级。Worker 未启动时命令直接失败并提示 `ennote start`,不做离线降级。
86
+
87
+ ### 安装 / 列出 / 卸载 Agent Pack
88
+
89
+ 安装同样只通过 loopback Worker 落盘,CLI 自己不做解析或写盘。
90
+
91
+ ```bash
92
+ ennote pack install ~/Downloads/cell-qc.enpack --dry-run # 只出计划,不写任何文件
93
+ ennote pack install ~/Downloads/cell-qc.enpack # 正式安装(可传目录包)
94
+ ennote pack install ~/src/cell-qc --as-flow qc-review=cell-qc-review
95
+ ennote pack list # 已安装 Pack 摘要
96
+ ennote pack remove seqyuan/cell-qc-review # 卸载
97
+ ennote pack remove seqyuan/cell-qc-review --force # 覆盖本地编辑 / 共享 Role 的拒绝
98
+ ```
99
+
100
+ 安装支持三种形态:Role-only Pack(每个 Role 变成 Worker 全局 Role 并发布 revision)、`format: graph` Flow Pack(未发布 revision 的 Graph draft + 其私有 Role/Skill)、以及 `format: flow` Agent Flow Pack(未发布 revision 的完整 FlowDefinition draft + 其 flow-local Role/Skill;安装前用真实 `agentflow.Validator` 校验 check/fan-out/terminal/next,不会降级成 graph)。非空 `mcp`、非空 `requires` 仍会被明确拒绝并返回稳定错误码,不会降级安装。dry-run 恒返回计划(含 blockers),正式安装遇到 blocker/冲突返回 422。
101
+
102
+ 安装后的 source 默认不可运行:`format: graph` 需要发布 Graph revision;`format: flow` 需要 `POST /v1/flows/{id}/publish` 发布 revision,再由 Project GraphBinding 显式启用。安装器永远不会自动发布或自动启用。
103
+
104
+ ### 从 v0.1.x 升级
105
+
106
+ v0.1.x 的旧数据库(`data/ennote.db`)不再被读取,但也不会被删除:升级后首次启动会把它(含 `-wal`/`-shm`)原子移动到 `data/legacy-v1/` 并打印一条 `archived legacy V1 database` 警告。迁移完成后再次启动不会改动该归档。需要找回旧数据时,从 `data/legacy-v1/` 手动复制即可。
107
+
108
+ ### 局域网访问
109
+
110
+ 默认只监听 `127.0.0.1`。需要从局域网打开时:
111
+
112
+ ```bash
113
+ # 推荐:TLS 终止在 ennogate,cookie 带 Secure
114
+ ENNOTE_HOSTNAME=0.0.0.0 ENNOTE_ALLOW_LAN=1 \
115
+ ENNOTE_TLS_CERT=/path/cert.pem ENNOTE_TLS_KEY=/path/key.pem ennogate
116
+
117
+ # 不推荐:显式接受明文风险(密码和 7 天 cookie 会明文过网)
118
+ ENNOTE_HOSTNAME=0.0.0.0 ENNOTE_ALLOW_LAN=1 ENNOTE_ALLOW_INSECURE_LAN=1 ennogate
119
+ ```
120
+
121
+ 非 loopback 地址在缺少 `ENNOTE_ALLOW_LAN=1` 时会拒绝启动;缺少 TLS 且没有 `ENNOTE_ALLOW_INSECURE_LAN=1` 时同样拒绝启动。
36
122
 
37
123
  ## 目录
38
124
 
@@ -46,33 +132,40 @@ ennoworker/
46
132
  config/ 配置与环境变量
47
133
  domain/ 领域类型和状态机
48
134
  events/ Durable EventWriter + 订阅 Hub
135
+ graphrun/ Agent Flow 执行与恢复
49
136
  llm/ Provider 接口、OpenAI-compatible、fake
50
137
  runs/ Run Coordinator(并发、取消、恢复)
51
138
  skills/ Skill bundle 加载与 per-run 快照
52
- store/ SQLite、迁移、Repository
53
- tools/ 7 个文件/Shell 工具 + registry
139
+ store/ SQLite、Repository
140
+ tools/ 文件/Shell/审批工具 + registry
54
141
  workspace/ Path jail、bubblewrap 沙箱
55
- migrations/ SQL 版本化迁移
56
-
142
+ sessionmigrations/ Session SQL 版本化迁移
143
+ catalogmigrations/ Catalog 投影迁移
144
+ usagemigrations/ Usage 投影迁移
57
145
  app/ Next.js 前端(React + SSE 消费)
58
146
  contracts/ OpenAPI 3.1 契约
59
147
  skills/ 内置 Skill bundle
60
- docs/ 设计与实施计划
61
148
  ```
62
149
 
63
- ## Roadmap
150
+ ## 当前重点
64
151
 
65
- 当前开发状态、优先级和明确延期范围以 [`docs/roadmap.md`](docs/roadmap.md) 为唯一来源。`docs/plans/` 中的旧优先级仅保留为设计与实施历史。
152
+ - Agent Flow 的可观测性、恢复语义和实际采用,而不是继续扩张产品表面。
153
+ - Run 的可审计性、审批体验和本地留痕。
154
+ - 本地优先与沙箱边界(bwrap 网络隔离、SandboxNone 的凭据保护)。
155
+ - 生信作为参考工作流维护,不承诺 Warm Kernel、Notebook 或 Publication 工作台。
66
156
 
67
157
  ## 开发
68
158
 
69
159
  ```bash
70
160
  make dev # 启动 ennogate + ennoworker
71
- make test-go # Go 测试(含 -race)
161
+ make test-go # Go 测试(go test ./...)
72
162
  make test # JS 测试
73
- make lint # ESLint
163
+ make lint # ESLint + design token guard
74
164
  make typecheck # tsc --noEmit
75
165
  make build # 构建静态前端和本机 Go 产物
166
+ npm run test:e2e # Playwright
76
167
  npm run release:prepare # 构建四个平台的发布包并检查 tarball
77
- npm run smoke:installed # 安装临时 tarball 并验证双进程生命周期
168
+ npm run smoke:installed # 安装临时 tarball 并验证双进程生命周期
78
169
  ```
170
+
171
+ CI 会在 Go 测试矩阵里额外运行 `-race`。
package/bin/ennote.js CHANGED
@@ -16,6 +16,8 @@ const DEFAULT_PORT = "45131";
16
16
  const DEFAULT_HOSTNAME = "127.0.0.1";
17
17
  const START_TIMEOUT_MS = 45_000;
18
18
  const STOP_TIMEOUT_MS = 15_000;
19
+ const PACK_INSPECT_TIMEOUT_MS = 15_000;
20
+ const PACK_REQUEST_TIMEOUT_MS = 15_000;
19
21
  const GITHUB_RELEASES_BASE = "https://github.com/seqyuan/ennote/releases/download";
20
22
 
21
23
  function platformSuffix(platform = os.platform(), arch = os.arch()) {
@@ -54,6 +56,18 @@ function statePaths(home) {
54
56
  };
55
57
  }
56
58
 
59
+ // workerStatePath is the owner-only handshake file ennoworker writes and
60
+ // ennogate supervises (see internal/runtimeinfo.WorkerState).
61
+ function workerStatePath(home) {
62
+ return path.join(home, "runtime", "worker-state.json");
63
+ }
64
+
65
+ function usageError(message) {
66
+ const error = new Error(message);
67
+ error.exitCode = 2;
68
+ return error;
69
+ }
70
+
57
71
  function validateRuntime(paths) {
58
72
  for (const [name, file] of [["ennogate", paths.gate], ["ennoworker", paths.worker]]) {
59
73
  if (!fs.existsSync(file)) throw new Error(`Missing ${name} binary: ${file}`);
@@ -302,6 +316,9 @@ async function startService(options) {
302
316
  ENNOTE_HOME: home,
303
317
  ENNOTE_STATIC_DIR: runtime.staticDir,
304
318
  ENNOTE_WORKER_PATH: runtime.worker,
319
+ ...(fs.existsSync(path.join(packageDir, "skills"))
320
+ ? { ENNOTE_BUILTIN_SKILLS_DIR: path.join(packageDir, "skills") }
321
+ : {}),
305
322
  PORT: String(port),
306
323
  ENNOTE_HOSTNAME: hostname,
307
324
  },
@@ -362,6 +379,373 @@ async function serviceStatus(home) {
362
379
  return { running: healthy, healthy, pid: state.pid, url: state.url, port: state.port };
363
380
  }
364
381
 
382
+ function isLoopbackHostname(hostname) {
383
+ const name = String(hostname ?? "").replace(/^\[|\]$/g, "").toLowerCase();
384
+ if (name === "localhost" || name === "::1") return true;
385
+ const match = name.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
386
+ return Boolean(match) && match.slice(1).every(part => Number(part) <= 255);
387
+ }
388
+
389
+ // loadWorkerEndpoint reads the Worker handshake written by ennoworker and
390
+ // refuses anything that is not a live loopback http Worker: ennote pack inspect
391
+ // talks to the real Worker instead of re-implementing pack parsing in Node.
392
+ function loadWorkerEndpoint(home) {
393
+ const file = workerStatePath(home);
394
+ if (!fs.existsSync(file)) {
395
+ throw new Error(`Ennote Worker is not running (${file} is missing). Start it with: ennote start`);
396
+ }
397
+ let state;
398
+ try {
399
+ state = JSON.parse(fs.readFileSync(file, "utf8"));
400
+ } catch {
401
+ throw new Error(`Ennote Worker state ${file} is unreadable. Restart it with: ennote restart`);
402
+ }
403
+ let parsed;
404
+ try {
405
+ parsed = new URL(String(state?.url ?? ""));
406
+ } catch {
407
+ throw new Error(`Ennote Worker state ${file} has an invalid url: ${state?.url ?? ""}`);
408
+ }
409
+ if (parsed.protocol !== "http:") {
410
+ throw new Error(`refusing Worker url ${state.url}: expected http:`);
411
+ }
412
+ if (!isLoopbackHostname(parsed.hostname)) {
413
+ throw new Error(`refusing non-loopback Worker url ${state.url}`);
414
+ }
415
+ if (!isPidAlive(state.pid)) {
416
+ throw new Error(`Ennote Worker pid ${state.pid} is not running. Start it with: ennote start`);
417
+ }
418
+ if (typeof state.bootstrapToken !== "string" || state.bootstrapToken === "") {
419
+ throw new Error(`Ennote Worker state ${file} has no bootstrap token. Restart it with: ennote restart`);
420
+ }
421
+ return { url: parsed.origin, token: state.bootstrapToken, pid: state.pid, instanceId: state.instanceId };
422
+ }
423
+
424
+ function postJSON(url, body, headers, timeoutMs) {
425
+ return httpRequest("POST", url, body, headers, timeoutMs);
426
+ }
427
+
428
+ // httpRequest is the one place the CLI talks to the Worker. GET/DELETE carry no
429
+ // body; POST serializes it. The Worker url is already pinned to loopback http by
430
+ // loadWorkerEndpoint, so no transport negotiation happens here.
431
+ function httpRequest(method, url, body, headers, timeoutMs) {
432
+ return new Promise((resolve, reject) => {
433
+ const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body), "utf8");
434
+ const request = http.request(url, {
435
+ method,
436
+ headers: payload
437
+ ? { "Content-Type": "application/json", "Content-Length": payload.length, ...headers }
438
+ : headers,
439
+ }, (response) => {
440
+ const chunks = [];
441
+ response.on("data", (chunk) => chunks.push(chunk));
442
+ response.on("end", () => resolve({
443
+ statusCode: response.statusCode ?? 0,
444
+ body: Buffer.concat(chunks).toString("utf8"),
445
+ }));
446
+ });
447
+ request.once("error", reject);
448
+ request.setTimeout(timeoutMs, () => {
449
+ request.destroy(new Error(`Worker request timed out after ${timeoutMs / 1000}s`));
450
+ });
451
+ request.end(payload ?? undefined);
452
+ });
453
+ }
454
+
455
+ // decodeWorkerResponse unwraps the Worker error envelope and the data envelope.
456
+ // A 204 (pack remove) has no data and yields null.
457
+ function decodeWorkerResponse(response, action) {
458
+ if (response.statusCode < 200 || response.statusCode >= 300) {
459
+ let message = `Worker returned HTTP ${response.statusCode}`;
460
+ let code;
461
+ try {
462
+ const parsed = JSON.parse(response.body);
463
+ code = parsed?.error?.code;
464
+ if (parsed?.error?.message) message = parsed.error.message;
465
+ } catch {
466
+ // A non-JSON error body keeps the status-only message.
467
+ }
468
+ const error = new Error(code ? `${message} (${code})` : message);
469
+ error.code = code;
470
+ error.statusCode = response.statusCode;
471
+ throw error;
472
+ }
473
+ if (response.statusCode === 204 || response.body.trim() === "") return null;
474
+ let parsed;
475
+ try {
476
+ parsed = JSON.parse(response.body);
477
+ } catch {
478
+ throw new Error(`Worker returned an unreadable ${action} response`);
479
+ }
480
+ if (!parsed || typeof parsed !== "object" || parsed.data == null) {
481
+ throw new Error(`Worker returned an unexpected ${action} response`);
482
+ }
483
+ return parsed.data;
484
+ }
485
+
486
+ // requestJSON performs one authenticated Worker call. Every pack command except
487
+ // install is GET/DELETE; install reuses postJSON so its body and headers stay in
488
+ // one place.
489
+ async function requestJSON(endpoint, method, pathname, options = {}) {
490
+ if (method === "POST") {
491
+ const response = await postJSON(
492
+ `${endpoint.url}${pathname}`,
493
+ options.body,
494
+ { Authorization: `Bearer ${endpoint.token}` },
495
+ options.timeoutMs ?? PACK_REQUEST_TIMEOUT_MS,
496
+ );
497
+ return decodeWorkerResponse(response, options.action ?? "pack");
498
+ }
499
+ const response = await httpRequest(
500
+ method,
501
+ `${endpoint.url}${pathname}`,
502
+ undefined,
503
+ { Authorization: `Bearer ${endpoint.token}` },
504
+ options.timeoutMs ?? PACK_REQUEST_TIMEOUT_MS,
505
+ );
506
+ return decodeWorkerResponse(response, options.action ?? "pack");
507
+ }
508
+
509
+ async function requestPackInspect(endpoint, target, options = {}) {
510
+ const body = {
511
+ source: { kind: "path", path: target },
512
+ includeDocuments: Boolean(options.documents),
513
+ };
514
+ if (options.expectedDigest) body.expectedDigest = options.expectedDigest;
515
+ const response = await postJSON(
516
+ `${endpoint.url}/v1/packs/inspect`,
517
+ body,
518
+ { Authorization: `Bearer ${endpoint.token}` },
519
+ options.timeoutMs ?? PACK_INSPECT_TIMEOUT_MS,
520
+ );
521
+ return decodeWorkerResponse(response, "inspect");
522
+ }
523
+
524
+ function formatPackSummary(data) {
525
+ const pack = data.pack ?? {};
526
+ const container = data.container ?? {};
527
+ const signature = data.signature ?? {};
528
+ const lines = [];
529
+ lines.push(`${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"})`);
530
+ if (pack.description) lines.push(pack.description);
531
+ lines.push(`Source: ${data.source?.path ?? "?"} (${container.kind ?? "?"}, ${container.bytes ?? 0} bytes)`);
532
+ lines.push(signature.present
533
+ ? `Signature: ${signature.algorithm} at ${signature.path} — UNVERIFIED (inspect never verifies signatures)`
534
+ : "Signature: Unsigned");
535
+ lines.push(`Manifest: ${data.manifestDigest ?? "?"}`);
536
+ lines.push(`Pack: ${data.packDigest ?? "?"}`);
537
+
538
+ const flows = data.flows ?? [];
539
+ lines.push(`Flows (${flows.length})`);
540
+ for (const flow of flows) {
541
+ lines.push(` - ${flow.id} [${flow.format}] ${flow.taskCount} task(s), ${flow.checkTasks} check, ${flow.fanOutTasks} fan-out, ${flow.maxTotalTokens} tokens`);
542
+ }
543
+ const roles = data.roles ?? [];
544
+ lines.push(`Roles (${roles.length})`);
545
+ for (const role of roles) {
546
+ lines.push(` - ${role.handle} (${role.scope}) ${role.authority}, tools: ${(role.allowedTools ?? []).join(", ") || "none"}`);
547
+ }
548
+ const skills = data.skills ?? [];
549
+ lines.push(`Skills (${skills.length})`);
550
+ for (const skill of skills) {
551
+ lines.push(` - ${skill.kind === "external" ? skill.ref : skill.id} (${skill.kind})`);
552
+ }
553
+ // External MCP profile refs are reported but never resolved: the summary says
554
+ // so explicitly so a reviewer does not read the line as an installed profile.
555
+ const mcp = data.mcp ?? [];
556
+ if (mcp.length > 0) {
557
+ lines.push(`MCP (${mcp.length})`);
558
+ for (const profile of mcp) {
559
+ lines.push(` - ${profile.ref} (external ref, not resolved or executed)`);
560
+ }
561
+ }
562
+ const envelope = data.capabilityEnvelope ?? {};
563
+ lines.push(`Checks: ${(envelope.checkCommands ?? []).join(", ") || "none"}`);
564
+ lines.push(`Writes: ${(envelope.writes ?? []).join(", ") || "none"}`);
565
+ lines.push(`Files: ${data.filesTotal ?? 0}${data.filesTruncated ? " (truncated)" : ""}`);
566
+
567
+ const warnings = data.warnings ?? [];
568
+ lines.push(`Warnings (${warnings.length})`);
569
+ for (const warning of warnings) lines.push(` - ${warning.code} (${warning.severity}): ${warning.message}`);
570
+ return lines.join("\n");
571
+ }
572
+
573
+ async function packCommand(home, positionals, values) {
574
+ const action = positionals[1];
575
+ const target = positionals[2];
576
+ if (action === "inspect") {
577
+ if (!target) {
578
+ throw usageError("Usage: ennote pack inspect <file|dir> [--json] [--documents]");
579
+ }
580
+ const endpoint = loadWorkerEndpoint(home);
581
+ const data = await requestPackInspect(endpoint, path.resolve(target), {
582
+ documents: values.documents,
583
+ });
584
+ if (values.json) console.log(JSON.stringify(data));
585
+ else console.log(formatPackSummary(data));
586
+ return;
587
+ }
588
+ if (action === "install") {
589
+ if (!target) {
590
+ throw usageError("Usage: ennote pack install <file|dir> [--dry-run] [--json] [--as-flow old=new] [--as-role old=new]");
591
+ }
592
+ // Renames are validated before the Worker is contacted, so a typo is a
593
+ // usage error instead of a refused install.
594
+ const renames = {
595
+ flows: parsePackRenames(values["as-flow"], "--as-flow"),
596
+ roles: parsePackRenames(values["as-role"], "--as-role"),
597
+ };
598
+ const endpoint = loadWorkerEndpoint(home);
599
+ const plan = await requestPackInstall(endpoint, path.resolve(target), {
600
+ dryRun: Boolean(values["dry-run"]),
601
+ renames,
602
+ });
603
+ if (values.json) console.log(JSON.stringify(plan));
604
+ else console.log(formatPackInstallPlan(plan, { dryRun: Boolean(values["dry-run"]) }));
605
+ return;
606
+ }
607
+ if (action === "list") {
608
+ const endpoint = loadWorkerEndpoint(home);
609
+ const summaries = await requestJSON(endpoint, "GET", "/v1/packs", { action: "pack list" });
610
+ if (values.json) console.log(JSON.stringify(summaries ?? []));
611
+ else console.log(formatPackList(summaries ?? []));
612
+ return;
613
+ }
614
+ if (action === "remove") {
615
+ if (!target) {
616
+ throw usageError("Usage: ennote pack remove <publisher>/<name> [--force] [--json]");
617
+ }
618
+ const packId = parsePackID(target);
619
+ const endpoint = loadWorkerEndpoint(home);
620
+ const [publisher, name] = packId.split("/");
621
+ const query = values.force ? "?force=1" : "";
622
+ const pathname = `/v1/packs/${encodeURIComponent(publisher)}/${encodeURIComponent(name)}${query}`;
623
+ // A 204 carries no body; the CLI still reports the same shape as --json so
624
+ // scripts do not have to special-case the empty response.
625
+ const data = (await requestJSON(endpoint, "DELETE", pathname, { action: "pack remove" })) ?? {
626
+ packId,
627
+ removed: true,
628
+ };
629
+ if (values.json) console.log(JSON.stringify(data));
630
+ else console.log(`Removed ${data.packId ?? packId}${values.force ? " (forced)" : ""}`);
631
+ return;
632
+ }
633
+ throw usageError(
634
+ `Unknown pack command: ${action ?? ""}\nUsage: ennote pack <inspect|install|list|remove> ...`,
635
+ );
636
+ }
637
+
638
+ // parsePackRenames turns repeated --as-flow/--as-role old=new flags into the
639
+ // renames object. A rename only changes the installed store id.
640
+ function parsePackRenames(entries, flag) {
641
+ const renames = {};
642
+ for (const entry of entries ?? []) {
643
+ const text = String(entry);
644
+ const separator = text.indexOf("=");
645
+ if (separator <= 0 || separator === text.length - 1) {
646
+ throw usageError(`${flag} expects old=new, got ${text}`);
647
+ }
648
+ const from = text.slice(0, separator).trim();
649
+ const to = text.slice(separator + 1).trim();
650
+ if (from === "" || to === "") throw usageError(`${flag} expects old=new, got ${text}`);
651
+ if (Object.prototype.hasOwnProperty.call(renames, from) && renames[from] !== to) {
652
+ throw usageError(`${flag} maps ${from} twice (${renames[from]} and ${to})`);
653
+ }
654
+ renames[from] = to;
655
+ }
656
+ return renames;
657
+ }
658
+
659
+ // parsePackID validates <publisher>/<name> before it goes on the wire: a bad id
660
+ // is a usage error, not a Worker 400/404.
661
+ function parsePackID(value) {
662
+ const segments = String(value).split("/");
663
+ if (segments.length !== 2 || segments.some(segment => segment === "" || segment === "." || segment === "..")) {
664
+ throw usageError(`pack id must be <publisher>/<name>, got ${value}`);
665
+ }
666
+ return segments.join("/");
667
+ }
668
+
669
+ async function requestPackInstall(endpoint, target, options = {}) {
670
+ const body = { source: { kind: "path", path: target }, dryRun: Boolean(options.dryRun) };
671
+ const renames = options.renames ?? {};
672
+ const encoded = {};
673
+ if (Object.keys(renames.flows ?? {}).length > 0) encoded.flows = renames.flows;
674
+ if (Object.keys(renames.roles ?? {}).length > 0) encoded.roles = renames.roles;
675
+ if (Object.keys(encoded).length > 0) body.renames = encoded;
676
+ return requestJSON(endpoint, "POST", "/v1/packs/install", { body, action: "pack install" });
677
+ }
678
+
679
+ function formatPackActionCounts(actions) {
680
+ const counts = new Map();
681
+ for (const action of actions) {
682
+ counts.set(action.action, (counts.get(action.action) ?? 0) + 1);
683
+ }
684
+ return [...counts.entries()]
685
+ .sort(([left], [right]) => left.localeCompare(right))
686
+ .map(([name, count]) => `${count} ${name}`)
687
+ .join(", ");
688
+ }
689
+
690
+ function formatPackInstallPlan(plan, options = {}) {
691
+ const pack = plan?.pack ?? {};
692
+ const lines = [];
693
+ const headline = plan?.reinstall === "noop"
694
+ ? "Already installed"
695
+ : options.dryRun ? "Dry run (nothing written)" : "Installed";
696
+ lines.push(`${headline}: ${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"})`);
697
+ lines.push(`Manifest: ${plan?.manifestDigest ?? "?"}`);
698
+ lines.push(`Pack: ${plan?.packDigest ?? "?"}`);
699
+
700
+ const actions = plan?.actions ?? [];
701
+ lines.push(`Actions (${actions.length}): ${formatPackActionCounts(actions) || "none"}`);
702
+ for (const action of actions) {
703
+ const scope = action.scope ? ` (${action.scope})` : "";
704
+ lines.push(` - ${action.action} ${action.kind} ${action.storeId}${scope}`);
705
+ }
706
+
707
+ const state = plan?.state;
708
+ if (state) {
709
+ lines.push(`State: ${state.installState ?? "?"}; flows ${state.flows?.length ?? 0}, roles ${state.roles?.length ?? 0}, skills ${state.skills?.length ?? 0}`);
710
+ const drafts = (state.flows ?? []).filter(flow => !flow.publishedRevision);
711
+ if (drafts.length > 0) {
712
+ lines.push(`Disabled: ${drafts.map(flow => flow.storeId).join(", ")} (draft Graph, no published revision — publish it to enable runs)`);
713
+ }
714
+ if (state.source?.path) lines.push(`Source: ${state.source.path}`);
715
+ const external = state.externalRefs ?? {};
716
+ const externalSkills = external.skills ?? [];
717
+ const externalMcp = external.mcp ?? [];
718
+ if (externalSkills.length > 0 || externalMcp.length > 0) {
719
+ lines.push(`Recorded (not installed): skills ${externalSkills.join(", ") || "none"}; mcp ${externalMcp.join(", ") || "none"}`);
720
+ }
721
+ } else if (options.dryRun) {
722
+ lines.push("State: not written (dry run)");
723
+ }
724
+
725
+ const blockers = plan?.blockers ?? [];
726
+ lines.push(`Blockers (${blockers.length})`);
727
+ for (const blocker of blockers) {
728
+ lines.push(` - ${blocker.code} (${blocker.severity ?? "error"}): ${blocker.message}`);
729
+ }
730
+ const warnings = plan?.warnings ?? [];
731
+ lines.push(`Warnings (${warnings.length})`);
732
+ for (const warning of warnings) {
733
+ lines.push(` - ${warning.code} (${warning.severity ?? "warning"}): ${warning.message}`);
734
+ }
735
+ return lines.join("\n");
736
+ }
737
+
738
+ function formatPackList(summaries) {
739
+ if (summaries.length === 0) return "No Agent Packs installed.";
740
+ const lines = [`Installed Agent Packs (${summaries.length})`];
741
+ for (const summary of summaries) {
742
+ const pack = summary.pack ?? {};
743
+ lines.push(` - ${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"}) ${summary.installState ?? "?"}`);
744
+ lines.push(` flows ${summary.flowCount ?? 0}, roles ${summary.roleCount ?? 0}, skills ${summary.skillCount ?? 0}; installed ${summary.installedAt ?? "?"}`);
745
+ }
746
+ return lines.join("\n");
747
+ }
748
+
365
749
  async function setPassword(home, reset) {
366
750
  const authFile = statePaths(home).auth;
367
751
  ensureDirectory(path.dirname(authFile));
@@ -397,11 +781,20 @@ Commands:
397
781
  logs Show recent service logs
398
782
  passwd Set the local password
399
783
  passwd --reset Remove the local password and require setup
784
+ pack inspect Inspect an Agent Pack (.enpack or directory) without installing
785
+ pack install Install an Agent Pack from a .enpack file or directory
786
+ pack list List installed Agent Packs
787
+ pack remove Uninstall an Agent Pack (<publisher>/<name>)
400
788
 
401
789
  Options:
402
790
  --port, -p Browser-facing port (default ${DEFAULT_PORT})
403
791
  --hostname, -H Bind address (default ${DEFAULT_HOSTNAME})
404
- --json Machine-readable output
792
+ --json Machine-readable output (prints the Worker data payload)
793
+ --documents pack inspect: include flow, role, and skill source text
794
+ --dry-run pack install: plan only and write nothing
795
+ --as-flow pack install: old=new rename of an installed Graph id
796
+ --as-role pack install: old=new rename of an installed Role handle
797
+ --force pack remove: override local edits and shared global Roles
405
798
  --version, -v Show version
406
799
  --help, -h Show this help`);
407
800
  }
@@ -415,6 +808,11 @@ async function main(argv = process.argv.slice(2)) {
415
808
  port: { type: "string", short: "p", default: DEFAULT_PORT },
416
809
  hostname: { type: "string", short: "H", default: DEFAULT_HOSTNAME },
417
810
  json: { type: "boolean", default: false },
811
+ documents: { type: "boolean", default: false },
812
+ "dry-run": { type: "boolean", default: false },
813
+ "as-flow": { type: "string", multiple: true },
814
+ "as-role": { type: "string", multiple: true },
815
+ force: { type: "boolean", default: false },
418
816
  reset: { type: "boolean", default: false },
419
817
  version: { type: "boolean", short: "v", default: false },
420
818
  help: { type: "boolean", short: "h", default: false },
@@ -451,6 +849,11 @@ async function main(argv = process.argv.slice(2)) {
451
849
  case "passwd":
452
850
  result = await setPassword(home, values.reset);
453
851
  break;
852
+ case "pack":
853
+ // Every pack command talks to the running Worker instead of re-implementing
854
+ // manifest parsing, installation, or store writes in Node.
855
+ await packCommand(home, positionals, values);
856
+ return;
454
857
  default:
455
858
  throw new Error(`Unknown command: ${command}`);
456
859
  }
@@ -466,6 +869,7 @@ module.exports = {
466
869
  runtimeBinDir,
467
870
  runtimePaths,
468
871
  statePaths,
872
+ workerStatePath,
469
873
  serviceURL,
470
874
  validateRuntime,
471
875
  healthCheck,
@@ -478,12 +882,23 @@ module.exports = {
478
882
  stopService,
479
883
  serviceStatus,
480
884
  setPassword,
885
+ isLoopbackHostname,
886
+ loadWorkerEndpoint,
887
+ requestPackInspect,
888
+ formatPackSummary,
889
+ requestJSON,
890
+ requestPackInstall,
891
+ parsePackRenames,
892
+ parsePackID,
893
+ formatPackInstallPlan,
894
+ formatPackList,
895
+ packCommand,
481
896
  main,
482
897
  };
483
898
 
484
899
  if (require.main === module) {
485
900
  main().catch((error) => {
486
901
  console.error(error instanceof Error ? error.message : String(error));
487
- process.exitCode = 1;
902
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
488
903
  });
489
904
  }