@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.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 (101) hide show
  1. package/README.md +46 -0
  2. package/package.json +7 -4
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +39 -0
  6. package/src/commands/pulse/doctor.js +1 -0
  7. package/src/commands/pulse/import.js +62 -0
  8. package/src/commands/pulse/log-write.js +26 -0
  9. package/src/commands/pulse/parity.js +20 -0
  10. package/src/commands/pulse/shadow.js +19 -0
  11. package/src/daemon/main.js +19 -12
  12. package/src/hub/core/auth.mjs +80 -0
  13. package/src/hub/core/changes.mjs +38 -0
  14. package/src/hub/core/conversations.mjs +121 -0
  15. package/src/hub/core/db.mjs +263 -0
  16. package/src/hub/core/edgeIds.mjs +9 -0
  17. package/src/hub/core/hub.mjs +20 -0
  18. package/src/hub/core/ids.mjs +39 -0
  19. package/src/hub/core/leases.mjs +55 -0
  20. package/src/hub/core/messaging.mjs +322 -0
  21. package/src/hub/core/registry.mjs +281 -0
  22. package/src/hub/core/ticks.mjs +58 -0
  23. package/src/hub/core/wake.mjs +125 -0
  24. package/src/hub/home.mjs +16 -0
  25. package/src/hub/hooks/hub-bridge.mjs +167 -0
  26. package/src/hub/keepawake/agent.mjs +96 -0
  27. package/src/hub/lib/brief.mjs +133 -0
  28. package/src/hub/lib/channels.mjs +268 -0
  29. package/src/hub/lib/chat.mjs +163 -0
  30. package/src/hub/lib/closure.mjs +92 -0
  31. package/src/hub/lib/collect-worker.mjs +20 -0
  32. package/src/hub/lib/collect.mjs +342 -0
  33. package/src/hub/lib/deliveries.mjs +222 -0
  34. package/src/hub/lib/git.mjs +93 -0
  35. package/src/hub/lib/me.mjs +123 -0
  36. package/src/hub/lib/personas.mjs +143 -0
  37. package/src/hub/lib/status.mjs +147 -0
  38. package/src/hub/lib/store.mjs +106 -0
  39. package/src/hub/lib/titles.mjs +29 -0
  40. package/src/hub/lib/transcript-feed.mjs +72 -0
  41. package/src/hub/lib/transcripts.mjs +364 -0
  42. package/src/hub/lib/writing.mjs +18 -0
  43. package/src/hub/projections/deliveries.mjs +31 -0
  44. package/src/hub/projections/graph.mjs +149 -0
  45. package/src/hub/runners/drivers.mjs +162 -0
  46. package/src/hub/workers/collector.mjs +128 -0
  47. package/src/hub/workers/manager.mjs +366 -0
  48. package/src/hub/workers/runner.mjs +145 -0
  49. package/src/lib/application.js +82 -34
  50. package/src/lib/channelScanner.js +99 -0
  51. package/src/lib/clientInstaller.js +111 -0
  52. package/src/lib/cloudFirstCommands.js +293 -0
  53. package/src/lib/contract.sha256 +1 -1
  54. package/src/lib/cutover.js +376 -0
  55. package/src/lib/cutoverWiring.js +137 -0
  56. package/src/lib/daemonLock.js +2 -0
  57. package/src/lib/daemonLoop.js +5 -2
  58. package/src/lib/daemonProcess.js +16 -2
  59. package/src/lib/daemonRuntime.js +8 -0
  60. package/src/lib/doctorWarnings.js +53 -0
  61. package/src/lib/errors.js +14 -0
  62. package/src/lib/hubDbReader.js +132 -0
  63. package/src/lib/hubMapping.js +108 -0
  64. package/src/lib/hubScan.js +611 -0
  65. package/src/lib/hubSnapshot.js +64 -0
  66. package/src/lib/mirrorContract.js +1 -1
  67. package/src/lib/mirrorService.js +8 -1
  68. package/src/lib/mirrorSource.js +13 -38
  69. package/src/lib/nodeFloor.js +19 -0
  70. package/src/lib/outboxFlusher.js +75 -0
  71. package/src/lib/pairingLifecycle.js +1 -1
  72. package/src/lib/reconcile.js +316 -0
  73. package/src/lib/reconcileView.js +129 -0
  74. package/src/lib/runnerStore.js +418 -0
  75. package/src/lib/serviceManager.js +61 -0
  76. package/src/lib/shadowService.js +435 -0
  77. package/src/lib/sqlite.js +28 -0
  78. package/src/lib/tokenStore.js +43 -4
  79. package/src/lib/transport.js +53 -5
  80. package/src/lib/transportContract.js +3 -0
  81. package/src/lib/uploadRedactor.js +102 -0
  82. package/src/runner/applier.js +297 -0
  83. package/src/runner/bridge.js +289 -0
  84. package/src/runner/busIdentity.js +499 -0
  85. package/src/runner/capture.js +103 -0
  86. package/src/runner/captureMapping.js +21 -0
  87. package/src/runner/channelDiscipline.js +425 -0
  88. package/src/runner/deliveryStates.js +220 -0
  89. package/src/runner/facade.js +876 -0
  90. package/src/runner/hubCapture.js +160 -0
  91. package/src/runner/lease.js +46 -0
  92. package/src/runner/main.js +35 -0
  93. package/src/runner/render.js +91 -0
  94. package/src/runner/runnerChild.js +57 -0
  95. package/src/runner/runnerHub.js +104 -0
  96. package/src/runner/runnerProcess.js +124 -0
  97. package/src/runner/runnerSchema.js +51 -0
  98. package/src/runner/runnerService.js +140 -0
  99. package/src/runner/spool.js +86 -0
  100. package/src/runner/spoolInto.js +22 -0
  101. package/src/runner/statusPage.js +30 -0
package/README.md CHANGED
@@ -77,6 +77,52 @@ How it reads and what it sends:
77
77
  - Rule packs include only `*.mdc` under `.cursor/rules/`, plus `AGENTS.md` and `CLAUDE.md`, from the workspace, each repo and your user folder. Files named like `.env*`, `*secret*` or `*token*`, symlinks that leave the pack, and oversized files are skipped.
78
78
  - Transcripts, diffs, raw provider output, the hub token, Keychain and OAuth material are never read for the mirror or sent.
79
79
 
