shadok-ai 0.17.0 → 0.17.2

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 (67) hide show
  1. package/README.md +56 -0
  2. package/context/agents-skill/SKILL.md +32 -0
  3. package/context/agents-skill/pilotctl.mjs +49 -1
  4. package/context/agents-skill/test/mock-server.mjs +21 -7
  5. package/context/agents-skill/test/talk.test.mjs +48 -0
  6. package/context/ledger-skill/SKILL.md +15 -0
  7. package/context/ledger-skill/ledger-core.d.mts +2 -0
  8. package/context/ledger-skill/ledger-core.mjs +19 -1
  9. package/context/ledger-skill/ledger.mjs +135 -45
  10. package/dist/accounts.d.ts +16 -0
  11. package/dist/accounts.js +39 -0
  12. package/dist/accounts.js.map +1 -1
  13. package/dist/capability.d.ts +43 -0
  14. package/dist/capability.js +62 -0
  15. package/dist/capability.js.map +1 -0
  16. package/dist/channels.d.ts +24 -5
  17. package/dist/channels.js +68 -6
  18. package/dist/channels.js.map +1 -1
  19. package/dist/claude-bin.d.ts +13 -0
  20. package/dist/claude-bin.js +22 -0
  21. package/dist/claude-bin.js.map +1 -1
  22. package/dist/extract.d.ts +5 -5
  23. package/dist/extract.js +11 -11
  24. package/dist/extract.js.map +1 -1
  25. package/dist/first-agent.d.ts +9 -1
  26. package/dist/first-agent.js +8 -2
  27. package/dist/first-agent.js.map +1 -1
  28. package/dist/groups.d.ts +323 -0
  29. package/dist/groups.js +511 -0
  30. package/dist/groups.js.map +1 -0
  31. package/dist/ledger.d.ts +66 -2
  32. package/dist/ledger.js +78 -3
  33. package/dist/ledger.js.map +1 -1
  34. package/dist/migrate-groups.d.ts +161 -0
  35. package/dist/migrate-groups.js +189 -0
  36. package/dist/migrate-groups.js.map +1 -0
  37. package/dist/osusers.d.ts +316 -0
  38. package/dist/osusers.js +497 -0
  39. package/dist/osusers.js.map +1 -0
  40. package/dist/secrets.d.ts +177 -17
  41. package/dist/secrets.js +327 -32
  42. package/dist/secrets.js.map +1 -1
  43. package/dist/server.d.ts +28 -0
  44. package/dist/server.js +1627 -79
  45. package/dist/server.js.map +1 -1
  46. package/dist/session.d.ts +39 -0
  47. package/dist/session.js +39 -2
  48. package/dist/session.js.map +1 -1
  49. package/dist/setup-token.d.ts +71 -0
  50. package/dist/setup-token.js +239 -0
  51. package/dist/setup-token.js.map +1 -0
  52. package/dist/tail.d.ts +10 -3
  53. package/dist/tail.js +13 -6
  54. package/dist/tail.js.map +1 -1
  55. package/dist/talk.d.ts +57 -0
  56. package/dist/talk.js +72 -0
  57. package/dist/talk.js.map +1 -0
  58. package/dist/telegram.d.ts +1 -1
  59. package/dist/telegram.js +39 -3
  60. package/dist/telegram.js.map +1 -1
  61. package/dist/tmux.d.ts +58 -0
  62. package/dist/tmux.js +81 -4
  63. package/dist/tmux.js.map +1 -1
  64. package/package.json +1 -1
  65. package/public/auth-expiry.js +28 -0
  66. package/public/index.html +942 -115
  67. package/public/secrets-panel.js +119 -0
package/README.md CHANGED
@@ -434,6 +434,62 @@ supplies the reaper. And either way it is decided when the container is
434
434
  **created**: `docker restart` empties the table — pid 1 restarts, the kernel
435
435
  drops the lot — but the leak starts over, so a recreate is what makes it stop.
436
436
 
