chainlesschain 0.46.0 → 0.47.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.
Files changed (60) hide show
  1. package/README.md +16 -5
  2. package/bin/chainlesschain.js +0 -0
  3. package/package.json +1 -1
  4. package/src/assets/web-panel/.build-hash +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-C1AnPdMx.js → Analytics-DgypYeUB.js} +2 -2
  6. package/src/assets/web-panel/assets/AppLayout-DQyDwGut.css +1 -0
  7. package/src/assets/web-panel/assets/AppLayout-ZHpCFO_p.js +1 -0
  8. package/src/assets/web-panel/assets/{Backup-D31iZX3l.js → Backup-Ba9UybpT.js} +1 -1
  9. package/src/assets/web-panel/assets/{Chat-DiXJ3TuK.js → Chat-BwXskT21.js} +1 -1
  10. package/src/assets/web-panel/assets/{Cowork-B8ZDdRm4.js → Cowork-UmOe7qvE.js} +1 -1
  11. package/src/assets/web-panel/assets/{Cron-DBt1ueXh.js → Cron-JHS-rc-4.js} +2 -2
  12. package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +1 -0
  13. package/src/assets/web-panel/assets/{Dashboard-jt6XPIjB.js → Dashboard-CpWz2g0n.js} +2 -2
  14. package/src/assets/web-panel/assets/{Git-hwQ1oZHj.js → Git-CSYO0_zk.js} +2 -2
  15. package/src/assets/web-panel/assets/{Logs-4D9p6PRM.js → Logs-Hxw_K0km.js} +2 -2
  16. package/src/assets/web-panel/assets/{McpTools-CyAUjbbs.js → McpTools-DIE75TrB.js} +2 -2
  17. package/src/assets/web-panel/assets/{Memory-BMqOR7S-.js → Memory-C4KVnLlp.js} +2 -2
  18. package/src/assets/web-panel/assets/{Notes-Cmas8i4E.js → Notes-DuzrHMAk.js} +2 -2
  19. package/src/assets/web-panel/assets/{Organization-DnSa58Tl.js → Organization-DTq6uF82.js} +4 -4
  20. package/src/assets/web-panel/assets/{P2P-BxksIBWs.js → P2P-C0hjlhsR.js} +2 -2
  21. package/src/assets/web-panel/assets/{Permissions-Bq5Qn2s3.js → Permissions-Ec0NH-xC.js} +4 -4
  22. package/src/assets/web-panel/assets/{Projects-B7EM0uPg.js → Projects-U8D0asCS.js} +2 -2
  23. package/src/assets/web-panel/assets/{Providers-DAwgG5KV.js → Providers-BngtTLvJ.js} +2 -2
  24. package/src/assets/web-panel/assets/{RssFeed-HSZoRXvS.js → RssFeed-B9NbwCKM.js} +3 -3
  25. package/src/assets/web-panel/assets/{Security-Cz17qBny.js → Security-BL5Rkr1T.js} +3 -3
  26. package/src/assets/web-panel/assets/{Services-D2EsLq-v.js → Services-D4MJzLld.js} +2 -2
  27. package/src/assets/web-panel/assets/{Skills-C9v-f3vZ.js → Skills-CQTOMDwF.js} +1 -1
  28. package/src/assets/web-panel/assets/{Tasks-yMEcU0n7.js → Tasks-DepbJMnL.js} +1 -1
  29. package/src/assets/web-panel/assets/{Templates-l7SvlKuB.js → Templates-C24PVZPu.js} +1 -1
  30. package/src/assets/web-panel/assets/{Wallet-BHWhLWn9.js → Wallet-PQoSpN_P.js} +3 -3
  31. package/src/assets/web-panel/assets/{WebAuthn-kWhFYaUK.js → WebAuthn-BcuyQ4Lr.js} +4 -4
  32. package/src/assets/web-panel/assets/WorkflowEditor-C-SvXbHW.js +1 -0
  33. package/src/assets/web-panel/assets/WorkflowEditor-D5bX6woe.css +1 -0
  34. package/src/assets/web-panel/assets/{antd-D6h4fDFf.js → antd-DEjZPGMj.js} +82 -82
  35. package/src/assets/web-panel/assets/index-CLmYSvow.js +2 -0
  36. package/src/assets/web-panel/assets/{markdown-BZsB-Dsv.js → markdown-CusdXFxb.js} +1 -1
  37. package/src/assets/web-panel/index.html +2 -2
  38. package/src/commands/cowork.js +213 -41
  39. package/src/gateways/ws/action-protocol.js +140 -0
  40. package/src/gateways/ws/message-dispatcher.js +5 -0
  41. package/src/gateways/ws/ws-server.js +21 -0
  42. package/src/lib/cowork-evomap-adapter.js +121 -0
  43. package/src/lib/cowork-observe-html.js +108 -0
  44. package/src/lib/cowork-observe.js +160 -0
  45. package/src/lib/cowork-share.js +114 -10
  46. package/src/lib/provider-options.js +133 -0
  47. package/src/lib/skill-loader.js +65 -0
  48. package/src/lib/sub-agent-context.js +16 -4
  49. package/src/lib/sub-agent-profiles.js +164 -0
  50. package/src/lib/todo-manager.js +108 -0
  51. package/src/lib/turn-context.js +95 -0
  52. package/src/lib/web-fetch.js +224 -0
  53. package/src/repl/agent-repl.js +4 -0
  54. package/src/runtime/agent-core.js +135 -3
  55. package/src/runtime/coding-agent-contract-shared.cjs +131 -0
  56. package/src/runtime/coding-agent-policy.cjs +30 -0
  57. package/src/assets/web-panel/assets/AppLayout-BnvARObz.js +0 -1
  58. package/src/assets/web-panel/assets/AppLayout-cxfKLu-m.css +0 -1
  59. package/src/assets/web-panel/assets/Dashboard-CKeMmCoT.css +0 -1
  60. package/src/assets/web-panel/assets/index-C1SPm_5l.js +0 -2
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Cowork ↔ EvoMap adapter — publish Cowork templates as EvoMap "genes" and
3
+ * pull them back for local install. Thin wrapper over `evomap-client.js`
4
+ * that fixes `kind = "cowork-template"` and carries N4 signatures through.
5
+ *
6
+ * Pure glue: all I/O is delegated to EvoMapClient (network) and the
7
+ * marketplace/share modules (disk). Injectable via `_deps.createClient`.
8
+ *
9
+ * @module cowork-evomap-adapter
10
+ */
11
+
12
+ import { EvoMapClient } from "./evomap-client.js";
13
+ import {
14
+ toShareableTemplate,
15
+ saveUserTemplate,
16
+ } from "./cowork-template-marketplace.js";
17
+ import { buildPacket } from "./cowork-share.js";
18
+
19
+ export const _deps = {
20
+ createClient: (opts) => new EvoMapClient(opts),
21
+ };
22
+
23
+ const KIND = "cowork-template";
24
+
25
+ function _wrapGene(template, signer) {
26
+ const payload = toShareableTemplate(template);
27
+ // Reuse share-packet builder so signed genes land on the hub with the same
28
+ // canonical shape as file-based packets (checksum + optional signature).
29
+ const packet = buildPacket({
30
+ kind: "template",
31
+ payload,
32
+ author: signer?.did || "anonymous",
33
+ cliVersion: undefined,
34
+ signer: signer || undefined,
35
+ });
36
+ return {
37
+ id: payload.id,
38
+ name: payload.name || payload.id,
39
+ description: payload.description || "",
40
+ kind: KIND,
41
+ version: payload.version || "1.0.0",
42
+ packet,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Publish a template to a hub. Requires API key on the client.
48
+ * @returns {Promise<object>} hub response (typically { id, ... })
49
+ */
50
+ export async function publishTemplateToHub(
51
+ template,
52
+ { hubUrl, apiKey, signer } = {},
53
+ ) {
54
+ if (!template || !template.id) {
55
+ throw new Error("template.id required");
56
+ }
57
+ const client = _deps.createClient({ hubUrl, apiKey });
58
+ const gene = _wrapGene(template, signer);
59
+ return client.publish(gene);
60
+ }
61
+
62
+ /**
63
+ * Search templates on a hub. Degrades to [] on network error unless
64
+ * `strict: true` is passed.
65
+ * @returns {Promise<Array>} annotated with `_hubMeta`
66
+ */
67
+ export async function searchTemplatesInHub(
68
+ query,
69
+ { hubUrl, limit = 20, strict = false } = {},
70
+ ) {
71
+ const client = _deps.createClient({ hubUrl });
72
+ try {
73
+ const results = await client.search(query || "", {
74
+ category: KIND,
75
+ limit,
76
+ });
77
+ return (results || []).map((r) => ({
78
+ ...r,
79
+ _hubMeta: {
80
+ hubUrl: client.hubUrl,
81
+ downloads: r.downloads || 0,
82
+ rating: r.rating || null,
83
+ },
84
+ }));
85
+ } catch (err) {
86
+ if (strict) throw err;
87
+ return [];
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Fetch a gene by id and install its template into the local marketplace.
93
+ * Returns the saved template object.
94
+ */
95
+ export async function installTemplateFromHub(
96
+ cwd,
97
+ geneId,
98
+ { hubUrl, requireSigned = false, trustedDids = null } = {},
99
+ ) {
100
+ const client = _deps.createClient({ hubUrl });
101
+ const data = await client.download(geneId);
102
+ // Hub may return { gene: { packet } } or { packet } directly
103
+ const gene = data?.gene || data;
104
+ const packet = gene?.packet || data?.packet;
105
+ if (!packet || packet.kind !== "template" || !packet.payload) {
106
+ throw new Error("Hub response missing template packet");
107
+ }
108
+ if (requireSigned && !packet.signature) {
109
+ throw new Error("Gene is not signed and --require-signed was set");
110
+ }
111
+ if (
112
+ Array.isArray(trustedDids) &&
113
+ trustedDids.length > 0 &&
114
+ (!packet.signature || !trustedDids.includes(packet.signature.did))
115
+ ) {
116
+ throw new Error(
117
+ `Gene signer not in trusted list${packet.signature ? ` (${packet.signature.did})` : ""}`,
118
+ );
119
+ }
120
+ return saveUserTemplate(cwd, packet.payload);
121
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Cowork Observe HTML — builds a static single-page dashboard from an
3
+ * aggregate snapshot. Pure function; no I/O.
4
+ *
5
+ * @module cowork-observe-html
6
+ */
7
+
8
+ function escapeHtml(s) {
9
+ return String(s)
10
+ .replace(/&/g, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+
17
+ function escapeJsonForScript(obj) {
18
+ return JSON.stringify(obj)
19
+ .replace(/</g, "\\u003c")
20
+ .replace(/>/g, "\\u003e")
21
+ .replace(/&/g, "\\u0026");
22
+ }
23
+
24
+ /**
25
+ * Build a self-contained HTML dashboard from aggregate output.
26
+ * @param {object} data - result of `aggregate()`
27
+ * @returns {string} full HTML document
28
+ */
29
+ export function buildHtml(data) {
30
+ const safeJson = escapeJsonForScript(data || {});
31
+ const win = data?.window || {};
32
+ const t = data?.tasks || {};
33
+ const templates = data?.templates || [];
34
+ const failures = data?.failures || [];
35
+ const next = data?.schedules?.nextTriggers || [];
36
+
37
+ const templateRows = templates
38
+ .slice(0, 10)
39
+ .map(
40
+ (row) =>
41
+ `<tr><td>${escapeHtml(row.templateName || row.templateId)}</td>` +
42
+ `<td>${row.runs}</td><td>${Math.round((row.successRate || 0) * 100)}%</td>` +
43
+ `<td>${row.avgTokens || 0}</td></tr>`,
44
+ )
45
+ .join("");
46
+
47
+ const failureRows = failures
48
+ .slice(0, 10)
49
+ .map(
50
+ (row) =>
51
+ `<tr><td>${escapeHtml(row.templateName || row.templateId)}</td>` +
52
+ `<td>${row.failureCount}</td>` +
53
+ `<td>${escapeHtml((row.commonSummaries?.[0]?.summary || "—").slice(0, 80))}</td></tr>`,
54
+ )
55
+ .join("");
56
+
57
+ const nextRows = next
58
+ .map(
59
+ (n) =>
60
+ `<tr><td>${escapeHtml(n.at)}</td><td>${escapeHtml(n.cron)}</td><td>${escapeHtml(n.scheduleId || "—")}</td></tr>`,
61
+ )
62
+ .join("");
63
+
64
+ return `<!DOCTYPE html>
65
+ <html lang="zh-CN">
66
+ <head>
67
+ <meta charset="UTF-8">
68
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
69
+ <title>Cowork Observe — ChainlessChain</title>
70
+ <script>window.__COWORK_OBSERVE__ = ${safeJson};</script>
71
+ <style>
72
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 20px; background: #0e1116; color: #e6edf3; }
73
+ h1 { margin: 0 0 8px; font-size: 22px; }
74
+ h2 { margin: 24px 0 8px; font-size: 16px; color: #9ec5ff; }
75
+ .meta { color: #7d8590; font-size: 13px; }
76
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 16px 0; }
77
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 14px; }
78
+ .card .num { font-size: 24px; font-weight: 600; color: #58a6ff; }
79
+ .card .lbl { color: #7d8590; font-size: 12px; margin-top: 4px; }
80
+ table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
81
+ th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #30363d; font-size: 13px; }
82
+ th { background: #1f242c; color: #9ec5ff; font-weight: 500; }
83
+ tr:last-child td { border-bottom: none; }
84
+ .empty { color: #7d8590; font-style: italic; padding: 12px 0; }
85
+ </style>
86
+ </head>
87
+ <body>
88
+ <h1>Cowork Observe</h1>
89
+ <div class="meta">Window: ${escapeHtml(win.days || 7)} days · ${escapeHtml(win.from || "")} → ${escapeHtml(win.to || "")}</div>
90
+ <div class="cards">
91
+ <div class="card"><div class="num">${t.total || 0}</div><div class="lbl">Tasks</div></div>
92
+ <div class="card"><div class="num">${Math.round((t.successRate || 0) * 100)}%</div><div class="lbl">Success rate</div></div>
93
+ <div class="card"><div class="num">${t.failed || 0}</div><div class="lbl">Failed</div></div>
94
+ <div class="card"><div class="num">${t.avgTokens || 0}</div><div class="lbl">Avg tokens</div></div>
95
+ <div class="card"><div class="num">${data?.schedules?.active || 0}</div><div class="lbl">Active schedules</div></div>
96
+ </div>
97
+
98
+ <h2>Templates (top 10 by runs)</h2>
99
+ ${templates.length ? `<table><thead><tr><th>Template</th><th>Runs</th><th>Success</th><th>Avg tokens</th></tr></thead><tbody>${templateRows}</tbody></table>` : `<div class="empty">No template runs in window.</div>`}
100
+
101
+ <h2>Failures (top 10)</h2>
102
+ ${failures.length ? `<table><thead><tr><th>Template</th><th>Count</th><th>Common summary</th></tr></thead><tbody>${failureRows}</tbody></table>` : `<div class="empty">No failures in window — nice.</div>`}
103
+
104
+ <h2>Next scheduled triggers</h2>
105
+ ${next.length ? `<table><thead><tr><th>At</th><th>Cron</th><th>Schedule</th></tr></thead><tbody>${nextRows}</tbody></table>` : `<div class="empty">No upcoming triggers.</div>`}
106
+ </body>
107
+ </html>`;
108
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Cowork Observe — aggregate view over Cowork task/workflow/schedule history.
3
+ *
4
+ * Produces a single snapshot combining:
5
+ * - task history (from F9 learning layer)
6
+ * - workflow run history (from F6)
7
+ * - active schedules + next fire times (from F5 cron)
8
+ *
9
+ * Pure + `_deps`-injected for testability. Reads files, never writes.
10
+ *
11
+ * @module cowork-observe
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import {
17
+ loadHistory,
18
+ computeTemplateStats,
19
+ summarizeFailures,
20
+ } from "./cowork-learning.js";
21
+ import { loadSchedules, parseCron } from "./cowork-cron.js";
22
+
23
+ export const _deps = {
24
+ existsSync,
25
+ readFileSync,
26
+ now: () => new Date(),
27
+ };
28
+
29
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
30
+
31
+ function _parseTs(s) {
32
+ if (!s) return 0;
33
+ const t = Date.parse(s);
34
+ return Number.isFinite(t) ? t : 0;
35
+ }
36
+
37
+ function _loadWorkflowHistory(cwd, cutoffMs) {
38
+ const file = join(cwd, ".chainlesschain", "cowork", "workflow-history.jsonl");
39
+ if (!_deps.existsSync(file)) return [];
40
+ const raw = _deps.readFileSync(file, "utf-8");
41
+ const out = [];
42
+ for (const line of raw.split("\n")) {
43
+ const trimmed = line.trim();
44
+ if (!trimmed) continue;
45
+ try {
46
+ const rec = JSON.parse(trimmed);
47
+ if (_parseTs(rec.startedAt || rec.timestamp) >= cutoffMs) out.push(rec);
48
+ } catch (_e) {
49
+ // skip malformed
50
+ }
51
+ }
52
+ // Most recent first, top 10
53
+ out.sort(
54
+ (a, b) =>
55
+ _parseTs(b.startedAt || b.timestamp) -
56
+ _parseTs(a.startedAt || a.timestamp),
57
+ );
58
+ return out.slice(0, 10);
59
+ }
60
+
61
+ /**
62
+ * Compute the next N fire times across all enabled schedules.
63
+ * Probes minute-by-minute from `from` for up to `maxMinutesAhead` (default 7d).
64
+ *
65
+ * Exported for testability.
66
+ */
67
+ export function _computeNextTriggers(schedules, from, limit = 5) {
68
+ const enabled = (schedules || []).filter((s) => s && s.enabled !== false);
69
+ if (enabled.length === 0) return [];
70
+ const matchers = [];
71
+ for (const s of enabled) {
72
+ try {
73
+ matchers.push({ id: s.id, cron: s.cron, match: parseCron(s.cron) });
74
+ } catch (_e) {
75
+ // skip invalid cron
76
+ }
77
+ }
78
+ if (matchers.length === 0) return [];
79
+
80
+ const out = [];
81
+ const MAX_MINUTES = 60 * 24 * 7; // 1 week window is enough for typical cadences
82
+ const start = new Date(from);
83
+ start.setSeconds(0, 0);
84
+ start.setMinutes(start.getMinutes() + 1); // first candidate = next whole minute
85
+ const cursor = new Date(start);
86
+ for (let i = 0; i < MAX_MINUTES && out.length < limit; i++) {
87
+ for (const m of matchers) {
88
+ if (m.match(cursor)) {
89
+ out.push({
90
+ scheduleId: m.id,
91
+ cron: m.cron,
92
+ at: new Date(cursor).toISOString(),
93
+ });
94
+ if (out.length >= limit) break;
95
+ }
96
+ }
97
+ cursor.setMinutes(cursor.getMinutes() + 1);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // ─── Main aggregate ──────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Aggregate all Cowork state for the given window.
106
+ *
107
+ * @param {string} cwd
108
+ * @param {object} [options]
109
+ * @param {number} [options.windowDays=7]
110
+ * @returns {{
111
+ * window: { days: number, from: string, to: string },
112
+ * tasks: { total, completed, failed, successRate, avgTokens },
113
+ * templates: Array, failures: Array, workflows: Array,
114
+ * schedules: { active: number, nextTriggers: Array },
115
+ * }}
116
+ */
117
+ export function aggregate(cwd, { windowDays = 7 } = {}) {
118
+ const now = _deps.now();
119
+ const cutoff = now.getTime() - windowDays * 86400_000;
120
+ const allHistory = loadHistory(cwd);
121
+ const history = allHistory.filter((r) => _parseTs(r.timestamp) >= cutoff);
122
+
123
+ const total = history.length;
124
+ const completed = history.filter((r) => r.status === "completed").length;
125
+ const failed = history.filter((r) => r.status === "failed").length;
126
+ let tokenSum = 0;
127
+ let tokenCount = 0;
128
+ for (const r of history) {
129
+ const t = Number(r.result?.tokenCount || 0);
130
+ if (t > 0) {
131
+ tokenSum += t;
132
+ tokenCount += 1;
133
+ }
134
+ }
135
+
136
+ const schedules = loadSchedules(cwd);
137
+ const activeSchedules = schedules.filter((s) => s && s.enabled !== false);
138
+
139
+ return {
140
+ window: {
141
+ days: windowDays,
142
+ from: new Date(cutoff).toISOString(),
143
+ to: now.toISOString(),
144
+ },
145
+ tasks: {
146
+ total,
147
+ completed,
148
+ failed,
149
+ successRate: total > 0 ? +(completed / total).toFixed(3) : 0,
150
+ avgTokens: tokenCount > 0 ? Math.round(tokenSum / tokenCount) : 0,
151
+ },
152
+ templates: computeTemplateStats(history),
153
+ failures: summarizeFailures(history),
154
+ workflows: _loadWorkflowHistory(cwd, cutoff),
155
+ schedules: {
156
+ active: activeSchedules.length,
157
+ nextTriggers: _computeNextTriggers(schedules, now, 5),
158
+ },
159
+ };
160
+ }
@@ -17,11 +17,12 @@
17
17
 
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
- import { createHash } from "node:crypto";
20
+ import crypto, { createHash } from "node:crypto";
21
21
  import {
22
22
  toShareableTemplate,
23
23
  saveUserTemplate,
24
24
  } from "./cowork-template-marketplace.js";
25
+ import { generateDID } from "./did-manager.js";
25
26
 
26
27
  export const _deps = {
27
28
  existsSync,
@@ -31,6 +32,8 @@ export const _deps = {
31
32
  now: () => new Date().toISOString(),
32
33
  };
33
34
 
35
+ const SUPPORTED_SIG_ALG = "Ed25519";
36
+
34
37
  const PACKET_VERSION = 1;
35
38
  const PACKET_KINDS = ["template", "result"];
36
39
 
@@ -62,7 +65,13 @@ function sha256Hex(s) {
62
65
  * filled with createdAt/cliVersion defaults; checksum is computed over the
63
66
  * canonical form of `{ kind, version, payload, meta }`.
64
67
  */
65
- export function buildPacket({ kind, payload, author, cliVersion } = {}) {
68
+ export function buildPacket({
69
+ kind,
70
+ payload,
71
+ author,
72
+ cliVersion,
73
+ signer,
74
+ } = {}) {
66
75
  if (!PACKET_KINDS.includes(kind)) {
67
76
  throw new Error(`kind must be one of ${PACKET_KINDS.join(", ")}`);
68
77
  }
@@ -76,7 +85,74 @@ export function buildPacket({ kind, payload, author, cliVersion } = {}) {
76
85
  };
77
86
  const body = { kind, version: PACKET_VERSION, payload, meta };
78
87
  const checksum = sha256Hex(canonicalize(body));
79
- return { ...body, checksum };
88
+ const packet = { ...body, checksum };
89
+ if (signer) {
90
+ packet.signature = _signBody(body, signer);
91
+ }
92
+ return packet;
93
+ }
94
+
95
+ // ─── Signatures (Ed25519) ────────────────────────────────────────────────────
96
+
97
+ function _signBody(body, signer) {
98
+ if (!signer.did || typeof signer.did !== "string") {
99
+ throw new Error("signer.did required");
100
+ }
101
+ if (!signer.privateKey || !signer.publicKey) {
102
+ throw new Error("signer.privateKey and signer.publicKey required (hex)");
103
+ }
104
+ if (signer.alg && signer.alg !== SUPPORTED_SIG_ALG) {
105
+ throw new Error(`unsupported signature alg: ${signer.alg}`);
106
+ }
107
+ // Verify did matches publicKey (prevents accidental DID spoofing in signer)
108
+ const expectedDid = generateDID(signer.publicKey);
109
+ if (signer.did !== expectedDid) {
110
+ throw new Error(
111
+ `signer.did does not match publicKey (expected ${expectedDid})`,
112
+ );
113
+ }
114
+ const privKey = crypto.createPrivateKey({
115
+ key: Buffer.from(signer.privateKey, "hex"),
116
+ format: "der",
117
+ type: "pkcs8",
118
+ });
119
+ const bytes = Buffer.from(canonicalize(body), "utf-8");
120
+ const sig = crypto.sign(null, bytes, privKey);
121
+ return {
122
+ alg: SUPPORTED_SIG_ALG,
123
+ did: signer.did,
124
+ publicKey: signer.publicKey,
125
+ sig: sig.toString("base64url").replace(/=+$/, ""),
126
+ };
127
+ }
128
+
129
+ function _verifySignature(body, signature) {
130
+ if (!signature || typeof signature !== "object") {
131
+ return { valid: false, error: "signature missing" };
132
+ }
133
+ if (signature.alg !== SUPPORTED_SIG_ALG) {
134
+ return { valid: false, error: `unsupported alg '${signature.alg}'` };
135
+ }
136
+ if (!signature.did || !signature.publicKey || !signature.sig) {
137
+ return { valid: false, error: "signature fields incomplete" };
138
+ }
139
+ const expectedDid = generateDID(signature.publicKey);
140
+ if (signature.did !== expectedDid) {
141
+ return { valid: false, error: "did does not match embedded publicKey" };
142
+ }
143
+ try {
144
+ const pubKey = crypto.createPublicKey({
145
+ key: Buffer.from(signature.publicKey, "hex"),
146
+ format: "der",
147
+ type: "spki",
148
+ });
149
+ const bytes = Buffer.from(canonicalize(body), "utf-8");
150
+ const sigBytes = Buffer.from(signature.sig, "base64url");
151
+ const ok = crypto.verify(null, bytes, pubKey, sigBytes);
152
+ return { valid: ok, error: ok ? null : "signature invalid" };
153
+ } catch (err) {
154
+ return { valid: false, error: `signature verify error: ${err.message}` };
155
+ }
80
156
  }
81
157
 
82
158
  /**
@@ -103,12 +179,18 @@ export function verifyPacket(packet) {
103
179
  if (!packet.checksum) errors.push("checksum missing");
104
180
  if (errors.length > 0) return { valid: false, errors };
105
181
 
106
- const { checksum, ...body } = packet;
182
+ const { checksum, signature, ...body } = packet;
107
183
  const expected = sha256Hex(canonicalize(body));
108
184
  if (expected !== checksum) {
109
185
  errors.push("checksum mismatch (packet may be corrupted or tampered with)");
110
186
  }
111
- return { valid: errors.length === 0, errors };
187
+ if (signature !== undefined) {
188
+ const sigRes = _verifySignature(body, signature);
189
+ if (!sigRes.valid) {
190
+ errors.push(sigRes.error);
191
+ }
192
+ }
193
+ return { valid: errors.length === 0, errors, signed: !!signature };
112
194
  }
113
195
 
114
196
  // ─── Higher-level helpers ────────────────────────────────────────────────────
@@ -117,16 +199,22 @@ export function verifyPacket(packet) {
117
199
  * Build a packet from a full Cowork template object. The template is reduced
118
200
  * to its shareable fields first.
119
201
  */
120
- export function exportTemplatePacket(template, { author, cliVersion } = {}) {
202
+ export function exportTemplatePacket(
203
+ template,
204
+ { author, cliVersion, signer } = {},
205
+ ) {
121
206
  const payload = toShareableTemplate(template);
122
- return buildPacket({ kind: "template", payload, author, cliVersion });
207
+ return buildPacket({ kind: "template", payload, author, cliVersion, signer });
123
208
  }
124
209
 
125
210
  /**
126
211
  * Build a packet from a history record (one line of history.jsonl).
127
212
  * Irrelevant internal fields are dropped.
128
213
  */
129
- export function exportResultPacket(historyRecord, { author, cliVersion } = {}) {
214
+ export function exportResultPacket(
215
+ historyRecord,
216
+ { author, cliVersion, signer } = {},
217
+ ) {
130
218
  if (!historyRecord || typeof historyRecord !== "object") {
131
219
  throw new Error("historyRecord required");
132
220
  }
@@ -139,7 +227,7 @@ export function exportResultPacket(historyRecord, { author, cliVersion } = {}) {
139
227
  timestamp: historyRecord.timestamp,
140
228
  result: historyRecord.result,
141
229
  };
142
- return buildPacket({ kind: "result", payload, author, cliVersion });
230
+ return buildPacket({ kind: "result", payload, author, cliVersion, signer });
143
231
  }
144
232
 
145
233
  /**
@@ -175,7 +263,8 @@ export function writePacket(filePath, packet) {
175
263
  /**
176
264
  * Read + verify a packet from disk. Throws on verification failure.
177
265
  */
178
- export function readPacket(filePath) {
266
+ export function readPacket(filePath, opts = {}) {
267
+ const { requireSigned = false, trustedDids = null } = opts;
179
268
  if (!_deps.existsSync(filePath)) {
180
269
  throw new Error(`Packet not found: ${filePath}`);
181
270
  }
@@ -188,6 +277,21 @@ export function readPacket(filePath) {
188
277
  }
189
278
  const { valid, errors } = verifyPacket(packet);
190
279
  if (!valid) throw new Error(`Invalid packet: ${errors.join("; ")}`);
280
+ if (requireSigned && !packet.signature) {
281
+ throw new Error(
282
+ "Invalid packet: signature required but packet is unsigned",
283
+ );
284
+ }
285
+ if (
286
+ Array.isArray(trustedDids) &&
287
+ trustedDids.length > 0 &&
288
+ packet.signature &&
289
+ !trustedDids.includes(packet.signature.did)
290
+ ) {
291
+ throw new Error(
292
+ `Invalid packet: signer ${packet.signature.did} not in trusted list`,
293
+ );
294
+ }
191
295
  return packet;
192
296
  }
193
297