80
+ ## Import and shadow into the Pulse log (cloud-first preview)
81
+
82
+ These need your team's Pulse cloud-first flag on, set by a team owner or admin, and Node 24 or later.
83
+
84
+ - `agentpulse pulse import --dry-run` reads the local Agent Hub without changing anything and prints, for each source, the rows it read and what it would send, skip or refuse.
85
+ - `agentpulse pulse import` snapshots the hub's sources (`hub.db`, personas, chat files and every channel file) into the connector's folder, then brings the snapshot into the Pulse log. `--reuse-snapshot` imports the same snapshot again, which writes nothing new. A later `pulse import` takes a new snapshot and sends only what changed since, so the final catch-up at cutover is an import, not the shadow tail.
86
+ - `agentpulse pulse import --reconcile` rescans the last snapshot from scratch and compares it with what Pulse holds for this machine: every channel's posts, every conversation's messages and receipts, actors, personas, agent rows and facts, by count and hash, plus every withheld or cut item by key. Each differing channel or conversation is classified. `retired` means a channel whose `channel.md` is gone from disk under every root. `redacted` means Pulse holds the same text redacted with a root deleted since; a rescan with that root must give exactly Pulse's hash, and the root is then kept. `drift`, `missing` and `unexplained` are mismatches. It exits 1 on any mismatch, never sends anything, and keeps the report, with the counts per class, in `reconcile-last.json`. `pulse cutover` records the classes, the retired channels and the redacted items in `cutover.json`.
87
+ - `agentpulse pulse shadow on` makes the daemon tail the live hub into the log after an import. It is meant for cutover only.
88
+ - `agentpulse pulse parity` shows how the Pulse log and this machine's mirror agree while the shadow runs. `--now` asks the daemon for a fresh check.
89
+
90
+ Everything sent goes through the same secret and path filter as the mirror, and the cloud filters it again.
91
+
92
+ ## Bus identity and delivery states (1.1.1)
93
+
94
+ After cutover the runner hosts the bus, and `hub.mjs bus` works as before with these additions:
95
+
96
+ - **One identity per session.** A session's bus address is its Cursor conversation id, which for a subagent is its Task agent id. `bus register --as <nickname>`, run as a Shell command inside the session, binds the nickname to it, and the hook says whether the binding took. Mail to the nickname then reaches that session.
97
+ - **Delivery states.** `bus publish` and `bus ask` add `delivery` (`queued` or `undeliverable`, and why) and print one stderr line for each recipient nobody can read. `bus status --id m-…` shows each recipient as `undeliverable`, `queued`, `delivered`, `read`, `skipped`, `acked` or `expired`.
98
+ - `read` means a step of the recipient's session ran after the message was shown, or the manager recorded a DONE or STATUS.
99
+ - `acked` comes only from `bus ack`, `bus reply`, a captured answer to your message, or a manager answer.
100
+ - **`bus who`** lists active sessions by default; `--all` lists history.
101
+
102
+ **Known limits:**
103
+
104
+ - A binding is recorded on the machine whose hook saw the register. Other machines learn that the alias is live from presence within about 30 seconds (beats are coalesced), and then report it `queued`. A send from another machine inside that window is reported `undeliverable`, though it is still queued and delivered. From 1.1.2 it is refused as unknown instead (see below).
105
+ - A `bus register` chained to a failing command binds only if `~/.cursor/hooks.json` sends `postToolUseFailure` to the hub hook; `pulse doctor` says which events are missing.
106
+ - `bus listen`, `bus ack` and `bus reply` send the Cursor session they run in (`CURSOR_CONVERSATION_ID`).
107
+ - The session holding an alias acts for its whole inbox, whether it names itself or the alias.
108
+ - Another Cursor session is refused on an alias a live session holds ("held by S", exit 2), before anything is claimed.
109
+ - A command run outside Cursor sends no session. It gets the mail sent literally to the address it names, as before 1.1.1, and never the holder's own mail.
110
+ - **Threat model.** The session field guards agents against each other's mistakes: a subagent listening as a nickname it shouldn't, or a stale script left running. It is not a security boundary. Any local process that can read `state/hub.token` can set the field, or do anything else the hub allows, exactly as before 1.1.1.
111
+ - The binding parser matches only `node <path>/hub.mjs bus register|listen … --as X`, with the path written out. A command such as `node "$HUB" …` does not bind.
112
+
113
+ **Release note:** from this version the plugin needs **Node 24 or later**: `runner.db` and the read-only `hub.db` reader use `node:sqlite`, and on Node 22 and older `fetch` can lose an abort on a long-lived stream. On older Node, `agentpulse pulse start`, `agentpulse pulse doctor` and the daemon refuse with a clear message.
114
+
115
+ ## Delivery discipline checks (1.1.2)
116
+
117
+ The runner checks the delivery-discipline rules itself, from the Cursor hook and the manager lease holder's sweep:
118
+
119
+ - **Unknown addresses (E1).** Mail to an `agent/<x>` that is neither a known session nor a bound alias is refused (`E_UNKNOWN_ADDRESS`, exit 2), with the closest addresses. Known means: a session a hook has shown (here or on another machine), a bound alias, an address that has listened, or a session or Task id (UUID-shaped) that ran `bus register` itself. That last case takes no proof, so a Task id can be mailed as soon as it registers. A nickname nobody bound is refused. Mail to an address idle more than 2 h is kept, and the sender sees `undeliverable: <x> offline since <t>`.
120
+ - **Channels (E2, E3, E4).** Which posts a session wrote is learned from its own tool calls: the channel they name and the header lines they write. A session that ends with a non-terminal newest post gets an automatic `STOPPED`. A channel whose newest post isn't terminal gets `STATUS?` to its owner at 30 minutes, and one escalation to its architect and to you at 60 minutes. A header stamped more than 5 minutes ahead of the clock is flagged to its writer and on the Board.
121
+ - **Subagents (E5).** A Task launch in this workspace is denied unless its brief names a `.agents/<task>/channel.md` with a KICKOFF. Read-only subagents (`explore` and the like, or `readonly: true`) and resumes are exempt.
122
+ - **Permission prompts (E6).** A Shell call with `required_permissions` posts a BLOCKER to its session's channel and to you, and the call's end clears it. It fires on every such call, including ones Cursor approves on its own.
123
+
124
+ **Known limits:** another machine learns of an alias bound here only from presence, within about 30 seconds (beats are coalesced). Until then it doesn't know the alias at all and refuses a send to it with `E_UNKNOWN_ADDRESS` (1.1.1 queued it as undeliverable); a retry after that window goes through. A subagent's end reaches its parent as `subagentStop`, which carries no subagent id, so E2 covers subagents only through E3's 30- and 60-minute checks. Whether Cursor sends `required_permissions` in `preToolUse`, before its prompt, is unverified live; the runner records each event's field names in `state/hook-shapes.jsonl`.
125
+
80
126
  ## Local checks