437
+ ### Groups and deployment modes
438
+
439
+ An agent lives in a **group**, and a group is both what is isolated and who can
440
+ see it:
441
+
442
+ - **Private** — only you. One per person, sealed, invisible even to an admin.
443
+ - **Restricted** — your team: a member list, any member can invite.
444
+ - **Public** — everyone. `Main` is the default public group.
445
+
446
+ You see and drive only your own groups plus the public ones; that covers the
447
+ agent list, secrets, the shared ledger and messaging between agents. Private and
448
+ restricted groups are real walls: each agent runs as its **own OS user** with its
449
+ own home and secrets, never as root. Crossing a wall is always a deliberate act:
450
+ drop a file in the shared `~/shared/` folder, record a ledger line with
451
+ `--public`, push a branch, or message an agent (public groups freely, restricted
452
+ ones only with a handle they gave you; private groups cannot be messaged).
453
+
454
+ Real isolation needs two things, checked once at boot:
455
+
456
+ 1. the server can create OS users (it runs as root or has passwordless `sudo` —
457
+ the official Docker image or a VPS deploy), **and**
458
+ 2. a GUI password is set (`SHADOK_GUI_PASSWORD`), because accounts are who the
459
+ walls protect.
460
+
461
+ Both → **multi-tenant**. Anything else — `npx shadok-ai` on your machine, Docker
462
+ without the password — stays **single-tenant**, exactly as before: it says so and
463
+ refuses to create a private or restricted group rather than drawing one that
464
+ protects nothing. You don't configure this; it follows from how you run it. The
465
+ boot log prints `deployment mode: …`.
466
+
467
+ An existing single-tenant instance that comes up capable is migrated
468
+ automatically: what you have becomes the public `Main` group, each account gets
469
+ a private group, and agents restart one at a time. A banner shows progress. Once
470
+ migration completes, private and restricted agents run as their own non-root
471
+ user, and `Main` agents run as a shared non-root `main` user. Until migration
472
+ completes (or if it fails), agents keep running as before, so the non-root
473
+ guarantee does not yet hold for them. On a **brand-new** instance one migration
474
+ step — seeding the shared Claude credential — can only run once you have signed
475
+ in to Claude, which happens after boot; the banner stays up until then and the
476
+ migration **finishes on its own the moment you sign in** (no restart needed).
477
+ **One-time provisioning:** isolated-group agents authenticate with a
478
+ subscription token injected into their environment, not a shared credential
479
+ file. Until it is set, group spawns are refused with a message naming this step,
480
+ and the cockpit shows a red "no subscription token" banner. Provide it from the
481
+ cockpit itself — **no shell needed**: as an admin, click **Provide token** on
482
+ that banner (or **Replace subscription token…** in the version menu to rotate
483
+ it), authorise on your Claude subscription in the browser, and paste the code
484
+ back; the server mints and stores it. From Telegram, `/mttoken` then
485
+ `/mtcode <code>` do the same. The token is captured and vaulted server-side and
486
+ is never shown. It is valid for about a year; re-run the card (or `/mttoken`)
487
+ before it expires. The shell path still works as a fallback: `claude setup-token`
488
+ and store the result as the vault secret `MT_CLAUDE_OAUTH_TOKEN`.
489
+
490
+ Telegram stays single-owner, and there are no
491
+ disk quotas yet. Design: `docs/superpowers/specs/2026-10-29-multi-tenant-scoped-isolation-design.md`.
492
+
437
493
  ### Exposing it beyond this machine
438
494
 
439
495
  The cockpit runs arbitrary commands on the host by design, so it binds