81
127
 
82
128
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentpulselabs/cli-plugin-pulse",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
4
4
  "description": "AgentPulse CLI plugin for installing and operating the Pulse local connector (replaces the deprecated build-discipline Pulse plugin).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,11 +19,14 @@
19
19
  }
20
20
  },
21
21
  "scripts": {
22
- "test": "node --import ./test/support/register-loader.mjs --test \"test/**/*.test.js\"",
23
- "check": "node --check src/commands/pulse/install.js && node --check src/commands/pulse/pair.js && node --check src/commands/pulse/login.js && node --check src/commands/pulse/start.js && node --check src/commands/pulse/status.js && node --check src/commands/pulse/doctor.js && node --check src/commands/pulse/stop.js && node --check src/commands/pulse/restart.js && node --check src/lib/selfRestart.js && node --check src/lib/subprocess.js && node --check src/lib/daemonProcess.js && node --check src/commands/pulse/mirror.js && node --check src/commands/pulse/hub-write.js && node --check src/lib/localHubWriter.js && node --check src/lib/hubActions.js && node --check src/lib/localHub.js && node --check src/lib/pathRedaction.js && node --check src/lib/rulePacks.js && node --check src/lib/mirrorContract.js && node --check src/lib/graphWindows.js && node --check src/lib/mirrorSource.js && node --check src/lib/mirrorSync.js && node --check src/lib/mirrorService.js && node --check src/lib/mirrorDaemon.js && node --check src/lib/daemonComposition.js && node --check src/lib/daemonRuntime.js && node --check src/lib/daemonLoop.js && node --check src/daemon/main.js"
22
+ "test": "node --import ./test/support/register-loader.mjs --test \"test/*.test.js\" \"test/hub/*.test.js\"",
23
+ "contract:check": "node scripts/sync-contract.mjs --local",
24
+ "check": "node --check src/commands/pulse/install.js && node --check src/commands/pulse/pair.js && node --check src/commands/pulse/login.js && node --check src/commands/pulse/start.js && node --check src/commands/pulse/status.js && node --check src/commands/pulse/doctor.js && node --check src/commands/pulse/stop.js && node --check src/commands/pulse/restart.js && node --check src/lib/selfRestart.js && node --check src/lib/subprocess.js && node --check src/lib/daemonProcess.js && node --check src/commands/pulse/mirror.js && node --check src/commands/pulse/hub-write.js && node --check src/lib/localHubWriter.js && node --check src/lib/hubActions.js && node --check src/lib/localHub.js && node --check src/lib/pathRedaction.js && node --check src/lib/rulePacks.js && node --check src/lib/mirrorContract.js && node --check src/lib/graphWindows.js && node --check src/lib/mirrorSource.js && node --check src/lib/mirrorSync.js && node --check src/lib/mirrorService.js && node --check src/lib/mirrorDaemon.js && node --check src/lib/daemonComposition.js && node --check src/lib/daemonRuntime.js && node --check src/lib/daemonLoop.js && node --check src/daemon/main.js && node --check src/lib/sqlite.js && node --check src/lib/nodeFloor.js && node --check src/lib/runnerStore.js && node --check src/lib/hubDbReader.js && node --check src/lib/channelScanner.js && node --check src/lib/uploadRedactor.js && node --check src/lib/hubScan.js && node --check src/lib/outboxFlusher.js && node --check src/lib/hubSnapshot.js && node --check src/lib/reconcileView.js && node --check src/lib/reconcile.js && node --check src/lib/shadowService.js && node --check src/lib/cloudFirstCommands.js && node --check src/commands/pulse/import.js && node --check src/commands/pulse/shadow.js && node --check src/commands/pulse/parity.js && node --check src/runner/main.js && node --check src/runner/runnerProcess.js && node --check src/runner/facade.js && node --check src/runner/bridge.js && node --check src/runner/runnerChild.js && node --check src/runner/applier.js && node --check src/runner/capture.js && node --check src/runner/hubCapture.js && node --check src/clients/hub.mjs && node --check src/clients/hub-bridge.mjs && node --check src/runner/busIdentity.js && node --check src/runner/deliveryStates.js && node --check src/lib/doctorWarnings.js && node --check src/hub/lib/closure.mjs && node --check src/runner/channelDiscipline.js",
25
+ "test:t2": "node --import ./test/support/register-loader.mjs --test --test-timeout=120000 \"test/t2/*.test.js\"",
26
+ "test:t3": "node --import ./test/support/register-loader.mjs --test --test-concurrency=1 --test-timeout=240000 \"test/t3/*.test.js\""
24
27
  },
25
28
  "engines": {
26
- "node": ">=20"
29
+ "node": ">=24"
27
30
  },
28
31
  "keywords": [
29
32
  "oclif-plugin",
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Pulse runner's thin Cursor hook: forwards the hook input, cut to the fields the runner reads, to the runner on
4
+ * 127.0.0.1 (`POST /hook`, where handleHook runs unchanged) and prints its answer. It opens no database.
5
+ * Always fails open: any problem prints {} within 3.5 s, so the chat is never blocked.
6
+ */
7
+ import fs from 'node:fs';
8
+
9
+ const PULSE = /*PULSE_CLIENT_CONFIG*/ null;
10
+ const config = PULSE || JSON.parse(process.env.PULSE_CLIENT_CONFIG || 'null');
11
+
12
+ /**
13
+ * Hard stop, armed before anything else and counted from process start: well under Cursor's 5 s hook timeout even if
14
+ * stdin never closes and the runner is silent. The runner gives up on a hook after 1.5 s and rolls back, inside this
15
+ * client's 2 s call, so it never commits a delivery this client didn't see.
16
+ */
17
+ const HARD_EXIT_MS = 2500;
18
+ const STDIN_MS = 1000;
19
+ const CALL_MS = 2000;
20
+
21
+ let finished = false;
22
+ const done = (o = {}) => {
23
+ if (finished) return;
24
+ finished = true;
25
+ process.stdout.write(JSON.stringify(o));
26
+ process.exit(0);
27
+ };
28
+ setTimeout(() => done(), Math.max(0, HARD_EXIT_MS - performance.now())).unref();
29
+
30
+ /** The fields the runner reads, and the size each may take; a tool's output never goes (it can pass the runner's 64 KB cap). */
31
+ const FIELDS = { hook_event_name: 100, conversation_id: 200, tool_name: 200, text: 32_000, status: 100, tool_use_id: 200, cwd: 1000, reason: 40 };
32
+ const COMMAND_MAX = 4000;
33
+ /** Pulse 1.1.2: a Task brief (E5), a Shell call's permissions and folder (E6), and its other short fields. */
34
+ const TOOL_FIELDS = { prompt: 16_000, description: 1000, resume: 200, subagent_type: 60, cwd: 1000, working_directory: 1000 };
35
+ const HEADER = /^## \[[A-Z][A-Z0-9 -]*\] \S+ /;
36
+ const CHANNEL_LINE = /\.agents\/[A-Za-z0-9._-]+/;
37
+
38
+ /**
39
+ * E2, E4: the post headers a call wrote and the lines naming the channel folder it wrote them to, from any string the
40
+ * tool took (a Shell heredoc, a Write's contents, a StrReplace's new text, a path). Only when something names channel.md.
41
+ */
42
+ function channelWrites(tool) {
43
+ const texts = Object.values(tool).filter((v) => typeof v === 'string');
44
+ if (!texts.some((t) => t.includes('channel.md'))) return {};
45
+ const lines = texts.flatMap((t) => t.split('\n'));
46
+ const headers = lines.filter((l) => HEADER.test(l.trim())).slice(0, 20).map((l) => l.trim().slice(0, 300));
47
+ const channels = lines.filter((l) => CHANNEL_LINE.test(l)).slice(0, 10).map((l) => l.slice(0, 600));
48
+ return headers.length && channels.length ? { headers, channels } : {};
49
+ }
50
+
51
+ /** The hook input cut to what the runner uses: handleHook's fields, a Shell command (for bus binding), and key names only. */
52
+ function trimmed(input) {
53
+ const out = {};
54
+ for (const [key, max] of Object.entries(FIELDS)) if (typeof input[key] === 'string') out[key] = input[key].slice(0, max);
55
+ if (!out.conversation_id && typeof input.session_id === 'string') out.conversation_id = input.session_id.slice(0, 200);
56
+ if (Number.isFinite(input.loop_count)) out.loop_count = input.loop_count;
57
+ if (Array.isArray(input.workspace_roots)) out.workspace_roots = input.workspace_roots.filter((r) => typeof r === 'string').slice(0, 20).map((r) => r.slice(0, 1000));
58
+ const tool = input.tool_input && typeof input.tool_input === 'object' && !Array.isArray(input.tool_input) ? input.tool_input : null;
59
+ if (tool) {
60
+ const kept = {};
61
+ if (typeof tool.command === 'string') kept.command = tool.command.slice(0, COMMAND_MAX);
62
+ for (const [key, max] of Object.entries(TOOL_FIELDS)) if (typeof tool[key] === 'string') kept[key] = tool[key].slice(0, max);
63
+ if (typeof tool.readonly === 'boolean') kept.readonly = tool.readonly;
64
+ if (Array.isArray(tool.required_permissions)) kept.required_permissions = tool.required_permissions.filter((p) => typeof p === 'string').slice(0, 10).map((p) => p.slice(0, 40));
65
+ Object.assign(kept, channelWrites(tool));
66
+ if (Object.keys(kept).length) out.tool_input = kept;
67
+ }
68
+ out._keys = { input: Object.keys(input).slice(0, 50), ...(tool ? { tool_input: Object.keys(tool).slice(0, 50) } : {}) };
69
+ return out;
70
+ }
71
+
72
+ async function forward(raw) {
73
+ let input;
74
+ try { input = JSON.parse(raw || '{}'); } catch { return {}; }
75
+ if (!input || typeof input !== 'object' || Array.isArray(input) || !config || config.offline) return {};
76
+ const token = fs.readFileSync(config.tokenFile, 'utf8').trim();
77
+ const res = await fetch(`http://127.0.0.1:${config.port}/hook`, {
78
+ method: 'POST',
79
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
80
+ body: JSON.stringify(trimmed(input)),
81
+ signal: AbortSignal.timeout(CALL_MS),
82
+ });
83
+ if (res.status !== 200) return {};
84
+ const answer = JSON.parse(await res.text());
85
+ return answer && typeof answer === 'object' && !Array.isArray(answer) ? answer : {};
86
+ }
87
+
88
+ let raw = '';
89
+ let sent = false;
90
+ const read = () => {
91
+ if (sent) return;
92
+ sent = true;
93
+ forward(raw).then(done, () => done());
94
+ };
95
+ process.stdin.setEncoding('utf8');
96
+ process.stdin.on('data', (c) => { raw += c; });
97
+ process.stdin.on('end', read);
98
+ process.stdin.on('error', () => done());
99
+ setTimeout(() => { process.stdin.pause(); read(); }, STDIN_MS).unref();
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ // agents-hub. Usage:
3
+ // node hub.mjs serve [--port 8890] map UI at http://127.0.0.1:8890 (?demo = scripted mockup)
4
+ // node hub.mjs map [--hours 6] text summary of the same map, for a chat session
5
+ // node hub.mjs request --from <id> --kind platform|script|rule --title "…" [--files a,b]
6
+ // node hub.mjs post --from <id> --to <id|*> --type NOTE --text "…"
7
+ // node hub.mjs wait --for <id> [--timeout 900]
8
+ // node hub.mjs bus publish|ask|listen|reply|register|who durable addressed messages with acknowledgements
9
+ //
10
+ // The Pulse runner's thin client for `hub.mjs`: the same commands, flags, output and exit codes, carried over HTTP to
11
+ // the runner on 127.0.0.1. It opens no database. Writes made while the runner is down go to an append-only spool
12
+ // that the runner imports once, under the id printed here. Node built-ins only, so it runs from any folder.
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ const PULSE = /*PULSE_CLIENT_CONFIG*/ null;
18
+ const config = PULSE || JSON.parse(process.env.PULSE_CLIENT_CONFIG || 'null');
19
+
20
+ const args = process.argv.slice(2);
21
+ const cmd = args[0];
22
+ const opt = (name, fallback) => { const i = args.indexOf(`--${name}`); return i > 0 && args[i + 1] !== undefined ? args[i + 1] : fallback; };
23
+ const VALUE_OPTIONS = new Set(['id', 'as', 'reply', 'body', 'name', 'reaction', 'topic', 'type', 'ttl-ms', 'role', 'topics', 'parent',
24
+ 'timeout', 'for', 'from', 'to', 'text', 'kind', 'title', 'files', 'hours', 'port', 'supersedes', 'minutes'].map((o) => `--${o}`));
25
+ const FLAGS = new Set(['--ack', '--reload', '--all']);
26
+ /**
27
+ * Bare words after the subcommand, e.g. the answer in `bus reply --id m-… --as <me> "<answer>"`. Only known options
28
+ * take a value; after `--` everything is the answer, so text like "--force is needed" stays whole.
29
+ */
30
+ function positional(from) {
31
+ const out = [];
32
+ for (let i = from; i < args.length; i++) {
33
+ if (args[i] === '--') { out.push(...args.slice(i + 1)); break; }
34
+ if (VALUE_OPTIONS.has(args[i])) { i++; continue; }
35
+ if (FLAGS.has(args[i])) continue;
36
+ out.push(args[i]);
37
+ }
38
+ return out.join(' ').trim();
39
+ }
40
+
41
+ // The hub's own checks (core/ids.mjs at 6b2fb0b), so a write spooled offline is one the runner will accept.
42
+ const TOPIC = /^(agent\/[A-Za-z0-9-]{8,80}|agent\/(manager|user)|stream\/[A-Za-z0-9._-]{1,80}\/[A-Za-z0-9._-]{1,80}|delivery\/[A-Za-z0-9-]{8,80}|role\/[A-Z][A-Z0-9-]{0,30}|manager|user|broadcast)$/;
43
+ const TYPE = /^[A-Z][A-Z0-9 _-]{0,30}$/;
44
+ const ADDRESS = /^([A-Za-z0-9-]{8,80}|manager|user)$/;
45
+ const MESSAGE_ID = /^m-[a-z0-9-]{4,120}$/;
46
+ const BODY_MAX = 4000;
47
+ const DEFAULT_TTL_MS = 24 * 3600e3;
48
+
49
+ /** Held at most this long by the runner; the client waits a little longer, so the runner always answers first. */
50
+ const LISTEN_HOLD_S = 25;
51
+ const LISTEN_MARGIN_MS = 3000;
52
+ const WRITE_TIMEOUT_MS = 8000;
53
+ const READ_TIMEOUT_MS = 8000;
54
+
55
+ const print = (value, pretty = false) => console.log(pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value));
56
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
57
+ const newId = (at = Date.now()) => `m-${at.toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
58
+ const replyId = (id, as) => `m-r-${crypto.createHash('sha256').update(['r', id, as].join('\0')).digest('hex').slice(0, 24)}`;
59
+ /** The Cursor session this command runs in, when it runs in one: the runner lets only that session act for its aliases. */
60
+ const SESSION = ADDRESS.test(String(process.env.CURSOR_CONVERSATION_ID || '')) ? process.env.CURSOR_CONVERSATION_ID : null;
61
+ const withSession = (body) => (SESSION ? { ...body, session: SESSION } : body);
62
+
63
+ /** Thrown for anything that means "the runner did not answer": not listening, timed out, 5xx, or no token to send. */
64
+ class Unreachable extends Error {}
65
+
66
+ function token() {
67
+ try {
68
+ const value = fs.readFileSync(config.tokenFile, 'utf8').trim();
69
+ if (value) return value;
70
+ } catch { /* no token */ }
71
+ throw new Unreachable('no hub token');
72
+ }
73
+
74
+ async function call(method, route, body, timeoutMs) {
75
+ // During a cutover's freeze the client is installed offline: every write spools, nothing reaches any hub.
76
+ if (!config || config.offline) throw new Unreachable(config ? 'offline' : 'not configured');
77
+ let res;
78
+ try {
79
+ res = await fetch(`http://127.0.0.1:${config.port}${route}`, {
80
+ method,
81
+ headers: { authorization: `Bearer ${token()}`, ...(body ? { 'content-type': 'application/json' } : {}) },
82
+ body: body ? JSON.stringify(body) : undefined,
83
+ signal: AbortSignal.timeout(timeoutMs),
84
+ });
85
+ } catch (error) {
86
+ if (error instanceof Unreachable) throw error;
87
+ throw new Unreachable(error && error.message);
88
+ }
89
+ if (res.status >= 500 && res.status !== 507) throw new Unreachable(`runner answered ${res.status}`);
90
+ let parsed;
91
+ try { parsed = JSON.parse(await res.text()); } catch { throw new Unreachable('runner answered with no JSON'); }
92
+ if (!parsed || typeof parsed !== 'object') throw new Unreachable('runner answered with no JSON');
93
+ return { status: res.status, body: parsed };
94
+ }
95
+
96
+ /** One spool line per write: append-only, owner-only, imported once by the runner under the same id. */
97
+ function spool(op, body) {
98
+ const dir = config && config.spoolDir;
99
+ if (!dir) throw new Error('no spool folder');
100
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
101
+ const at = Date.now();
102
+ const day = new Date(at).toISOString().slice(0, 10).replaceAll('-', '');
103
+ const fd = fs.openSync(path.join(dir, `${day}.jsonl`), fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT, 0o600);
104
+ try { fs.writeSync(fd, `${JSON.stringify({ v: 1, at, op, body })}\n`); } finally { fs.closeSync(fd); }
105
+ return at;
106
+ }
107
+
108
+ /**
109
+ * What a publish could not reach, on stderr, so it is seen even when stdout is cut short: one line per recipient
110
+ * nobody can read now, or one line when the topic reached nobody. The command still succeeds.
111
+ */
112
+ function warnUndelivered(answer) {
113
+ if (answer && answer.error === 'E_UNKNOWN_ADDRESS') {
114
+ const near = Array.isArray(answer.didYouMean) ? answer.didYouMean.map((d) => `agent/${d.address}${d.label ? ` (${d.label})` : ''}`) : [];
115
+ console.error(`unknown address: agent/${answer.address} is not a known session id or bound alias; nothing was sent${near.length ? `. Did you mean ${near.join(', ')}?` : ''}`);
116
+ return;
117
+ }
118
+ if (!answer || answer.error || !Array.isArray(answer.delivery)) return;
119
+ if (Array.isArray(answer.to) && !answer.to.length) {
120
+ console.error(`no recipients: ${answer.topic} reaches nobody right now, so nothing was queued`);
121
+ return;
122
+ }
123
+ const hours = Math.round((Number(answer.expiresAt) - Number(answer.at)) / 3600e3) || 24;
124
+ for (const d of answer.delivery) {
125
+ if (d.state !== 'undeliverable') continue;
126
+ if (d.reason === 'offline') {
127
+ console.error(`undeliverable: ${d.to} offline since ${new Date(d.offlineSince).toISOString()} (queued ${hours} h; it is delivered if its session comes back, binds or listens)`);
128
+ continue;
129
+ }
130
+ const why = d.reason === 'stale' ? `its session ${d.session} has had no activity for ${d.idleMin} min` : 'has no reader seen from this machine';
131
+ console.error(`undeliverable: ${d.to} ${why} (queued ${hours} h; it is delivered if a session binds or listens)`);
132
+ }
133
+ }
134
+
135
+ /** A write: answered by the runner when it is up, spooled when it isn't. Prints what the hub would have printed. */
136
+ async function write(route, op, body, offline) {
137
+ try {
138
+ const { status, body: answer } = await call('POST', route, body, WRITE_TIMEOUT_MS);
139
+ print(answer);
140
+ if (status >= 400 || answer.error) process.exitCode = 2;
141
+ if (route === '/bus/publish' || route === '/bus/ack') warnUndelivered(answer);
142
+ return;
143
+ } catch (error) {
144
+ if (!(error instanceof Unreachable)) throw error;
145
+ }
146
+ let at;
147
+ try { at = spool(op, body); } catch {
148
+ print({ error: 'pulse_offline_spool_failed' });
149
+ process.exitCode = 2;
150
+ return;
151
+ }
152
+ print({ ...offline(at), spooled: true });
153
+ }
154
+
155
+ function offlineFailure() {
156
+ print({ error: 'pulse_offline' });
157
+ process.exitCode = 2;
158
+ }
159
+
160
+ async function listen() {
161
+ const as = opt('as');
162
+ const deadline = Date.now() + Math.max(0, Number(opt('timeout', 25))) * 1000;
163
+ if (as) {
164
+ try {
165
+ await call('POST', '/bus/register', { id: as, role: opt('role'), name: opt('name'), topics: String(opt('topics', '')).split(',').filter(Boolean), parent: opt('parent') }, WRITE_TIMEOUT_MS);
166
+ } catch (error) {
167
+ if (!(error instanceof Unreachable)) throw error;
168
+ }
169
+ }
170
+ do {
171
+ const left = Math.max(0, deadline - Date.now());
172
+ if (!as) { await sleep(Math.min(left, 1000)); continue; }
173
+ const hold = Math.min(LISTEN_HOLD_S, Math.ceil(left / 1000));
174
+ try {
175
+ const { body } = await call('POST', '/bus/listen', withSession({ as, timeout: hold, heartbeat: true }), hold * 1000 + LISTEN_MARGIN_MS);
176
+ if (body.error === 'held') { print(body); process.exitCode = 2; return; }
177
+ if (Array.isArray(body.messages) && body.messages.length) { print({ messages: body.messages }); return; }
178
+ if (body.error) await sleep(Math.min(left, 1000));
179
+ } catch (error) {
180
+ if (!(error instanceof Unreachable)) throw error;
181
+ await sleep(Math.min(left, 1000));
182
+ }
183
+ } while (Date.now() < deadline);
184
+ print({ messages: [], type: 'TIMEOUT' });
185
+ }
186
+
187
+ async function busCli() {
188
+ const action = args[1];
189
+ if (action === 'register') {
190
+ const body = { id: opt('as'), role: opt('role'), name: opt('name'), topics: String(opt('topics', '')).split(',').filter(Boolean), parent: opt('parent') };
191
+ if (!ADDRESS.test(String(body.id || ''))) { print({ error: 'bad address' }); process.exitCode = 2; return; }
192
+ return write('/bus/register', 'register', body, () => ({ id: body.id }));
193
+ }
194
+ if (action === 'publish' || action === 'ask') {
195
+ const at = Date.now();
196
+ const body = {
197
+ id: newId(at), from: opt('as'), fromName: opt('name'), topic: opt('topic'), type: opt('type', action === 'ask' ? 'QUESTION' : 'MESSAGE'),
198
+ body: opt('body') || positional(2), needsAck: action === 'ask' || args.includes('--ack'), ttlMs: Number(opt('ttl-ms')) || undefined,
199
+ ...(opt('supersedes') !== undefined ? { supersedes: opt('supersedes') } : {}),
200
+ };
201
+ const refused = !ADDRESS.test(String(body.from || '')) ? 'bad sender' : !TOPIC.test(String(body.topic || '')) ? 'bad topic'
202
+ : !TYPE.test(String(body.type || '')) ? 'bad type' : (typeof body.body !== 'string' || !body.body.trim()) ? 'empty body'
203
+ : (body.supersedes !== undefined && !MESSAGE_ID.test(String(body.supersedes))) ? 'bad supersedes' : null;
204
+ if (refused) { print({ error: refused }); process.exitCode = 2; return; }
205
+ return write('/bus/publish', 'publish', body, (spooledAt) => ({
206
+ id: body.id, at: spooledAt, from: body.from, fromName: typeof body.fromName === 'string' ? body.fromName.slice(0, 80) : null,
207
+ topic: body.topic, type: body.type, body: body.body.slice(0, BODY_MAX), needsAck: !!body.needsAck,
208
+ expiresAt: spooledAt + Math.max(60e3, Math.min(Number(body.ttlMs) || DEFAULT_TTL_MS, 7 * DEFAULT_TTL_MS)),
209
+ replyTo: null, reaction: null, to: [], meta: body.supersedes ? { supersedes: body.supersedes } : {},
210
+ }));
211
+ }
212
+ if (action === 'listen') return listen();
213
+ if (action === 'ack' || action === 'reply') {
214
+ const body = { as: opt('as'), id: opt('id'), reply: opt('reply') || opt('body') || positional(2) || undefined, fromName: opt('name'), reaction: opt('reaction') };
215
+ if (!ADDRESS.test(String(body.as || '')) || !MESSAGE_ID.test(String(body.id || ''))) { print({ error: 'bad ack' }); process.exitCode = 2; return; }
216
+ const wantsReply = (typeof body.reply === 'string' && body.reply.trim()) || body.reaction;
217
+ return write('/bus/ack', 'ack', withSession(body), () => ({ ok: true, reply: wantsReply ? replyId(body.id, body.as) : null }));
218
+ }
219
+ if (action === 'who') {
220
+ // Active sessions only, unless --all (F4); a runner from before 1.1.1 has no /bus/who and answers the old list.
221
+ const query = `all=${args.includes('--all') ? 1 : 0}${opt('minutes') ? `&minutes=${encodeURIComponent(opt('minutes'))}` : ''}`;
222
+ try {
223
+ const who = await call('GET', `/bus/who?${query}`, null, READ_TIMEOUT_MS);
224
+ if (who.status === 404) print({ registrations: (await call('GET', '/bus/directory', null, READ_TIMEOUT_MS)).body.registrations }, true);
225
+ else print(who.body, true);
226
+ } catch (error) {
227
+ if (!(error instanceof Unreachable)) throw error;
228
+ offlineFailure();
229
+ }
230
+ return;
231
+ }
232
+ if (action === 'status') {
233
+ const id = opt('id');
234
+ if (!MESSAGE_ID.test(String(id || ''))) { print({ error: 'bad id' }); process.exitCode = 2; return; }
235
+ try {
236
+ const { status, body: answer } = await call('GET', `/bus/status?id=${encodeURIComponent(id)}`, null, READ_TIMEOUT_MS);
237
+ print(answer, true);
238
+ if (status >= 400 || answer.error) process.exitCode = 2;
239
+ } catch (error) {
240
+ if (!(error instanceof Unreachable)) throw error;
241
+ offlineFailure();
242
+ }
243
+ return;
244
+ }
245
+ if (action === 'messages') {
246
+ const since = Number(opt('since')) || 0;
247
+ try { print({ messages: (await call('GET', `/bus/messages?since=${since}&limit=200`, null, READ_TIMEOUT_MS)).body.messages }, true); } catch (error) {
248
+ if (!(error instanceof Unreachable)) throw error;
249
+ offlineFailure();
250
+ }
251
+ return;
252
+ }
253
+ console.error('bus: publish|ask|listen|ack|reply|register|who|messages');
254
+ process.exitCode = 2;
255
+ }
256
+
257
+ function fmtDur(ms) { const m = Math.round(ms / 60e3); return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`; }
258
+ function fmtTok(n) { return n == null ? '—' : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n); }
259
+
260
+ async function printMap() {
261
+ let g;
262
+ try { g = (await call('GET', `/api/legacy/map?hours=${Number(opt('hours', 6))}`, null, 60e3)).body; } catch (error) {
263
+ if (!(error instanceof Unreachable)) throw error;
264
+ offlineFailure();
265
+ return;
266
+ }
267
+ const t = g.totals;
268
+ console.log(`Last ${g.hours} h: ${t.sessions} sessions, ${t.agents} agents (${t.active} active), ${t.messages} messages, ≈${fmtTok(t.tokensEst)} tokens (estimate), ${t.requests} shared-change requests\n`);
269
+ const kids = new Map();
270
+ for (const n of g.nodes) if (n.parent) { if (!kids.has(n.parent)) kids.set(n.parent, []); kids.get(n.parent).push(n); }
271
+ const line = (n, pad) => console.log(`${pad}${n.status === 'active' ? '●' : n.status === 'idle' ? '◐' : '○'} ${n.label.padEnd(pad ? 22 : 46)} ${(n.channel || '').padEnd(14)} ${fmtDur(n.lastAt - n.startedAt).padStart(6)} ≈${fmtTok(n.tokensEst).padStart(5)} ${n.msgsOut}↑ ${n.msgsIn}↓`);
272
+ for (const s of g.nodes.filter((n) => n.kind === 'session' && !n.parent).sort((a, b) => b.lastAt - a.lastAt)) {
273
+ line(s, '');
274
+ for (const k of (kids.get(s.id) || []).sort((a, b) => b.lastAt - a.lastAt)) line(k, ' └ ');
275
+ }
276
+ const loose = g.nodes.filter((n) => n.kind === 'agent' && !n.parent);
277
+ if (loose.length) { console.log('\nAgents without a known parent session:'); for (const n of loose) line(n, ' '); }
278
+ if (g.requests.length) { console.log('\nShared-change requests:'); for (const r of g.requests.slice(0, 15)) console.log(` [${r.status}] ${r.title} (${r.channel || r.from.join(',')})`); }
279
+ }
280
+
281
+ async function legacy(route, body) {
282
+ try {
283
+ const { status, body: answer } = await call('POST', route, body, WRITE_TIMEOUT_MS);
284
+ print(answer);
285
+ if (status >= 400 || answer.error) process.exitCode = 2;
286
+ } catch (error) {
287
+ if (!(error instanceof Unreachable)) throw error;
288
+ offlineFailure();
289
+ }
290
+ }
291
+
292
+ async function wait() {
293
+ const me = opt('for');
294
+ if (!me) { console.error('wait: --for <id> is required'); process.exit(2); }
295
+ const deadline = Date.now() + Number(opt('timeout', 900)) * 1000;
296
+ const started = Date.now();
297
+ while (Date.now() < deadline) {
298
+ try {
299
+ const { body } = await call('POST', '/api/legacy/wait', { for: me, since: started }, WRITE_TIMEOUT_MS);
300
+ if (body.message || body.event) { print(body.message || body.event); return; }
301
+ } catch (error) {
302
+ if (!(error instanceof Unreachable)) throw error;
303
+ }
304
+ await sleep(5000);
305
+ }
306
+ print({ type: 'TIMEOUT' });
307
+ }
308
+
309
+ switch (cmd) {
310
+ case 'serve':
311
+ console.error('The local Agent Hub is retired on this machine: the Pulse runner serves 127.0.0.1:8890. Undo with `agentpulse pulse cutover --undo`.');
312
+ process.exitCode = 78;
313
+ break;
314
+ case 'map': await printMap(); break;
315
+ case 'request':
316
+ await legacy('/api/legacy/request', { from: opt('from'), kind: opt('kind'), title: opt('title'), files: opt('files') });
317
+ break;
318
+ case 'post':
319
+ await legacy('/api/legacy/post', { from: opt('from'), to: opt('to'), type: opt('type'), text: opt('text') });
320
+ break;
321
+ case 'wait': await wait(); break;
322
+ case 'bus': await busCli(); break;
323
+ default: console.log(fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(1, 8).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
324
+ }
@@ -0,0 +1,39 @@
1
+ import { Flags } from '@oclif/core';
2
+ import BaseCommand from '../../lib/BaseCommand.js';
3
+ import { defaultConfigDirectory } from '../../lib/application.js';
4
+ import { createCutover } from '../../lib/cutoverWiring.js';
5
+
6
+ export default class PulseCutover extends BaseCommand {
7
+ static description = 'Move this machine\'s local Agent Hub into the Pulse daemon (a short freeze, a backup kept), check first with --dry-run, or go back with --rollback (restores the backup; loses only what was written after the switch).';
8
+ static examples = [
9
+ '<%= config.bin %> pulse cutover --dry-run',
10
+ '<%= config.bin %> pulse cutover --smoke-channel agentpulse-agent-hub/cf-phase3',
11
+ '<%= config.bin %> pulse cutover --rollback',
12
+ ];
13
+ static flags = {
14
+ 'dry-run': Flags.boolean({ description: 'Check everything and say what would change; change nothing', default: false }),
15
+ rollback: Flags.boolean({ description: 'Back to the local hub from the backup', default: false, exclusive: ['dry-run'] }),
16
+ 'smoke-channel': Flags.string({ description: 'A task channel (<repo>/<task>) for the smoke test\'s one channel post' }),
17
+ };
18
+
19
+ async run() {
20
+ const { flags } = await this.parse(PulseCutover);
21
+ const cutover = this.cutover || await createCutover({ configDir: defaultConfigDirectory(), log: (event, details) => this.log(`${event} ${JSON.stringify(details)}`) });
22
+ if (flags['dry-run']) {
23
+ const report = await cutover.dryRun();
24
+ for (const check of report.checks) this.log(`${check.ok ? 'ok ' : 'FAIL'} ${check.name}${check.detail ? `: ${check.detail}` : ''}${!check.ok && check.fix ? ` (fix: ${check.fix})` : ''}`);
25
+ this.log(`Outage while it runs: ${report.outage.what}; at most ${Math.round(report.outage.boundMs / 60_000)} min.`);
26
+ this.log(report.message);
27
+ if (!report.ok) process.exitCode = 1;
28
+ return report;
29
+ }
30
+ if (flags.rollback) {
31
+ const report = await cutover.rollback();
32
+ this.log(report.message);
33
+ return report;
34
+ }
35
+ const journal = await cutover.run({ smokeChannel: flags['smoke-channel'] ?? null });
36
+ this.log(`Cut over: the Pulse daemon serves 127.0.0.1:${journal.port}. The backup is ${journal.backup.file}. Go back any time with \`agentpulse pulse cutover --rollback\`.`);
37
+ return journal;
38
+ }
39
+ }
@@ -9,6 +9,7 @@ export default class PulseDoctor extends BaseCommand {
9
9
  const { flags } = await this.parse(PulseDoctor);
10
10
  const result = await (this.runtime || getDefaultApplication()).doctor(flags);
11
11
  this.log(result.message);
12
+ for (const warning of result.warnings || []) this.log(`Warning: ${warning}`);
12
13
  return result;
13
14
  }