@@ -31,6 +31,8 @@ and automatically starts the shadok-ai server when it is not running (port
31
31
  | `stop <id>` | ends the session (for ALL its clients) |
32
32
  | `screen <id>` | raw TUI screen (debug) |
33
33
  | `profile-prompt "<text>" [--name NAME] [--readonly]` | rewrites a profile's **system prompt**: your own by default; any of them (and creation with `--name`) under the lead profile |
34
+ | `talk-handle` | prints **your own** handle — the "here is a handle to me" you share so another agent can send you a letter → `{handle}` |
35
+ | `talk <handle> "message"` | sends a one-way **letter** to the agent that handle points to → `{ok, delivered}`. A letter, NOT a drive: it is read in the recipient's own context and you get no transcript back (see below) |
34
36
 
35
37
  ## Choosing a profile (`--profile`)
36
38
 
@@ -113,6 +115,36 @@ time, and the information comes to you.
113
115
  `--parent none` spawns an agent deliberately unattached; `--parent <id>`
114
116
  attaches it elsewhere.
115
117
 
118
+ ## Talking across groups: a letter, not a remote control (`talk`)
119
+
120
+ When agents belong to different **groups** (teams sharing one cockpit), you
121
+ cannot `prompt`/`drive` another group's agent — see/drive stay to your own group
122
+ and to public ones. `talk` is the one narrow, consensual exception: you send a
123
+ one-way **letter** the recipient handles in ITS OWN context, under its own
124
+ guardrails. You never receive its transcript, stream or files back — `talk`
125
+ returns only that the letter was accepted and delivered.
126
+
127
+ The ACL, enforced server-side:
128
+
129
+ - an agent in a **public** group — messageable freely, no handle needed;
130
+ - an agent in another **restricted** group — ONLY if you hold its **handle** (an
131
+ opaque token it shared with you). There is no roster to browse: you can only
132
+ write to an agent whose handle reached you out of band;
133
+ - an agent in a **private** group — **sealed inbound**: no letter gets in, even
134
+ with a handle. (But a private-group agent may still send OUTWARD.)
135
+
136
+ To let someone write to you, give them your handle:
137
+
138
+ ```bash
139
+ node ~/.claude/skills/shadok-ai-agents/pilotctl.mjs talk-handle # → {"handle":"…"}
140
+ # share that string (a human or a prompt relays it), then they run:
141
+ node ~/.claude/skills/shadok-ai-agents/pilotctl.mjs talk <handle> "please review PR 42"
142
+ ```
143
+
144
+ There is no automatic reply channel — if you want an answer, include YOUR handle
145
+ inside the message. (In mono-tenant / single-team cockpits everything is one
146
+ public commons, so `talk` is simply unrestricted.)
147
+
116
148
  ## Being prompted BY another agent
117
149
 
118
150
  The other direction: a prompt from an agent arrives under a context header
@@ -567,6 +567,50 @@ async function cmdProfilePrompt(text, flags) {
567
567
  return out;
568
568
  }
569
569
 
570
+ // Cross-group TALK: send another agent a one-way LETTER, the sanctioned
571
+ // server-mediated channel (there is no native SendMessage). This is NOT a drive:
572
+ // the recipient reads the message in its OWN context under its own guardrails,
573
+ // and you never receive its transcript/stream back — the call returns only that
574
+ // the letter was accepted and dispatched.
575
+ //
576
+ // ACL (enforced server-side): a PUBLIC-group agent is messageable freely; an
577
+ // agent in another RESTRICTED group only if you hold its HANDLE (an opaque token
578
+ // it shared — there is no roster to browse); a PRIVATE group is SEALED inbound.
579
+ // A private-group agent may still send OUTWARD.
580
+ //
581
+ // `handle` identifies the recipient. Get your OWN handle to share with
582
+ // `pilotctl talk-handle`; obtaining someone else's is out of band (a human or a
583
+ // prompt relays it) — pilotctl deliberately offers no way to enumerate handles.
584
+ async function cmdTalk(handle, message) {
585
+ const key = process.env.SHADOK_SESSION_KEY;
586
+ if (!key) throw new Error("no SHADOK_SESSION_KEY in env — not running as a piloted agent");
587
+ if (!handle) throw new Error("usage: pilotctl talk <handle> <message>");
588
+ if (!message || !message.trim()) throw new Error("a message is required");
589
+ const r = await fetch(`${httpBase()}/talk`, {
590
+ method: "POST",
591
+ headers: { "Content-Type": "application/json", ...authHeaders() },
592
+ body: JSON.stringify({ key, handle, message }),
593
+ });
594
+ const out = await r.json();
595
+ if (!r.ok) throw new Error(out.error ?? `HTTP ${r.status}`);
596
+ return out;
597
+ }
598
+
599
+ // Your OWN talk handle — the "here is a handle to me" an agent shares so another
600
+ // agent can send it a letter. Scoped to yourself by the session key: there is no
601
+ // way to mint or look up someone else's.
602
+ async function cmdTalkHandle() {
603
+ const key = process.env.SHADOK_SESSION_KEY;
604
+ if (!key) throw new Error("no SHADOK_SESSION_KEY in env — not running as a piloted agent");
605
+ const r = await fetch(`${httpBase()}/talk/handle`, {
606
+ method: "GET",
607
+ headers: { ...authHeaders() },
608
+ });
609
+ const out = await r.json();
610
+ if (!r.ok) throw new Error(out.error ?? `HTTP ${r.status}`);
611
+ return out;
612
+ }
613
+
570
614
  async function cmdScreen(id, flags) {
571
615
  await ensureServer();
572
616
  const st = readState(id);
@@ -579,7 +623,7 @@ async function cmdScreen(id, flags) {
579
623
  }
580
624
 
581
625
  const HELP =
582
- "usage: pilotctl <spawn|prompt|dialog|choose|toggle|confirm|freetext|list|diff|stop|screen|profile-prompt|peer> …\n" +
626
+ "usage: pilotctl <spawn|prompt|dialog|choose|toggle|confirm|freetext|list|diff|stop|screen|profile-prompt|talk|talk-handle|peer> …\n" +
583
627
  " add --peer <alias> to any command to run it against another instance's agents\n" +
584
628
  " pilotctl peer <add <alias> <url> <ticket> [--token]|list|rm <alias>>";
585
629
 
@@ -685,6 +729,10 @@ export async function run(argv) {
685
729
  return cmdScreen(pos[0], flags);
686
730
  case "profile-prompt":
687
731
  return cmdProfilePrompt(pos[0], flags);
732
+ case "talk":
733
+ return cmdTalk(pos[0], pos[1]);
734
+ case "talk-handle":
735
+ return cmdTalkHandle();
688
736
  default:
689
737
  throw new Error(HELP);
690
738
  }
@@ -8,15 +8,28 @@ export function startMockServer(script = {}) {
8
8
  // Cookie headers seen on HTTP requests and on the WS upgrade — lets a test
9
9
  // assert pilotctl presents the SHADOK_AUTH cookie when a password is set.
10
10
  const cookies = { http: [], ws: [] };
11
+ // Per-route HTTP requests seen, with their parsed JSON body and the session-key
12
+ // header — lets a test assert what pilotctl actually sent (e.g. `talk`).
13
+ const http_ = [];
11
14
  const app = http.createServer((req, res) => {
12
15
  if (req.headers.cookie) cookies.http.push(req.headers.cookie);
13
- res.setHeader("content-type", "application/json");
14
- if (req.url.startsWith("/sessions")) return res.end(JSON.stringify(script.sessions ?? []));
15
- if (req.url.startsWith("/diff"))
16
- return res.end(
17
- JSON.stringify(script.diff ?? { status: "", diff: "", branch: null, error: "no such session" }),
18
- );
19
- res.end("{}");
16
+ const chunks = [];
17
+ req.on("data", (c) => chunks.push(c));
18
+ req.on("end", () => {
19
+ const raw = Buffer.concat(chunks).toString("utf8");
20
+ let body;
21
+ try { body = raw ? JSON.parse(raw) : undefined; } catch { body = raw; }
22
+ http_.push({ method: req.method, url: req.url, body, sessionKey: req.headers["x-shadok-session-key"] });
23
+ res.setHeader("content-type", "application/json");
24
+ if (req.url.startsWith("/sessions")) return res.end(JSON.stringify(script.sessions ?? []));
25
+ if (req.url.startsWith("/diff"))
26
+ return res.end(
27
+ JSON.stringify(script.diff ?? { status: "", diff: "", branch: null, error: "no such session" }),
28
+ );
29
+ if (req.url.startsWith("/talk/handle")) return res.end(JSON.stringify(script.talkHandle ?? { handle: "H" }));
30
+ if (req.url.startsWith("/talk")) return res.end(JSON.stringify(script.talk ?? { ok: true, delivered: "abcd" }));
31
+ res.end("{}");
32
+ });
20
33
  });
21
34
  const wss = new WebSocketServer({ server: app, path: "/ws" });
22
35
  const received = [];
@@ -33,6 +46,7 @@ export function startMockServer(script = {}) {
33
46
  resolve({
34
47
  port: app.address().port,
35
48
  received,
49
+ http: http_,
36
50
  cookies,
37
51
  close: () => new Promise((r) => { wss.close(); app.close(r); }),
38
52
  }),
@@ -0,0 +1,48 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { startMockServer } from "./mock-server.mjs";
7
+
8
+ process.env.SHADOK_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pilotctl-talk-"));
9
+ process.env.SHADOK_NO_HOLDER = "1";
10
+ process.env.SHADOK_NO_AUTOSTART = "1";
11
+ const { run } = await import("../pilotctl.mjs");
12
+
13
+ test("talk without a session key is refused (not running as a piloted agent)", async () => {
14
+ delete process.env.SHADOK_SESSION_KEY;
15
+ await assert.rejects(() => run(["talk", "H", "hi"]), /SHADOK_SESSION_KEY/);
16
+ });
17
+
18
+ test("talk POSTs the session key, handle and message, and returns the server's reply", async () => {
19
+ const mock = await startMockServer({ talk: { ok: true, delivered: "ef01" } });
20
+ process.env.SHADOK_PORT = String(mock.port);
21
+ process.env.SHADOK_SESSION_KEY = "sk-key-123";
22
+ try {
23
+ const r = await run(["talk", "handle-xyz", "please review PR 42"]);
24
+ assert.deepEqual(r, { ok: true, delivered: "ef01" });
25
+ const call = mock.http.find((h) => h.url === "/talk" && h.method === "POST");
26
+ assert.ok(call, "pilotctl did not POST /talk");
27
+ assert.equal(call.sessionKey, "sk-key-123", "the session key header must be sent");
28
+ assert.equal(call.body.handle, "handle-xyz");
29
+ assert.equal(call.body.message, "please review PR 42");
30
+ } finally {
31
+ await mock.close();
32
+ }
33
+ });
34
+
35
+ test("talk-handle returns the caller's own handle", async () => {
36
+ const mock = await startMockServer({ talkHandle: { handle: "my-handle" } });
37
+ process.env.SHADOK_PORT = String(mock.port);
38
+ process.env.SHADOK_SESSION_KEY = "sk-key-123";
39
+ try {
40
+ const r = await run(["talk-handle"]);
41
+ assert.deepEqual(r, { handle: "my-handle" });
42
+ const call = mock.http.find((h) => h.url.startsWith("/talk/handle"));
43
+ assert.ok(call, "pilotctl did not GET /talk/handle");
44
+ assert.equal(call.sessionKey, "sk-key-123");
45
+ } finally {
46
+ await mock.close();
47
+ }
48
+ });
@@ -115,6 +115,21 @@ node ~/.claude/skills/shadok-ledger/ledger.mjs record \
115
115
  --id <id> --status resolved --note "<one line>"
116
116
  ```
117
117
 
118
+ ## Who sees a row: your group by default, everyone with `--public`
119
+
120
+ On an instance with several groups, a row you record is **private to your
121
+ group**: only agents of your group see it in `check`, `list` and the pushed
122
+ block. That is the safe default — forgetting a flag can never leak anything. To
123
+ share a resolution with every group, say so on purpose:
124
+
125
+ ```
126
+ … record --entity "<name>" --status resolved --public
127
+ ```
128
+
129
+ It is the twin of dropping a file into `~/shared/` to make it public. Publish
130
+ only what is meant for the other groups. `--id <id> --public` publishes an
131
+ existing row. (On a single-group instance nothing changes.)
132
+
118
133
  ## To consult the whole table
119
134
 
120
135
  ```
@@ -12,12 +12,14 @@ export interface LedgerCoreRow {
12
12
  source?: string;
13
13
  updatedAt?: number;
14
14
  verifiedAt?: number;
15
+ scope?: "group" | "public";
15
16
  }
16
17
  export type LedgerKind = "task" | "fact" | "decision";
17
18
  export const KINDS: LedgerKind[];
18
19
  export const LABEL_MAX: number;
19
20
  export function normEntity(s: unknown): string;
20
21
  export function normKind(k: unknown): LedgerKind | null;
22
+ export function normScope(sc: unknown): "group" | "public" | null;
21
23
  export function rowKind(entry: unknown): LedgerKind;
22
24
  export function resolveId<T extends LedgerCoreRow>(entries: T[], idOrPrefix: unknown): T | null;
23
25
  export function upsertEntry<T extends LedgerCoreRow>(
@@ -86,6 +86,21 @@ export function rowKind(entry) {
86
86
  return normEntity(entry?.status) === "decided" ? "decision" : "task";
87
87
  }
88
88
 
89
+ /**
90
+ * A row's SCOPE: `group` (visible to the writer's own group only) or `public`
91
+ * (the commons, every group). The DIRECTION OF THE DEFAULT is the invariant:
92
+ * absent means `group`, so forgetting a flag can only ever keep something
93
+ * private. Public-by-default would reopen the cross-group leak on the first
94
+ * oversight — publishing is a deliberate act (`--public`), the twin of dropping
95
+ * a file into `~/shared/`.
96
+ */
97
+ export function normScope(sc) {
98
+ const s = String(sc ?? "").trim().toLowerCase();
99
+ if (!s) return null;
100
+ if (s !== "group" && s !== "public") throw new Error(`unknown scope "${sc}" — use group or public`);
101
+ return s;
102
+ }
103
+
89
104
  /**
90
105
  * Upsert a row, two ways:
91
106
  * - `patch.id` present → UPDATE that exact row in place (resolved via resolveId),
@@ -114,6 +129,7 @@ export function rowKind(entry) {
114
129
  * was last observed, which is the truth and makes the migration lazy, per row.
115
130
  */
116
131
  export function upsertEntry(entries, patch, now, newId) {
132
+ const scope = normScope(patch.scope);
117
133
  const kind = normKind(patch.kind); // refuses an unknown kind before anything is written
118
134
  const statusGiven = patch.status != null && String(patch.status).trim() !== "";
119
135
 
@@ -131,7 +147,7 @@ export function upsertEntry(entries, patch, now, newId) {
131
147
  ? KIND_STATUS[kind]
132
148
  : cur.status;
133
149
  const edited =
134
- statusGiven || kind != null || patch.note != null || patch.source != null || patch.entity != null;
150
+ statusGiven || kind != null || patch.note != null || patch.source != null || patch.entity != null || scope != null;
135
151
  const observed = statusGiven || patch.verified === true || status !== cur.status;
136
152
  const row = {
137
153
  id: cur.id,
@@ -139,6 +155,7 @@ export function upsertEntry(entries, patch, now, newId) {
139
155
  patch.entity != null && String(patch.entity).trim() ? String(patch.entity).trim() : cur.entity,
140
156
  status,
141
157
  ...(effKind ? { kind: effKind } : {}),
158
+ scope: scope ?? normScope(cur.scope) ?? "group",
142
159
  ...((patch.note ?? cur.note) ? { note: String(patch.note ?? cur.note).trim() } : {}),
143
160
  ...((patch.source ?? cur.source) ? { source: String(patch.source ?? cur.source).trim() } : {}),
144
161
  updatedAt: edited ? now : (cur.updatedAt ?? now),
@@ -160,6 +177,7 @@ export function upsertEntry(entries, patch, now, newId) {
160
177
  entity: String(patch.entity).trim(),
161
178
  status,
162
179
  ...(effKind ? { kind: effKind } : {}),
180
+ scope: scope ?? normScope(existing?.scope) ?? "group",
163
181
  ...(patch.note ? { note: String(patch.note).trim() } : {}),
164
182
  ...(patch.source ? { source: String(patch.source).trim() } : {}),
165
183
  updatedAt: now,
@@ -2,34 +2,52 @@
2
2
  // shadok-ledger CLI — a tiny state table so agents verify a status before they
3
3
  // assert or act on it, and record what they resolve/decide.
4
4
  //
5
- // Store: the PER-INSTANCE ledger. The server hands each agent the exact path in
6
- // SHADOK_LEDGER_FILE at spawn (an agent's own cwd is a worktree, not the launch
7
- // dir, so it cannot derive it); a hand-run CLI with no env falls back to the
8
- // legacy global file. See SKILL.md and the design spec. No server involved.
5
+ // Store: ONE ledger table per instance, owned by the server. On an instance,
6
+ // `record`/`check`/`list` go THROUGH the server (POST /ledger/record, GET
7
+ // /ledger), authenticated with the per-session key — exactly like the
8
+ // `shadok-secrets` skill reaches the vault. The server stamps the row's
9
+ // AUTHORING GROUP from the session (never from here) and filters reads to that
10
+ // group ∪ public, so a group's private rows never leak and no write crosses the
11
+ // OS-user wall (the file lives 0600 in the server HOME).
12
+ //
13
+ // FALLBACK: with no server reachable (a hand-run CLI, or a mono-tenant instance
14
+ // with no port/key), it reads and writes the local file directly —
15
+ // SHADOK_LEDGER_FILE if the server handed one, else the legacy global file.
16
+ // Nothing about the single-group / off-server case changes.
9
17
  import fs from "node:fs";
10
18
  import os from "node:os";
11
19
  import path from "node:path";
12
20
  import crypto from "node:crypto";
13
21
  import { upsertEntry, searchEntries, ageDays, resolveId, normEntity, rowKind, KINDS, LABEL_MAX } from "./ledger-core.mjs";
14
22
 
23
+ const PORT = (process.env.SHADOK_PORT || "").trim();
24
+ const KEY = (process.env.SHADOK_SESSION_KEY || "").trim();
25
+ const AUTH = (process.env.SHADOK_AUTH || "").trim();
26
+ // On-server: a port AND a session key. The key is what proves WHICH agent calls,
27
+ // and it is what the server scopes the authoring group by — without it there is
28
+ // no identity, so we fall back to the local file.
29
+ const useServer = !!PORT && !!KEY;
30
+ const BASE = `http://127.0.0.1:${PORT}`;
31
+ const TIMEOUT_MS = 15_000;
32
+
15
33
  const FILE =
16
34
  (process.env.SHADOK_LEDGER_FILE || "").trim() ||
17
35
  path.join(os.homedir(), ".shadok-ai", "ledger.json");
18
36
 
19
- function load() {
37
+ function loadFile(file) {
20
38
  try {
21
- const j = JSON.parse(fs.readFileSync(FILE, "utf8"));
39
+ const j = JSON.parse(fs.readFileSync(file, "utf8"));
22
40
  return Array.isArray(j) ? j : [];
23
41
  } catch {
24
42
  return []; // absent or unreadable → empty table, never a crash
25
43
  }
26
44
  }
27
45
 
28
- function save(rows) {
29
- fs.mkdirSync(path.dirname(FILE), { recursive: true });
30
- const tmp = FILE + ".tmp";
46
+ function saveFile(file, rows) {
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ const tmp = file + ".tmp";
31
49
  fs.writeFileSync(tmp, JSON.stringify(rows, null, 2), { mode: 0o600 });
32
- fs.renameSync(tmp, FILE); // atomic: a concurrent reader never sees half a file
50
+ fs.renameSync(tmp, file); // atomic: a concurrent reader never sees half a file
33
51
  }
34
52
 
35
53
  /** A short, unique handle (4 hex): quotable from the pushed ledger block. */
@@ -42,9 +60,44 @@ function mintId(rows) {
42
60
  return crypto.randomBytes(4).toString("hex");
43
61
  }
44
62
 
63
+ /** Call the server. Exits with a clear message on a transport/HTTP failure —
64
+ * the same shape as the secrets skill. */
65
+ async function api(method, p, body) {
66
+ let res;
67
+ try {
68
+ res = await fetch(BASE + p, {
69
+ method,
70
+ headers: {
71
+ "content-type": "application/json",
72
+ ...(AUTH ? { Cookie: AUTH } : {}),
73
+ // The durable credential: the cookie in the env expires after a week,
74
+ // the session key does not (invariant 33).
75
+ ...(KEY ? { "x-shadok-session-key": KEY } : {}),
76
+ },
77
+ body: body === undefined ? undefined : JSON.stringify(body),
78
+ signal: AbortSignal.timeout(TIMEOUT_MS),
79
+ });
80
+ } catch (e) {
81
+ console.error(`cannot reach the cockpit on ${BASE}: ${e.message}`);
82
+ process.exit(1);
83
+ }
84
+ const raw = await res.text();
85
+ if (!res.ok) {
86
+ let msg = raw.slice(0, 300);
87
+ try {
88
+ msg = JSON.parse(raw).error ?? msg;
89
+ } catch {
90
+ /* non-JSON body — keep the raw slice */
91
+ }
92
+ console.error(`ledger API error ${res.status}: ${msg}`);
93
+ process.exit(1);
94
+ }
95
+ return raw ? JSON.parse(raw) : {};
96
+ }
97
+
45
98
  /** Flags that take NO value, listed rather than guessed: inferring "the next arg
46
99
  * starts with -- so this one is a boolean" would misread `--note "--foo"`. */
47
- const BOOL_FLAGS = new Set(["verified"]);
100
+ const BOOL_FLAGS = new Set(["verified", "public"]);
48
101
 
49
102
  /** `--key value` pairs → object. Values may be quoted by the shell already. */
50
103
  function parseFlags(args) {
@@ -70,23 +123,29 @@ function fmt(e, now) {
70
123
  // noise on every line. The two that change how a status must be READ are named.
71
124
  const tag = kind === "task" ? "" : ` · ${kind}`;
72
125
  const handle = e.id ? `[${e.id}] ` : "";
73
- const head = `• ${handle}${e.entity} — ${e.status}${tag} (${stamp}${e.source ? ` · ${e.source}` : ""})`;
126
+ const pub = e.scope === "public" ? " · public" : "";
127
+ const head = `• ${handle}${e.entity} — ${e.status}${tag}${pub} (${stamp}${e.source ? ` · ${e.source}` : ""})`;
74
128
  return e.note ? `${head}\n ${e.note}` : head;
75
129
  }
76
130
 
77
- const [cmd, ...rest] = process.argv.slice(2);
78
- const now = Date.now();
131
+ /** The rows a read works over: on-server, the server-scoped table (own group ∪
132
+ * public); off-server, the local file. */
133
+ async function visibleRows() {
134
+ if (useServer) return (await api("GET", "/ledger")).entries ?? [];
135
+ return loadFile(FILE);
136
+ }
79
137
 
80
- if (cmd === "check") {
138
+ async function doCheck(rest) {
81
139
  const q = rest.join(" ").trim();
82
140
  if (!q) {
83
141
  console.error('usage: ledger check "<topic or entity>"');
84
142
  process.exit(2);
85
143
  }
144
+ const now = Date.now();
86
145
  // Two tiers, and the difference is the whole point: `strong` answers the
87
146
  // question, `weak` merely shares words with it. Collapsing them would make a
88
147
  // loosely-related row read as a status — the opposite of what this gates.
89
- const { strong, weak } = searchEntries(load(), q);
148
+ const { strong, weak } = searchEntries(await visibleRows(), q);
90
149
  if (strong.length) {
91
150
  console.log(strong.map((e) => fmt(e, now)).join("\n"));
92
151
  } else {
@@ -99,51 +158,75 @@ if (cmd === "check") {
99
158
  console.log(weak.map((e) => fmt(e, now)).join("\n"));
100
159
  }
101
160
  }
102
- } else if (cmd === "record") {
161
+ }
162
+
163
+ async function doList() {
164
+ const now = Date.now();
165
+ const rows = searchEntries(await visibleRows(), "").strong;
166
+ console.log(rows.length ? rows.map((e) => fmt(e, now)).join("\n") : "(ledger empty)");
167
+ }
168
+
169
+ async function doRecord(rest) {
103
170
  const f = parseFlags(rest);
104
171
  // Two shapes: create/supersede by --entity (+ --status), or update an existing
105
172
  // row by its --id handle (change any of status/note/source; entity optional).
106
173
  const byId = f.id != null && String(f.id).trim();
107
174
  const idHasNoFields =
108
175
  f.status == null && f.note == null && f.source == null && f.entity == null &&
109
- f.kind == null && f.verified == null;
176
+ f.kind == null && f.verified == null && f.public == null;
110
177
  // A fact or a decision has no lifecycle, so `--kind` stands in for `--status`:
111
178
  // requiring one would only invite an invented word.
112
179
  const hasStatus = !!f.status || !!f.kind;
113
180
  if (byId ? idHasNoFields : !f.entity || !hasStatus) {
114
181
  console.error(
115
- 'usage: ledger record --entity "<name>" --status <resolved|open|in-progress|decided> [--note "<line>"] [--source "<ref>"]\n' +
182
+ 'usage: ledger record --entity "<name>" --status <resolved|open|in-progress|decided> [--note "<line>"] [--source "<ref>"] [--public]\n' +
116
183
  ` or: ledger record --entity "<name>" --kind <${KINDS.join("|")}> [--note "<line>"] [--source "<ref>"]\n` +
117
- ' or: ledger record --id <handle> [--status <…>] [--kind <…>] [--note "<line>"] [--source "<ref>"]\n' +
184
+ ' or: ledger record --id <handle> [--status <…>] [--kind <…>] [--note "<line>"] [--source "<ref>"] [--public]\n' +
118
185
  ' or: ledger record --id <handle> --verified (still true/current — nothing else changed)',
119
186
  );
120
187
  process.exit(2);
121
188
  }
122
- const rows0 = load();
123
- let rows;
124
- try {
125
- rows = upsertEntry(
126
- rows0,
127
- {
128
- id: f.id,
129
- entity: f.entity,
130
- status: f.status,
131
- kind: f.kind,
132
- verified: f.verified === true,
133
- note: f.note,
134
- source: f.source,
135
- },
136
- now,
137
- mintId(rows0),
138
- );
139
- } catch (e) {
140
- console.error(String(e?.message ?? e));
141
- process.exit(2);
189
+
190
+ const patch = {
191
+ id: f.id,
192
+ entity: f.entity,
193
+ status: f.status,
194
+ kind: f.kind,
195
+ verified: f.verified === true,
196
+ note: f.note,
197
+ source: f.source,
198
+ // Group-private unless asked: `--public` is the only way out.
199
+ public: f.public === true ? true : undefined,
200
+ };
201
+
202
+ let row;
203
+ if (useServer) {
204
+ // The server stamps the authoring group (from the session) and scopes the
205
+ // upsert to it, so a cross-group clobber is impossible by construction.
206
+ row = (await api("POST", "/ledger/record", patch)).row;
207
+ } else {
208
+ // Off-server fallback: write the local file directly (mono-tenant / hand-run).
209
+ const rows0 = loadFile(FILE);
210
+ let next;
211
+ try {
212
+ next = upsertEntry(
213
+ rows0,
214
+ { ...patch, scope: patch.public === true ? "public" : undefined },
215
+ Date.now(),
216
+ mintId(rows0),
217
+ );
218
+ } catch (e) {
219
+ console.error(String(e?.message ?? e));
220
+ process.exit(2);
221
+ }
222
+ saveFile(FILE, next);
223
+ row = byId ? resolveId(next, f.id) : next.find((r) => normEntity(r.entity) === normEntity(f.entity));
142
224
  }
143
- save(rows);
144
- const row = byId ? resolveId(rows, f.id) : rows.find((r) => normEntity(r.entity) === normEntity(f.entity));
225
+
145
226
  const k = rowKind(row);
146
- console.log(`recorded: ${row.entity} — ${row.status}${k === "task" ? "" : ` · ${k}`} [${row.id}]`);
227
+ console.log(
228
+ `recorded: ${row.entity} — ${row.status}${k === "task" ? "" : ` · ${k}`}${row.scope === "public" ? " · public" : ""} [${row.id}]`,
229
+ );
147
230
  // Say it, don't enforce it: the row is already saved (a refused write is a
148
231
  // lost fact) and the full label stays in the table. But the delta pushed to
149
232
  // siblings only carries the first LABEL_MAX characters of it, and a label
@@ -154,9 +237,16 @@ if (cmd === "check") {
154
237
  `Only the first ${LABEL_MAX} reach the ⟦ledger⟧ block your siblings are pushed.`,
155
238
  );
156
239
  }
240
+ }
241
+
242
+ const [cmd, ...rest] = process.argv.slice(2);
243
+
244
+ if (cmd === "check") {
245
+ await doCheck(rest);
246
+ } else if (cmd === "record") {
247
+ await doRecord(rest);
157
248
  } else if (cmd === "list") {
158
- const rows = searchEntries(load(), "").strong;
159
- console.log(rows.length ? rows.map((e) => fmt(e, now)).join("\n") : "(ledger empty)");
249
+ await doList();
160
250
  } else {
161
251
  console.error("usage: ledger <check|record|list> …");
162
252
  process.exit(2);
@@ -155,3 +155,19 @@ export declare function promptAuthor(origin: string | undefined, sessionName: st
155
155
  export declare function signSessionKey(sessionId: string, secret: Buffer): string;
156
156
  /** Pure: the session id a key attests to, or null if it does not verify. */
157
157
  export declare function readSessionKey(key: string, secret: Buffer): string | null;
158
+ /**
159
+ * A shareable TALK HANDLE for a session — an opaque token that lets ANOTHER
160
+ * agent address a one-way LETTER to this one (cross-group talk, `src/talk.ts`),
161
+ * and nothing more.
162
+ *
163
+ * It is DELIBERATELY NOT a session key. `signSessionKey` authenticates AS the
164
+ * agent and can DRIVE it; a handle must only ever resolve to "who to deliver a
165
+ * letter to", leaving the talk ACL to decide if the letter may pass. They share
166
+ * the id-inside-the-token shape but are DOMAIN-SEPARATED (`talk:` vs `session:`
167
+ * in the HMAC input), so a handle can never be replayed as a session key and a
168
+ * leaked session key is not a valid handle — a handle shared freely grants no
169
+ * drive capability.
170
+ */
171
+ export declare function signTalkHandle(sessionId: string, secret: Buffer): string;
172
+ /** Pure: the session id a talk handle points to, or null if it does not verify. */
173
+ export declare function readTalkHandle(handle: string, secret: Buffer): string | null;