14
15
  }
@@ -0,0 +1,62 @@
1
+ import { Flags } from '@oclif/core';
2
+ import BaseCommand from '../../lib/BaseCommand.js';
3
+ import { defaultConfigDirectory } from '../../lib/application.js';
4
+ import { CloudFirstCommands } from '../../lib/cloudFirstCommands.js';
5
+
6
+ export default class PulseImport extends BaseCommand {
7
+ static description = 'Import the local Agent Hub into the Pulse log from a snapshot of its sources (idempotent: a re-run on the same snapshot writes nothing). --dry-run reads only and prints what it would send; --reconcile rescans the snapshot and compares it with Pulse, exiting 1 on any mismatch.';
8
+ static examples = ['<%= config.bin %> pulse import --dry-run', '<%= config.bin %> pulse import', '<%= config.bin %> pulse import --reconcile', '<%= config.bin %> pulse import --reuse-snapshot'];
9
+ static flags = {
10
+ 'dry-run': Flags.boolean({ description: 'Read the local hub and report per source; send and write nothing', default: false }),
11
+ wait: Flags.boolean({ description: 'Follow the import until it has fully drained', default: true, allowNo: true }),
12
+ reconcile: Flags.boolean({ description: 'Rescan the last import\'s snapshot and compare every family, channel and conversation with Pulse', default: false }),
13
+ 'reuse-snapshot': Flags.boolean({ description: 'Import the last snapshot again instead of taking a new one', default: false }),
14
+ };
15
+
16
+ async run() {
17
+ const { flags } = await this.parse(PulseImport);
18
+ const commands = this.cloudFirst || new CloudFirstCommands({ configDir: defaultConfigDirectory() });
19
+ if (flags['dry-run']) {
20
+ const result = await commands.dryRun();
21
+ for (const row of result.sources) {
22
+ const detail = [...Object.entries(row.skipped).map(([k, v]) => `skipped ${k} ${v}`), ...Object.entries(row.refused).map(([k, v]) => `refused ${k} ${v}`)];
23
+ this.log(`${row.source.padEnd(9)} read ${row.read} planned ${row.planned} unchanged ${row.unchanged} redactions ${row.redactions}${row.cut ? ` cut ${row.cut}` : ''}${detail.length ? ` (${detail.join(', ')})` : ''}`);
24
+ if (Object.keys(row.events).length) this.log(` events ${Object.entries(row.events).map(([type, n]) => `${type} ${n}`).join(', ')}`);
25
+ }
26
+ this.log(result.message);
27
+ if (!result.totals.balanced) process.exitCode = 1;
28
+ return result;
29
+ }
30
+ let last = '';
31
+ if (flags.reconcile) {
32
+ const result = await commands.reconcile({
33
+ onProgress: (progress) => {
34
+ const line = `Reconcile: ${progress.state}${progress.code ? ` (${progress.code})` : ''}`;
35
+ if (line !== last) this.log(line);
36
+ last = line;
37
+ },
38
+ });
39
+ for (const [family, row] of Object.entries(result.families || {})) {
40
+ this.log(`${family.padEnd(18)} local ${String(row.local).padStart(6)} pulse ${String(row.cloud).padStart(6)} ${row.equal ? 'equal' : 'DIFFERS'}`);
41
+ }
42
+ for (const [source, row] of Object.entries(result.sources || {})) {
43
+ this.log(`source ${source.padEnd(9)} rows ${row.counted ?? '—'} read ${row.read} accounted ${row.accounted}`);
44
+ }
45
+ for (const item of (result.mismatches || []).slice(0, 20)) this.log(`MISMATCH ${item.family}${item.item ? ` ${item.item}` : ''}${item.note ? `: ${item.note}` : ''}`);
46
+ this.log(result.message);
47
+ process.exitCode = result.exitCode;
48
+ return result;
49
+ }
50
+ const result = await commands.import({
51
+ wait: flags.wait,
52
+ reuseSnapshot: flags['reuse-snapshot'],
53
+ onProgress: (progress) => {
54
+ const line = `Import: ${progress.state}${progress.code ? ` (${progress.code})` : ''}${progress.pending !== null && progress.pending !== undefined ? `, ${progress.pending} commands waiting` : ''}`;
55
+ if (line !== last) this.log(line);
56
+ last = line;
57
+ },
58
+ });
59
+ this.log(result.message);
60
+ return result;
61
+ }
62
+ }